diff --git a/.github/workflows/checkstyle.yml b/.github/workflows/checkstyle.yml index 5c26a408..0260a845 100644 --- a/.github/workflows/checkstyle.yml +++ b/.github/workflows/checkstyle.yml @@ -1,76 +1,80 @@ -name: Checkstyle - -on: - pull_request: - branches: - - main - - develop - push: - branches: - - main - - develop - -jobs: - checkstyle: - name: Code Style Check - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - submodules: recursive - token: ${{ secrets.SUBMODULE_TOKEN }} - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Run Checkstyle - run: ./gradlew checkstyleMain --no-daemon - - - name: Upload Checkstyle Report - if: failure() - uses: actions/upload-artifact@v4 - with: - name: checkstyle-report - path: | - build/reports/checkstyle/main.html - build/reports/checkstyle/main.xml - retention-days: 7 - - - name: Comment PR with Checkstyle Results - if: github.event_name == 'pull_request' && failure() - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const path = require('path'); - - let comment = '## ⚠️ Checkstyle 위반 사항 발견\n\n'; - comment += 'Checkstyle 검사에서 코딩 컨벤션 위반이 발견되었습니다.\n\n'; - comment += '### 📋 상세 리포트\n'; - comment += '- [Main 소스 리포트 다운로드](../actions/runs/${{ github.run_id }})\n'; - - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: comment - }); +name: Checkstyle + +on: + pull_request: + branches: + - main + - develop + push: + branches: + - main + - develop + +permissions: + contents: read + pull-requests: write + +jobs: + checkstyle: + name: Code Style Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_TOKEN }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run Checkstyle + run: ./gradlew checkstyleMain --no-daemon + + - name: Upload Checkstyle Report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: checkstyle-report + path: | + build/reports/checkstyle/main.html + build/reports/checkstyle/main.xml + retention-days: 7 + + - name: Comment PR with Checkstyle Results + if: github.event_name == 'pull_request' && failure() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const path = require('path'); + + let comment = '## ⚠️ Checkstyle 위반 사항 발견\n\n'; + comment += 'Checkstyle 검사에서 코딩 컨벤션 위반이 발견되었습니다.\n\n'; + comment += '### 📋 상세 리포트\n'; + comment += '- [Main 소스 리포트 다운로드](../actions/runs/${{ github.run_id }})\n'; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubApi.java index b4ffe25d..d437d094 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubApi.java @@ -2,8 +2,10 @@ import org.springdoc.core.annotations.ParameterObject; 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.ModelAttribute; +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.PutMapping; @@ -20,13 +22,21 @@ import gg.agit.konect.domain.club.dto.ClubDetailResponse; import gg.agit.konect.domain.club.dto.ClubFeeInfoReplaceRequest; import gg.agit.konect.domain.club.dto.ClubFeeInfoResponse; +import gg.agit.konect.domain.club.dto.ClubMemberAddRequest; +import gg.agit.konect.domain.club.dto.ClubMemberCondition; import gg.agit.konect.domain.club.dto.ClubMembersResponse; import gg.agit.konect.domain.club.dto.ClubMembershipsResponse; +import gg.agit.konect.domain.club.dto.ClubPositionCreateRequest; +import gg.agit.konect.domain.club.dto.ClubPositionUpdateRequest; +import gg.agit.konect.domain.club.dto.ClubPositionsResponse; import gg.agit.konect.domain.club.dto.ClubRecruitmentCreateRequest; import gg.agit.konect.domain.club.dto.ClubRecruitmentResponse; import gg.agit.konect.domain.club.dto.ClubRecruitmentUpdateRequest; import gg.agit.konect.domain.club.dto.ClubUpdateRequest; import gg.agit.konect.domain.club.dto.ClubsResponse; +import gg.agit.konect.domain.club.dto.MemberPositionChangeRequest; +import gg.agit.konect.domain.club.dto.PresidentTransferRequest; +import gg.agit.konect.domain.club.dto.VicePresidentChangeRequest; import gg.agit.konect.global.auth.annotation.UserId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -70,7 +80,7 @@ ResponseEntity createClub( ); @Operation(summary = "동아리 정보를 수정한다.", description = """ - 동아리 회장 또는 매니저만 동아리 정보를 수정할 수 있습니다. + 동아리 회장 또는 부회장만 동아리 정보를 수정할 수 있습니다. 수정 가능 항목: 동아리명, 한 줄 소개, 로고 이미지, 위치, 분과, 상세 소개 ## 에러 @@ -101,7 +111,7 @@ ResponseEntity getManagedClubs( - 동아리 관리자만 해당 동아리의 지원 내역을 조회할 수 있습니다. - 현재 지정된 모집 일정 범위에 지원한 내역만 볼 수 있습니다. - 상시 모집의 경우 모든 내역을 봅니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -115,7 +125,7 @@ ResponseEntity getClubApplications( @Operation(summary = "동아리 지원 답변을 조회한다.", description = """ - 동아리 관리자만 해당 동아리의 지원 답변을 조회할 수 있습니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -128,10 +138,17 @@ ResponseEntity getClubApplicationAnswers( @UserId Integer userId ); - @Operation(summary = "동아리 멤버 리스트를 조회한다.") + @Operation(summary = "동아리 멤버 리스트를 조회한다.", description = """ + 동아리 회원만 멤버 리스트를 조회할 수 있습니다. + positionGroup 파라미터로 특정 직책 그룹의 회원만 필터링할 수 있습니다. + + ## 에러 + - FORBIDDEN_CLUB_MEMBER_ACCESS (403): 동아리 멤버 조회 권한이 없습니다. + """) @GetMapping("/{clubId}/members") ResponseEntity getClubMembers( @PathVariable(name = "clubId") Integer clubId, + @Valid @ParameterObject @ModelAttribute ClubMemberCondition condition, @UserId Integer userId ); @@ -153,7 +170,7 @@ ResponseEntity applyClub( @Operation(summary = "동아리 회비 정보를 조회한다.", description = """ 동아리 가입 신청을 완료했거나 동아리 관리자 권한이 있는 사용자만 회비 계좌 정보를 조회할 수 있습니다. - + ## 에러 - FORBIDDEN_CLUB_FEE_INFO (403): 회비 정보 조회 권한이 없습니다. """) @@ -168,7 +185,7 @@ ResponseEntity getFeeInfo( - 모든 필드를 전달하면 생성/수정합니다. - 모든 필드가 null이면 회비 정보를 삭제합니다. - 일부 필드가 누락된 경우 에러가 발생합니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - INVALID_REQUEST_BODY (400): 요청 본문의 형식이 올바르지 않거나 필수 값이 누락된 경우 @@ -244,8 +261,8 @@ ResponseEntity createRecruitment( ); @Operation(summary = "동아리 모집 정보를 수정한다.", description = """ - 동아리 회장 또는 매니저만 모집 공고를 수정할 수 있습니다. - + 동아리 회장 또는 부회장만 모집 공고를 수정할 수 있습니다. + ## 에러 - INVALID_RECRUITMENT_DATE_NOT_ALLOWED (400): 상시 모집인 경우 모집 시작일과 마감일을 지정할 수 없습니다. - INVALID_RECRUITMENT_DATE_REQUIRED (400): 상시 모집이 아닐 경우 모집 시작일과 마감일이 필수입니다. @@ -261,4 +278,171 @@ ResponseEntity updateRecruitment( @PathVariable(name = "clubId") Integer clubId, @UserId Integer userId ); + + @Operation(summary = "동아리 직책 목록을 조회한다.", description = """ + 동아리의 모든 직책을 우선순위 순으로 조회합니다. + 각 직책의 회원 수, 수정/삭제 가능 여부도 함께 반환됩니다. + + ## 에러 + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + """) + @GetMapping("/{clubId}/positions") + ResponseEntity getClubPositions( + @PathVariable(name = "clubId") Integer clubId, + @UserId Integer userId + ); + + @Operation(summary = "동아리 직책을 생성한다.", description = """ + 동아리 회장 또는 부회장만 직책을 생성할 수 있습니다. + PRESIDENT와 VICE_PRESIDENT 직책은 생성할 수 없으며, MANAGER 또는 MEMBER 그룹의 직책만 생성 가능합니다. + + ## 에러 + - POSITION_NAME_DUPLICATED (400): 동일한 직책 이름이 이미 존재합니다. + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + """) + @PostMapping("/{clubId}/positions") + ResponseEntity createClubPosition( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubPositionCreateRequest request, + @UserId Integer userId + ); + + @Operation(summary = "동아리 직책의 이름을 수정한다.", description = """ + 동아리 회장 또는 부회장만 직책 이름을 수정할 수 있습니다. + PRESIDENT와 VICE_PRESIDENT 직책의 이름은 변경할 수 없습니다. + + ## 에러 + - POSITION_NAME_DUPLICATED (400): 동일한 직책 이름이 이미 존재합니다. + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. + - FORBIDDEN_POSITION_NAME_CHANGE (403): 해당 직책의 이름은 변경할 수 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + - NOT_FOUND_CLUB_POSITION (404): 동아리 직책을 찾을 수 없습니다. + """) + @PatchMapping("/{clubId}/positions/{positionId}") + ResponseEntity updateClubPositionName( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "positionId") Integer positionId, + @Valid @RequestBody ClubPositionUpdateRequest request, + @UserId Integer userId + ); + + @Operation(summary = "동아리 직책을 삭제한다.", description = """ + 동아리 회장 또는 부회장만 직책을 삭제할 수 있습니다. + PRESIDENT와 VICE_PRESIDENT 직책은 삭제할 수 없습니다. + 해당 직책을 사용 중인 회원이 없어야 하며, 해당 그룹에 최소 2개의 직책이 있어야 삭제 가능합니다. + + ## 에러 + - CANNOT_DELETE_ESSENTIAL_POSITION (400): 필수 직책은 삭제할 수 없습니다. + - POSITION_IN_USE (400): 해당 직책을 사용 중인 회원이 있어 삭제할 수 없습니다. + - INSUFFICIENT_POSITION_COUNT (400): 해당 그룹에 최소 2개의 직책이 있어야 삭제 가능합니다. + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + - NOT_FOUND_CLUB_POSITION (404): 동아리 직책을 찾을 수 없습니다. + """) + @DeleteMapping("/{clubId}/positions/{positionId}") + ResponseEntity deleteClubPosition( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "positionId") Integer positionId, + @UserId Integer userId + ); + + @Operation(summary = "동아리 회원의 직책을 변경한다.", description = """ + 동아리 회장 또는 부회장만 회원의 직책을 변경할 수 있습니다. + 자기 자신의 직책은 변경할 수 없으며, 상위 직급만 하위 직급의 회원을 관리할 수 있습니다. + + ## 에러 + - CANNOT_CHANGE_OWN_POSITION (400): 자기 자신의 직책은 변경할 수 없습니다. + - CANNOT_MANAGE_HIGHER_POSITION (400): 자신보다 높은 직급의 회원은 관리할 수 없습니다. + - VICE_PRESIDENT_ALREADY_EXISTS (409): 부회장은 이미 존재합니다. + - MANAGER_LIMIT_EXCEEDED (400): 운영진은 최대 20명까지 임명 가능합니다. + - FORBIDDEN_MEMBER_POSITION_CHANGE (403): 회원 직책 변경 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + - NOT_FOUND_CLUB_MEMBER (404): 동아리 회원을 찾을 수 없습니다. + - NOT_FOUND_CLUB_POSITION (404): 동아리 직책을 찾을 수 없습니다. + """) + @PatchMapping("/{clubId}/members/{memberId}/position") + ResponseEntity changeMemberPosition( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "memberId") Integer memberId, + @Valid @RequestBody MemberPositionChangeRequest request, + @UserId Integer userId + ); + + @Operation(summary = "동아리 회장 권한을 위임한다.", description = """ + 현재 회장만 회장 권한을 다른 회원에게 위임할 수 있습니다. + 회장 위임 시 현재 회장은 일반회원으로 강등됩니다. + + ## 에러 + - ILLEGAL_ARGUMENT (400): 자기 자신에게는 위임할 수 없습니다. + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 회장 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + - NOT_FOUND_CLUB_MEMBER (404): 동아리 회원을 찾을 수 없습니다. + - NOT_FOUND_CLUB_PRESIDENT (404): 동아리 회장을 찾을 수 없습니다. + - NOT_FOUND_CLUB_POSITION (404): 동아리 직책을 찾을 수 없습니다. + """) + @PostMapping("/{clubId}/president/transfer") + ResponseEntity transferPresident( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody PresidentTransferRequest request, + @UserId Integer userId + ); + + @Operation(summary = "동아리 부회장을 변경한다.", description = """ + 동아리 회장만 부회장을 임명하거나 해제할 수 있습니다. + vicePresidentUserId가 null이면 부회장을 해제하고, 값이 있으면 해당 회원을 부회장으로 임명합니다. + + ## 에러 + - CANNOT_CHANGE_OWN_POSITION (400): 자기 자신을 부회장으로 임명할 수 없습니다. + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 회장 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + - NOT_FOUND_CLUB_MEMBER (404): 동아리 회원을 찾을 수 없습니다. + - NOT_FOUND_CLUB_POSITION (404): 동아리 직책을 찾을 수 없습니다. + """) + @PatchMapping("/{clubId}/vice-president") + ResponseEntity changeVicePresident( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody VicePresidentChangeRequest request, + @UserId Integer userId + ); + + @Operation(summary = "동아리에 회원을 직접 추가한다.", description = """ + 동아리 회장 또는 부회장만 회원을 직접 추가할 수 있습니다. + 회장 직책으로는 추가할 수 없으며, 부회장과 운영진은 인원 제한이 있습니다. + + ## 에러 + - ALREADY_CLUB_MEMBER (409): 이미 동아리 회원입니다. + - VICE_PRESIDENT_ALREADY_EXISTS (409): 부회장은 이미 존재합니다. + - MANAGER_LIMIT_EXCEEDED (400): 운영진은 최대 20명까지 임명 가능합니다. + - FORBIDDEN_MEMBER_POSITION_CHANGE (403): 회원 추가 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + - NOT_FOUND_USER (404): 유저를 찾을 수 없습니다. + - NOT_FOUND_CLUB_POSITION (404): 동아리 직책을 찾을 수 없습니다. + """) + @PostMapping("/{clubId}/members") + ResponseEntity addMember( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubMemberAddRequest request, + @UserId Integer userId + ); + + @Operation(summary = "동아리 회원을 강제 탈퇴시킨다.", description = """ + 동아리 회장 또는 부회장만 회원을 강제 탈퇴시킬 수 있습니다. + 일반회원만 강제 탈퇴 가능하며, 부회장이나 운영진은 먼저 직책을 변경한 후 탈퇴시켜야 합니다. + + ## 에러 + - CANNOT_REMOVE_SELF (400): 자기 자신을 강제 탈퇴시킬 수 없습니다. + - CANNOT_REMOVE_NON_MEMBER (400): 일반회원만 강제 탈퇴할 수 있습니다. + - CANNOT_DELETE_CLUB_PRESIDENT (400): 회장은 강제 탈퇴시킬 수 없습니다. + - CANNOT_MANAGE_HIGHER_POSITION (400): 자신보다 높은 직급의 회원은 관리할 수 없습니다. + - FORBIDDEN_MEMBER_POSITION_CHANGE (403): 회원 관리 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + - NOT_FOUND_CLUB_MEMBER (404): 동아리 회원을 찾을 수 없습니다. + """) + @DeleteMapping("/{clubId}/members/{memberId}") + ResponseEntity removeMember( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "memberId") Integer memberId, + @UserId Integer userId + ); } diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubController.java index 8b71f076..dacf88ae 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubController.java @@ -18,13 +18,23 @@ import gg.agit.konect.domain.club.dto.ClubDetailResponse; import gg.agit.konect.domain.club.dto.ClubFeeInfoReplaceRequest; import gg.agit.konect.domain.club.dto.ClubFeeInfoResponse; +import gg.agit.konect.domain.club.dto.ClubMemberAddRequest; +import gg.agit.konect.domain.club.dto.ClubMemberCondition; import gg.agit.konect.domain.club.dto.ClubMembersResponse; import gg.agit.konect.domain.club.dto.ClubMembershipsResponse; +import gg.agit.konect.domain.club.dto.ClubPositionCreateRequest; +import gg.agit.konect.domain.club.dto.ClubPositionUpdateRequest; +import gg.agit.konect.domain.club.dto.ClubPositionsResponse; import gg.agit.konect.domain.club.dto.ClubRecruitmentCreateRequest; import gg.agit.konect.domain.club.dto.ClubRecruitmentResponse; import gg.agit.konect.domain.club.dto.ClubRecruitmentUpdateRequest; import gg.agit.konect.domain.club.dto.ClubUpdateRequest; import gg.agit.konect.domain.club.dto.ClubsResponse; +import gg.agit.konect.domain.club.dto.MemberPositionChangeRequest; +import gg.agit.konect.domain.club.dto.PresidentTransferRequest; +import gg.agit.konect.domain.club.dto.VicePresidentChangeRequest; +import gg.agit.konect.domain.club.service.ClubMemberManagementService; +import gg.agit.konect.domain.club.service.ClubPositionService; import gg.agit.konect.domain.club.service.ClubService; import gg.agit.konect.global.auth.annotation.UserId; import jakarta.validation.Valid; @@ -36,6 +46,8 @@ public class ClubController implements ClubApi { private final ClubService clubService; + private final ClubPositionService clubPositionService; + private final ClubMemberManagementService clubMemberManagementService; @Override public ResponseEntity getClubs( @@ -115,9 +127,10 @@ public ResponseEntity getClubApplicationAnswers( @Override public ResponseEntity getClubMembers( @PathVariable(name = "clubId") Integer clubId, + @Valid @ParameterObject @ModelAttribute ClubMemberCondition condition, @UserId Integer userId ) { - ClubMembersResponse response = clubService.getClubMembers(clubId, userId); + ClubMembersResponse response = clubService.getClubMembers(clubId, userId, condition); return ResponseEntity.ok(response); } @@ -197,4 +210,97 @@ public ResponseEntity updateRecruitment( clubService.updateRecruitment(clubId, userId, request); return ResponseEntity.noContent().build(); } + + @Override + public ResponseEntity getClubPositions( + @PathVariable(name = "clubId") Integer clubId, + @UserId Integer userId + ) { + ClubPositionsResponse response = clubPositionService.getClubPositions(clubId, userId); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity createClubPosition( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubPositionCreateRequest request, + @UserId Integer userId + ) { + ClubPositionsResponse response = clubPositionService.createClubPosition(clubId, userId, request); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity updateClubPositionName( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "positionId") Integer positionId, + @Valid @RequestBody ClubPositionUpdateRequest request, + @UserId Integer userId + ) { + ClubPositionsResponse response = clubPositionService.updateClubPositionName( + clubId, positionId, userId, request + ); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity deleteClubPosition( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "positionId") Integer positionId, + @UserId Integer userId + ) { + clubPositionService.deleteClubPosition(clubId, positionId, userId); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity changeMemberPosition( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "memberId") Integer memberId, + @Valid @RequestBody MemberPositionChangeRequest request, + @UserId Integer userId + ) { + clubMemberManagementService.changeMemberPosition(clubId, memberId, userId, request); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity transferPresident( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody PresidentTransferRequest request, + @UserId Integer userId + ) { + clubMemberManagementService.transferPresident(clubId, userId, request); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity changeVicePresident( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody VicePresidentChangeRequest request, + @UserId Integer userId + ) { + clubMemberManagementService.changeVicePresident(clubId, userId, request); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity addMember( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubMemberAddRequest request, + @UserId Integer userId + ) { + clubMemberManagementService.addMember(clubId, userId, request); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity removeMember( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "memberId") Integer memberId, + @UserId Integer userId + ) { + clubMemberManagementService.removeMember(clubId, memberId, userId); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberAddRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberAddRequest.java new file mode 100644 index 00000000..0b51d477 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberAddRequest.java @@ -0,0 +1,17 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record ClubMemberAddRequest( + @NotNull(message = "사용자 ID는 필수 입력입니다.") + @Schema(description = "추가할 사용자 ID", example = "123", requiredMode = REQUIRED) + Integer userId, + + @NotNull(message = "직책 ID는 필수 입력입니다.") + @Schema(description = "부여할 직책 ID", example = "4", requiredMode = REQUIRED) + Integer positionId +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberCondition.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberCondition.java new file mode 100644 index 00000000..53c16cd4 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberCondition.java @@ -0,0 +1,13 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import gg.agit.konect.domain.club.enums.ClubPositionGroup; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubMemberCondition( + @Schema(description = "직책 그룹으로 필터링 (null이면 전체 조회)", example = "PRESIDENT", requiredMode = NOT_REQUIRED) + ClubPositionGroup positionGroup +) { + +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubPositionCreateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubPositionCreateRequest.java new file mode 100644 index 00000000..2c837d68 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubPositionCreateRequest.java @@ -0,0 +1,22 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import gg.agit.konect.domain.club.enums.ClubPositionGroup; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record ClubPositionCreateRequest( + @NotBlank(message = "직책 이름은 필수 입력입니다.") + @Size(max = 50, message = "직책 이름은 최대 50자까지 입력 가능합니다.") + @Schema(description = "직책 이름", example = "부운영진", requiredMode = REQUIRED) + String name, + + @NotNull(message = "직책 그룹은 필수 입력입니다.") + @Schema(description = "직책 그룹 (MANAGER 또는 MEMBER만 가능)", example = "MANAGER", requiredMode = REQUIRED) + ClubPositionGroup positionGroup +) { + +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubPositionUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubPositionUpdateRequest.java new file mode 100644 index 00000000..759f20cc --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubPositionUpdateRequest.java @@ -0,0 +1,16 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ClubPositionUpdateRequest( + @NotBlank(message = "직책 이름은 필수 입력입니다.") + @Size(max = 50, message = "직책 이름은 최대 50자까지 입력 가능합니다.") + @Schema(description = "직책 이름", example = "총무", requiredMode = REQUIRED) + String name +) { + +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubPositionsResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubPositionsResponse.java new file mode 100644 index 00000000..0dc2b207 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubPositionsResponse.java @@ -0,0 +1,48 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.club.enums.ClubPositionGroup; +import gg.agit.konect.domain.club.model.ClubPosition; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubPositionsResponse( + @Schema(description = "직책 리스트", requiredMode = REQUIRED) + List positions +) { + public record InnerClubPosition( + @Schema(description = "직책 ID", example = "1", requiredMode = REQUIRED) + Integer positionId, + + @Schema(description = "직책 이름", example = "회장", requiredMode = REQUIRED) + String name, + + @Schema(description = "직책 그룹", example = "PRESIDENT", requiredMode = REQUIRED) + ClubPositionGroup positionGroup, + + @Schema(description = "우선순위 (낮을수록 높은 직급)", example = "0", requiredMode = REQUIRED) + Integer priority, + + @Schema(description = "해당 직책의 회원 수", example = "1", requiredMode = REQUIRED) + Long memberCount + ) { + public static InnerClubPosition of( + ClubPosition position, + Long memberCount + ) { + return new InnerClubPosition( + position.getId(), + position.getName(), + position.getClubPositionGroup(), + position.getPriority(), + memberCount + ); + } + } + + public static ClubPositionsResponse of(List positions) { + return new ClubPositionsResponse(positions); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/MemberPositionChangeRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/MemberPositionChangeRequest.java new file mode 100644 index 00000000..7d0c71a6 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/MemberPositionChangeRequest.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record MemberPositionChangeRequest( + @NotNull(message = "직책 ID는 필수 입력입니다.") + @Schema(description = "변경할 직책 ID", example = "3", requiredMode = REQUIRED) + Integer positionId +) { + +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/PresidentTransferRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/PresidentTransferRequest.java new file mode 100644 index 00000000..2e4cb344 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/PresidentTransferRequest.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record PresidentTransferRequest( + @NotNull(message = "새 회장의 사용자 ID는 필수 입력입니다.") + @Schema(description = "새 회장으로 임명할 사용자 ID", example = "2", requiredMode = REQUIRED) + Integer newPresidentUserId +) { + +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/VicePresidentChangeRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/VicePresidentChangeRequest.java new file mode 100644 index 00000000..a65ec1fd --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/VicePresidentChangeRequest.java @@ -0,0 +1,12 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record VicePresidentChangeRequest( + @Schema(description = "부회장으로 임명할 사용자 ID (null이면 부회장 해제)", example = "3", requiredMode = NOT_REQUIRED) + Integer vicePresidentUserId +) { + +} diff --git a/src/main/java/gg/agit/konect/domain/club/enums/ClubPositionGroup.java b/src/main/java/gg/agit/konect/domain/club/enums/ClubPositionGroup.java index 7892a954..376c13bb 100644 --- a/src/main/java/gg/agit/konect/domain/club/enums/ClubPositionGroup.java +++ b/src/main/java/gg/agit/konect/domain/club/enums/ClubPositionGroup.java @@ -4,14 +4,45 @@ @Getter public enum ClubPositionGroup { - PRESIDENT("회장"), - MANAGER("운영진"), - MEMBER("일반회원"), + PRESIDENT("회장", 0, 1, 1), + VICE_PRESIDENT("부회장", 1, 0, 1), + MANAGER("운영진", 2, 0, 20), + MEMBER("일반회원", 3, 0, Integer.MAX_VALUE), ; private final String description; + private final int priority; + private final int minCount; + private final int maxCount; - ClubPositionGroup(String description) { + ClubPositionGroup(String description, int priority, int minCount, int maxCount) { this.description = description; + this.priority = priority; + this.minCount = minCount; + this.maxCount = maxCount; + } + + public boolean canManage(ClubPositionGroup target) { + return this.priority < target.priority; + } + + public boolean isHigherThan(ClubPositionGroup target) { + return this.priority < target.priority; + } + + public boolean isPresident() { + return this == PRESIDENT; + } + + public boolean isVicePresident() { + return this == VICE_PRESIDENT; + } + + public boolean isManager() { + return this == MANAGER; + } + + public boolean isMember() { + return this == MEMBER; } } diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubMember.java b/src/main/java/gg/agit/konect/domain/club/model/ClubMember.java index 67c069fa..8b300616 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/ClubMember.java +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubMember.java @@ -3,6 +3,7 @@ import static jakarta.persistence.FetchType.LAZY; import static lombok.AccessLevel.PROTECTED; +import gg.agit.konect.domain.club.enums.ClubPositionGroup; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.global.model.BaseEntity; import jakarta.persistence.Column; @@ -62,4 +63,16 @@ public boolean isSameUser(Integer userId) { public void updatePosition(ClubPosition clubPosition) { this.clubPosition = clubPosition; } + + public void changePosition(ClubPosition clubPosition) { + this.clubPosition = clubPosition; + } + + public ClubPositionGroup getPositionGroup() { + return this.clubPosition.getClubPositionGroup(); + } + + public boolean canManage(ClubMember target) { + return this.getPositionGroup().canManage(target.getPositionGroup()); + } } diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubPosition.java b/src/main/java/gg/agit/konect/domain/club/model/ClubPosition.java index 8e167db0..2a3928ce 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/ClubPosition.java +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubPosition.java @@ -64,4 +64,34 @@ private ClubPosition(Integer id, String name, ClubPositionGroup clubPositionGrou public boolean isPresident() { return clubPositionGroup == ClubPositionGroup.PRESIDENT; } + + public boolean isVicePresident() { + return clubPositionGroup == ClubPositionGroup.VICE_PRESIDENT; + } + + public boolean isManager() { + return clubPositionGroup == ClubPositionGroup.MANAGER; + } + + public boolean isMember() { + return clubPositionGroup == ClubPositionGroup.MEMBER; + } + + public boolean canRename() { + return clubPositionGroup == ClubPositionGroup.MANAGER + || clubPositionGroup == ClubPositionGroup.MEMBER; + } + + public boolean canDelete() { + return clubPositionGroup == ClubPositionGroup.MANAGER + || clubPositionGroup == ClubPositionGroup.MEMBER; + } + + public void updateName(String newName) { + this.name = newName; + } + + public int getPriority() { + return clubPositionGroup.getPriority(); + } } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java index c9749b2a..b37b0927 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java @@ -11,6 +11,8 @@ import gg.agit.konect.domain.club.enums.ClubPositionGroup; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.model.ClubMemberId; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; public interface ClubMemberRepository extends Repository { @@ -18,11 +20,33 @@ public interface ClubMemberRepository extends Repository findAllByClubId(@Param("clubId") Integer clubId); + @Query(""" + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.user + JOIN FETCH cm.clubPosition cp + WHERE cm.club.id = :clubId + AND cp.clubPositionGroup = :positionGroup + ORDER BY cp.name ASC + """) + List findAllByClubIdAndPositionGroup( + @Param("clubId") Integer clubId, + @Param("positionGroup") ClubPositionGroup positionGroup + ); + @Query(""" SELECT cm FROM ClubMember cm @@ -78,6 +102,11 @@ boolean existsByClubIdAndUserIdAndPositionGroupIn( """) Optional findByClubIdAndUserId(@Param("clubId") Integer clubId, @Param("userId") Integer userId); + default ClubMember getByClubIdAndUserId(Integer clubId, Integer userId) { + return findByClubIdAndUserId(clubId, userId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CLUB_MEMBER)); + } + boolean existsByClubIdAndUserId(Integer clubId, Integer userId); List findByUserId(Integer userId); @@ -99,7 +128,29 @@ boolean existsByClubIdAndUserIdAndPositionGroupIn( """) List findByUserIdIn(@Param("userIds") List userIds); + @Query(""" + SELECT COUNT(cm) + FROM ClubMember cm + WHERE cm.clubPosition.id = :positionId + """) + long countByPositionId(@Param("positionId") Integer positionId); + + @Query(""" + SELECT COUNT(cm) + FROM ClubMember cm + JOIN cm.clubPosition cp + WHERE cm.club.id = :clubId + AND cp.clubPositionGroup = :positionGroup + """) + long countByClubIdAndPositionGroup( + @Param("clubId") Integer clubId, + @Param("positionGroup") ClubPositionGroup positionGroup + ); + + void delete(ClubMember clubMember); + ClubMember save(ClubMember clubMember); void deleteByUserId(Integer userId); + } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubPositionRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubPositionRepository.java index 85ee707d..0fc55f63 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubPositionRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubPositionRepository.java @@ -1,12 +1,56 @@ package gg.agit.konect.domain.club.repository; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_POSITION; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; +import gg.agit.konect.domain.club.enums.ClubPositionGroup; import gg.agit.konect.domain.club.model.ClubPosition; +import gg.agit.konect.global.exception.CustomException; public interface ClubPositionRepository extends Repository { ClubPosition save(ClubPosition clubPosition); -} + void delete(ClubPosition clubPosition); + + Optional findById(Integer id); + + default ClubPosition getById(Integer id) { + return findById(id) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CLUB_POSITION)); + } + + @Query(""" + SELECT cp + FROM ClubPosition cp + WHERE cp.club.id = :clubId + ORDER BY + CASE cp.clubPositionGroup + WHEN gg.agit.konect.domain.club.enums.ClubPositionGroup.PRESIDENT THEN 0 + WHEN gg.agit.konect.domain.club.enums.ClubPositionGroup.VICE_PRESIDENT THEN 1 + WHEN gg.agit.konect.domain.club.enums.ClubPositionGroup.MANAGER THEN 2 + WHEN gg.agit.konect.domain.club.enums.ClubPositionGroup.MEMBER THEN 3 + END ASC, + cp.name ASC + """) + List findAllByClubId(@Param("clubId") Integer clubId); + boolean existsByClubIdAndName(Integer clubId, String name); + + boolean existsByClubIdAndNameAndIdNot(Integer clubId, String name, Integer id); + + long countByClubIdAndClubPositionGroup(Integer clubId, ClubPositionGroup positionGroup); + + Optional findFirstByClubIdAndClubPositionGroup(Integer clubId, ClubPositionGroup positionGroup); + + default ClubPosition getFirstByClubIdAndClubPositionGroup(Integer clubId, ClubPositionGroup positionGroup) { + return findFirstByClubIdAndClubPositionGroup(clubId, positionGroup) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CLUB_POSITION)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java new file mode 100644 index 00000000..f9fdacdc --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java @@ -0,0 +1,257 @@ +package gg.agit.konect.domain.club.service; + +import static gg.agit.konect.domain.club.enums.ClubPositionGroup.*; +import static gg.agit.konect.global.code.ApiResponseCode.*; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.club.dto.ClubMemberAddRequest; +import gg.agit.konect.domain.club.dto.MemberPositionChangeRequest; +import gg.agit.konect.domain.club.dto.PresidentTransferRequest; +import gg.agit.konect.domain.club.dto.VicePresidentChangeRequest; +import gg.agit.konect.domain.club.enums.ClubPositionGroup; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubPosition; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPositionRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ClubMemberManagementService { + + public static final int MAX_MANAGER_COUNT = 20; + private final ClubRepository clubRepository; + private final ClubMemberRepository clubMemberRepository; + private final ClubPositionRepository clubPositionRepository; + private final UserRepository userRepository; + + @Transactional + public void changeMemberPosition( + Integer clubId, + Integer targetUserId, + Integer requesterId, + MemberPositionChangeRequest request + ) { + clubRepository.getById(clubId); + + validateNotSelf(requesterId, targetUserId, CANNOT_CHANGE_OWN_POSITION); + + ClubMember requester = clubMemberRepository.getByClubIdAndUserId(clubId, requesterId); + validateManagerPermission(requester); + + ClubMember target = clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId); + + if (!requester.canManage(target)) { + throw CustomException.of(CANNOT_MANAGE_HIGHER_POSITION); + } + + ClubPosition newPosition = clubPositionRepository.getById(request.positionId()); + + if (!newPosition.getClub().getId().equals(clubId)) { + throw CustomException.of(NOT_FOUND_CLUB_POSITION); + } + + ClubPositionGroup newPositionGroup = newPosition.getClubPositionGroup(); + + if (!requester.getPositionGroup().canManage(newPositionGroup)) { + throw CustomException.of(FORBIDDEN_MEMBER_POSITION_CHANGE); + } + + if (newPositionGroup == VICE_PRESIDENT) { + long vicePresidentCount = clubMemberRepository.countByClubIdAndPositionGroup(clubId, VICE_PRESIDENT); + if (target.getPositionGroup() != VICE_PRESIDENT && vicePresidentCount >= 1) { + throw CustomException.of(VICE_PRESIDENT_ALREADY_EXISTS); + } + } + + if (newPositionGroup == MANAGER) { + long managerCount = clubMemberRepository.countByClubIdAndPositionGroup(clubId, MANAGER); + if (target.getPositionGroup() != MANAGER && managerCount >= MAX_MANAGER_COUNT) { + throw CustomException.of(MANAGER_LIMIT_EXCEEDED); + } + } + + target.changePosition(newPosition); + } + + @Transactional + public void addMember( + Integer clubId, + Integer requesterId, + ClubMemberAddRequest request + ) { + clubRepository.getById(clubId); + + ClubMember requester = clubMemberRepository.getByClubIdAndUserId(clubId, requesterId); + validateManagerPermission(requester); + + Integer targetUserId = request.userId(); + + if (clubMemberRepository.existsByClubIdAndUserId(clubId, targetUserId)) { + throw CustomException.of(ALREADY_CLUB_MEMBER); + } + + userRepository.getById(targetUserId); + + ClubPosition position = clubPositionRepository.getById(request.positionId()); + + if (!position.getClub().getId().equals(clubId)) { + throw CustomException.of(NOT_FOUND_CLUB_POSITION); + } + + ClubPositionGroup positionGroup = position.getClubPositionGroup(); + + if (positionGroup == PRESIDENT) { + throw CustomException.of(FORBIDDEN_MEMBER_POSITION_CHANGE); + } + + if (positionGroup == VICE_PRESIDENT) { + long vicePresidentCount = clubMemberRepository.countByClubIdAndPositionGroup(clubId, VICE_PRESIDENT); + if (vicePresidentCount >= 1) { + throw CustomException.of(VICE_PRESIDENT_ALREADY_EXISTS); + } + } + + if (positionGroup == MANAGER) { + long managerCount = clubMemberRepository.countByClubIdAndPositionGroup(clubId, MANAGER); + if (managerCount >= MAX_MANAGER_COUNT) { + throw CustomException.of(MANAGER_LIMIT_EXCEEDED); + } + } + + ClubMember newMember = ClubMember.builder() + .club(clubRepository.getById(clubId)) + .user(userRepository.getById(targetUserId)) + .clubPosition(position) + .isFeePaid(false) + .build(); + + clubMemberRepository.save(newMember); + } + + @Transactional + public void transferPresident( + Integer clubId, + Integer currentPresidentId, + PresidentTransferRequest request + ) { + clubRepository.getById(clubId); + + ClubMember currentPresident = clubMemberRepository.getByClubIdAndUserId(clubId, currentPresidentId); + validatePresidentPermission(currentPresident); + + Integer newPresidentUserId = request.newPresidentUserId(); + validateNotSelf(currentPresidentId, newPresidentUserId, ILLEGAL_ARGUMENT); + + ClubMember newPresident = clubMemberRepository.getByClubIdAndUserId(clubId, newPresidentUserId); + + ClubPosition presidentPosition = clubPositionRepository.getFirstByClubIdAndClubPositionGroup(clubId, PRESIDENT); + ClubPosition memberPosition = clubPositionRepository.getFirstByClubIdAndClubPositionGroup(clubId, MEMBER); + + currentPresident.changePosition(memberPosition); + newPresident.changePosition(presidentPosition); + } + + @Transactional + public void changeVicePresident( + Integer clubId, + Integer requesterId, + VicePresidentChangeRequest request + ) { + clubRepository.getById(clubId); + + ClubMember requester = clubMemberRepository.getByClubIdAndUserId(clubId, requesterId); + validatePresidentPermission(requester); + + ClubPosition vicePresidentPosition = clubPositionRepository.getFirstByClubIdAndClubPositionGroup( + clubId, + VICE_PRESIDENT + ); + + Optional currentVicePresidentOpt = clubMemberRepository.findAllByClubIdAndPositionGroup(clubId, + VICE_PRESIDENT) + .stream() + .findFirst(); + + Integer newVicePresidentUserId = request.vicePresidentUserId(); + + if (newVicePresidentUserId == null) { + if (currentVicePresidentOpt.isPresent()) { + ClubMember currentVicePresident = currentVicePresidentOpt.get(); + ClubPosition memberPosition = clubPositionRepository.getFirstByClubIdAndClubPositionGroup(clubId, + MEMBER); + currentVicePresident.changePosition(memberPosition); + } + return; + } + + validateNotSelf(requesterId, newVicePresidentUserId, CANNOT_CHANGE_OWN_POSITION); + + ClubMember newVicePresident = clubMemberRepository.getByClubIdAndUserId(clubId, newVicePresidentUserId); + + if (currentVicePresidentOpt.isPresent()) { + ClubMember currentVicePresident = currentVicePresidentOpt.get(); + if (!currentVicePresident.getId().getUserId().equals(newVicePresidentUserId)) { + ClubPosition memberPosition = clubPositionRepository.getFirstByClubIdAndClubPositionGroup(clubId, + MEMBER); + currentVicePresident.changePosition(memberPosition); + } + } + + newVicePresident.changePosition(vicePresidentPosition); + } + + @Transactional + public void removeMember(Integer clubId, Integer targetUserId, Integer requesterId) { + clubRepository.getById(clubId); + + validateNotSelf(requesterId, targetUserId, CANNOT_REMOVE_SELF); + + ClubMember requester = clubMemberRepository.getByClubIdAndUserId(clubId, requesterId); + validateManagerPermission(requester); + + ClubMember target = clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId); + + if (target.isPresident()) { + throw CustomException.of(CANNOT_DELETE_CLUB_PRESIDENT); + } + + if (!requester.canManage(target)) { + throw CustomException.of(CANNOT_MANAGE_HIGHER_POSITION); + } + + if (target.getPositionGroup() != MEMBER) { + throw CustomException.of(CANNOT_REMOVE_NON_MEMBER); + } + + clubMemberRepository.delete(target); + } + + private void validateNotSelf(Integer userId1, Integer userId2, ApiResponseCode errorCode) { + if (userId1.equals(userId2)) { + throw CustomException.of(errorCode); + } + } + + private void validatePresidentPermission(ClubMember member) { + if (!member.isPresident()) { + throw CustomException.of(FORBIDDEN_CLUB_MANAGER_ACCESS); + } + } + + private void validateManagerPermission(ClubMember member) { + ClubPositionGroup positionGroup = member.getPositionGroup(); + if (positionGroup != PRESIDENT && positionGroup != VICE_PRESIDENT) { + throw CustomException.of(FORBIDDEN_MEMBER_POSITION_CHANGE); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubPositionService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubPositionService.java new file mode 100644 index 00000000..a9427445 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubPositionService.java @@ -0,0 +1,152 @@ +package gg.agit.konect.domain.club.service; + +import static gg.agit.konect.domain.club.enums.ClubPositionGroup.PRESIDENT; +import static gg.agit.konect.domain.club.enums.ClubPositionGroup.VICE_PRESIDENT; +import static gg.agit.konect.global.code.ApiResponseCode.*; + +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.club.dto.ClubPositionCreateRequest; +import gg.agit.konect.domain.club.dto.ClubPositionUpdateRequest; +import gg.agit.konect.domain.club.dto.ClubPositionsResponse; +import gg.agit.konect.domain.club.enums.ClubPositionGroup; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubPosition; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPositionRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ClubPositionService { + + private static final Set MANAGER_ALLOWED_GROUPS = + EnumSet.of(PRESIDENT, VICE_PRESIDENT); + + private final ClubRepository clubRepository; + private final ClubPositionRepository clubPositionRepository; + private final ClubMemberRepository clubMemberRepository; + + public ClubPositionsResponse getClubPositions(Integer clubId, Integer userId) { + Club club = clubRepository.getById(clubId); + + List positions = clubPositionRepository.findAllByClubId(clubId); + + List positionResponses = positions.stream() + .map(position -> { + long memberCount = clubMemberRepository.countByPositionId(position.getId()); + return ClubPositionsResponse.InnerClubPosition.of(position, memberCount); + }) + .toList(); + + return ClubPositionsResponse.of(positionResponses); + } + + @Transactional + public ClubPositionsResponse createClubPosition( + Integer clubId, + Integer userId, + ClubPositionCreateRequest request + ) { + Club club = clubRepository.getById(clubId); + + validateManagerPermission(clubId, userId); + + ClubPositionGroup positionGroup = request.positionGroup(); + if (positionGroup == ClubPositionGroup.PRESIDENT || positionGroup == ClubPositionGroup.VICE_PRESIDENT) { + throw CustomException.of(FORBIDDEN_CLUB_MANAGER_ACCESS); + } + + if (clubPositionRepository.existsByClubIdAndName(clubId, request.name())) { + throw CustomException.of(POSITION_NAME_DUPLICATED); + } + + ClubPosition newPosition = ClubPosition.builder() + .name(request.name()) + .clubPositionGroup(positionGroup) + .club(club) + .build(); + + clubPositionRepository.save(newPosition); + + return getClubPositions(clubId, userId); + } + + @Transactional + public ClubPositionsResponse updateClubPositionName( + Integer clubId, + Integer positionId, + Integer userId, + ClubPositionUpdateRequest request + ) { + clubRepository.getById(clubId); + + validateManagerPermission(clubId, userId); + + ClubPosition position = clubPositionRepository.getById(positionId); + + if (!position.getClub().getId().equals(clubId)) { + throw CustomException.of(NOT_FOUND_CLUB_POSITION); + } + + if (!position.canRename()) { + throw CustomException.of(FORBIDDEN_POSITION_NAME_CHANGE); + } + + if (clubPositionRepository.existsByClubIdAndNameAndIdNot(clubId, request.name(), positionId)) { + throw CustomException.of(POSITION_NAME_DUPLICATED); + } + + position.updateName(request.name()); + + return getClubPositions(clubId, userId); + } + + @Transactional + public void deleteClubPosition(Integer clubId, Integer positionId, Integer userId) { + Club club = clubRepository.getById(clubId); + + validateManagerPermission(clubId, userId); + + ClubPosition position = clubPositionRepository.getById(positionId); + + if (!position.getClub().getId().equals(clubId)) { + throw CustomException.of(NOT_FOUND_CLUB_POSITION); + } + + if (!position.canDelete()) { + throw CustomException.of(CANNOT_DELETE_ESSENTIAL_POSITION); + } + + long memberCount = clubMemberRepository.countByPositionId(positionId); + if (memberCount > 0) { + throw CustomException.of(POSITION_IN_USE); + } + + ClubPositionGroup positionGroup = position.getClubPositionGroup(); + long sameGroupCount = clubPositionRepository.countByClubIdAndClubPositionGroup(clubId, positionGroup); + if (sameGroupCount < 2) { + throw CustomException.of(INSUFFICIENT_POSITION_COUNT); + } + + clubPositionRepository.delete(position); + } + + private void validateManagerPermission(Integer clubId, Integer userId) { + boolean hasPermission = clubMemberRepository.existsByClubIdAndUserIdAndPositionGroupIn( + clubId, userId, MANAGER_ALLOWED_GROUPS + ); + + if (!hasPermission) { + throw CustomException.of(FORBIDDEN_CLUB_MANAGER_ACCESS); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubService.java index d64ae2ac..e006191e 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubService.java @@ -1,12 +1,12 @@ package gg.agit.konect.domain.club.service; -import static gg.agit.konect.domain.club.enums.ClubPositionGroup.MANAGER; -import static gg.agit.konect.domain.club.enums.ClubPositionGroup.PRESIDENT; +import static gg.agit.konect.domain.club.enums.ClubPositionGroup.*; import static gg.agit.konect.global.code.ApiResponseCode.*; import java.time.LocalDateTime; import java.time.LocalTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.EnumSet; import java.util.HashSet; import java.util.List; @@ -30,6 +30,7 @@ import gg.agit.konect.domain.club.dto.ClubDetailResponse; import gg.agit.konect.domain.club.dto.ClubFeeInfoReplaceRequest; import gg.agit.konect.domain.club.dto.ClubFeeInfoResponse; +import gg.agit.konect.domain.club.dto.ClubMemberCondition; import gg.agit.konect.domain.club.dto.ClubMembersResponse; import gg.agit.konect.domain.club.dto.ClubMembershipsResponse; import gg.agit.konect.domain.club.dto.ClubRecruitmentCreateRequest; @@ -114,14 +115,17 @@ public ClubDetailResponse createClub(Integer userId, ClubCreateRequest request) Club savedClub = clubRepository.save(club); - ClubPosition presidentPosition = ClubPosition.builder() - .name("회장") - .clubPositionGroup(PRESIDENT) - .club(savedClub) - .build(); + List defaultPositions = Arrays.stream(ClubPositionGroup.values()) + .map(group -> ClubPosition.builder() + .name(group.getDescription()) + .clubPositionGroup(group) + .club(savedClub) + .build()) + .toList(); - clubPositionRepository.save(presidentPosition); + defaultPositions.forEach(clubPositionRepository::save); + ClubPosition presidentPosition = defaultPositions.get(0); ClubMember president = ClubMember.builder() .club(savedClub) .user(user) @@ -215,13 +219,19 @@ private List findApplicationsByRecruitmentPeriod( ); } - public ClubMembersResponse getClubMembers(Integer clubId, Integer userId) { + public ClubMembersResponse getClubMembers(Integer clubId, Integer userId, ClubMemberCondition condition) { boolean isMember = clubMemberRepository.existsByClubIdAndUserId(clubId, userId); if (!isMember) { throw CustomException.of(FORBIDDEN_CLUB_MEMBER_ACCESS); } - List clubMembers = clubMemberRepository.findAllByClubId(clubId); + List clubMembers; + if (condition != null && condition.positionGroup() != null) { + clubMembers = clubMemberRepository.findAllByClubIdAndPositionGroup(clubId, condition.positionGroup()); + } else { + clubMembers = clubMemberRepository.findAllByClubId(clubId); + } + return ClubMembersResponse.from(clubMembers); } diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index dcd51722..5338995d 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -21,8 +21,17 @@ public enum ApiResponseCode { FAILED_EXTRACT_EMAIL(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 이메일 정보를 가져올 수 없습니다."), FAILED_EXTRACT_PROVIDER_ID(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 제공자 식별자를 가져올 수 없습니다."), CANNOT_CREATE_CHAT_ROOM_WITH_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 채팅방을 만들 수 없습니다."), - REQUIRED_CLUB_APPLY_ANSWER_MISSING(HttpStatus.BAD_REQUEST, "필수 가입 답변이 누락되었습니다."), + CANNOT_CHANGE_OWN_POSITION(HttpStatus.BAD_REQUEST, "자기 자신의 직책은 변경할 수 없습니다."), CANNOT_DELETE_CLUB_PRESIDENT(HttpStatus.BAD_REQUEST, "동아리 회장인 경우 회장을 양도하고 탈퇴해야 합니다."), + CANNOT_DELETE_ESSENTIAL_POSITION(HttpStatus.BAD_REQUEST, "필수 직책은 삭제할 수 없습니다."), + CANNOT_MANAGE_HIGHER_POSITION(HttpStatus.BAD_REQUEST, "자신보다 높은 직급의 회원은 관리할 수 없습니다."), + CANNOT_REMOVE_SELF(HttpStatus.BAD_REQUEST, "자기 자신을 강제 탈퇴시킬 수 없습니다."), + CANNOT_REMOVE_NON_MEMBER(HttpStatus.BAD_REQUEST, "일반회원만 강제 탈퇴할 수 있습니다. 먼저 직책을 변경한 후 탈퇴시켜주세요."), + INSUFFICIENT_POSITION_COUNT(HttpStatus.BAD_REQUEST, "해당 그룹에 최소 2개의 직책이 있어야 삭제 가능합니다."), + MANAGER_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "운영진은 최대 20명까지 임명 가능합니다."), + POSITION_IN_USE(HttpStatus.BAD_REQUEST, "해당 직책을 사용 중인 회원이 있어 삭제할 수 없습니다."), + POSITION_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "동일한 직책 이름이 이미 존재합니다."), + REQUIRED_CLUB_APPLY_ANSWER_MISSING(HttpStatus.BAD_REQUEST, "필수 가입 답변이 누락되었습니다."), STUDY_TIMER_NOT_RUNNING(HttpStatus.BAD_REQUEST, "실행 중인 스터디 타이머가 없습니다."), STUDY_TIMER_TIME_MISMATCH(HttpStatus.BAD_REQUEST, "스터디 타이머 시간이 유효하지 않습니다."), INVALID_RECRUITMENT_DATE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "상시 모집인 경우 모집 시작일과 마감일을 지정할 수 없습니다."), @@ -37,8 +46,10 @@ public enum ApiResponseCode { FORBIDDEN_CLUB_FEE_INFO(HttpStatus.FORBIDDEN, "회비 정보 조회 권한이 없습니다."), FORBIDDEN_CLUB_MANAGER_ACCESS(HttpStatus.FORBIDDEN, "동아리 매니저 권한이 없습니다."), FORBIDDEN_CLUB_MEMBER_ACCESS(HttpStatus.FORBIDDEN, "동아리 멤버 조회 권한이 없습니다."), - FORBIDDEN_COUNCIL_NOTICE_ACCESS(HttpStatus.FORBIDDEN, "총동아리연합회 공지사항 조회 권한이 없습니다."), FORBIDDEN_CLUB_RECRUITMENT_CREATE(HttpStatus.FORBIDDEN, "동아리 모집 공고를 생성할 권한이 없습니다."), + FORBIDDEN_COUNCIL_NOTICE_ACCESS(HttpStatus.FORBIDDEN, "총동아리연합회 공지사항 조회 권한이 없습니다."), + FORBIDDEN_MEMBER_POSITION_CHANGE(HttpStatus.FORBIDDEN, "회원 직책 변경 권한이 없습니다."), + FORBIDDEN_POSITION_NAME_CHANGE(HttpStatus.FORBIDDEN, "해당 직책의 이름은 변경할 수 없습니다."), // 404 Not Found (리소스를 찾을 수 없음) NO_HANDLER_FOUND(HttpStatus.NOT_FOUND, "유효하지 않은 API 경로입니다."), @@ -67,14 +78,15 @@ public enum ApiResponseCode { DUPLICATE_STUDENT_NUMBER(HttpStatus.CONFLICT, "이미 사용 중인 학번입니다."), DUPLICATE_PHONE_NUMBER(HttpStatus.CONFLICT, "이미 사용 중인 전화번호입니다."), ALREADY_APPLIED_CLUB(HttpStatus.CONFLICT, "이미 동아리에 가입 신청을 완료했습니다."), + ALREADY_CLUB_MEMBER(HttpStatus.CONFLICT, "이미 동아리 회원입니다."), DUPLICATE_CLUB_APPLY_QUESTION(HttpStatus.CONFLICT, "중복된 가입 문항이 포함되어 있습니다."), + VICE_PRESIDENT_ALREADY_EXISTS(HttpStatus.CONFLICT, "부회장은 이미 존재합니다."), ALREADY_RUNNING_STUDY_TIMER(HttpStatus.CONFLICT, "이미 실행 중인 스터디 타이머가 있습니다."), ALREADY_EXIST_CLUB_RECRUITMENT(HttpStatus.CONFLICT, "이미 동아리 모집 공고가 존재합니다."), // 500 Internal Server Error (서버 오류) CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."), - UNEXPECTED_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 예기치 못한 에러가 발생했습니다.") - ; + UNEXPECTED_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 예기치 못한 에러가 발생했습니다."); private final HttpStatus httpStatus; private final String message;