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..413b41f1 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; @@ -15,18 +17,29 @@ import gg.agit.konect.domain.club.dto.ClubApplyQuestionsReplaceRequest; import gg.agit.konect.domain.club.dto.ClubApplyQuestionsResponse; import gg.agit.konect.domain.club.dto.ClubApplyRequest; +import gg.agit.konect.domain.club.dto.ClubBasicInfoUpdateRequest; import gg.agit.konect.domain.club.dto.ClubCondition; import gg.agit.konect.domain.club.dto.ClubCreateRequest; import gg.agit.konect.domain.club.dto.ClubDetailResponse; +import gg.agit.konect.domain.club.dto.ClubDetailUpdateRequest; 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.ClubProfileUpdateRequest; 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.ClubTagsResponse; 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; @@ -69,22 +82,60 @@ ResponseEntity createClub( @UserId Integer userId ); - @Operation(summary = "동아리 정보를 수정한다.", description = """ - 동아리 회장 또는 매니저만 동아리 정보를 수정할 수 있습니다. - 수정 가능 항목: 동아리명, 한 줄 소개, 로고 이미지, 위치, 분과, 상세 소개 + @Operation(summary = "동아리 프로필을 수정한다.", description = """ + 동아리 회장 또는 부회장만 동아리 프로필을 수정할 수 있습니다. + 수정 가능 항목: 한 줄 소개, 로고 이미지, 태그 + 동아리명과 분과는 수정할 수 없으며, 변경이 필요한 경우 문의하기를 통해 어드민에게 요청하세요. ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. - NOT_FOUND_USER (404): 유저를 찾을 수 없습니다. """) - @PutMapping("/{clubId}") - ResponseEntity updateClub( + @PutMapping("/{clubId}/profile") + ResponseEntity updateProfile( @PathVariable(name = "clubId") Integer clubId, - @Valid @RequestBody ClubUpdateRequest request, + @Valid @RequestBody ClubProfileUpdateRequest request, @UserId Integer userId ); + @Operation(summary = "동아리 상세정보를 수정한다.", description = """ + 동아리 회장 또는 부회장만 동아리 상세정보를 수정할 수 있습니다. + 수정 가능 항목: 동방 위치, 상세 소개 + + ## 에러 + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + - NOT_FOUND_USER (404): 유저를 찾을 수 없습니다. + """) + @PutMapping("/{clubId}/details") + ResponseEntity updateDetails( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubDetailUpdateRequest request, + @UserId Integer userId + ); + + @Operation(summary = "동아리 기본정보를 수정한다 (어드민 전용).", description = """ + 어드민만 동아리 기본정보를 수정할 수 있습니다. + 수정 가능 항목: 동아리명, 분과 + 일반 관리자는 이 API를 사용할 수 없으며, 변경이 필요한 경우 문의하기를 통해 어드민에게 요청하세요. + + ## 에러 + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 어드민 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + - NOT_FOUND_USER (404): 유저를 찾을 수 없습니다. + """) + @PutMapping("/{clubId}/basic-info") + ResponseEntity updateBasicInfo( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubBasicInfoUpdateRequest request, + @UserId Integer userId + ); + + @Operation(summary = "사용 가능한 전체 태그 목록을 조회한다.") + @GetMapping("/tags") + ResponseEntity getTags(); + @Operation(summary = "가입한 동아리 리스트를 조회한다.") @GetMapping("/joined") ResponseEntity getJoinedClubs( @@ -101,7 +152,7 @@ ResponseEntity getManagedClubs( - 동아리 관리자만 해당 동아리의 지원 내역을 조회할 수 있습니다. - 현재 지정된 모집 일정 범위에 지원한 내역만 볼 수 있습니다. - 상시 모집의 경우 모든 내역을 봅니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -115,7 +166,7 @@ ResponseEntity getClubApplications( @Operation(summary = "동아리 지원 답변을 조회한다.", description = """ - 동아리 관리자만 해당 동아리의 지원 답변을 조회할 수 있습니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -128,10 +179,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 +211,7 @@ ResponseEntity applyClub( @Operation(summary = "동아리 회비 정보를 조회한다.", description = """ 동아리 가입 신청을 완료했거나 동아리 관리자 권한이 있는 사용자만 회비 계좌 정보를 조회할 수 있습니다. - + ## 에러 - FORBIDDEN_CLUB_FEE_INFO (403): 회비 정보 조회 권한이 없습니다. """) @@ -168,7 +226,7 @@ ResponseEntity getFeeInfo( - 모든 필드를 전달하면 생성/수정합니다. - 모든 필드가 null이면 회비 정보를 삭제합니다. - 일부 필드가 누락된 경우 에러가 발생합니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - INVALID_REQUEST_BODY (400): 요청 본문의 형식이 올바르지 않거나 필수 값이 누락된 경우 @@ -244,8 +302,8 @@ ResponseEntity createRecruitment( ); @Operation(summary = "동아리 모집 정보를 수정한다.", description = """ - 동아리 회장 또는 매니저만 모집 공고를 수정할 수 있습니다. - + 동아리 회장 또는 부회장만 모집 공고를 수정할 수 있습니다. + ## 에러 - INVALID_RECRUITMENT_DATE_NOT_ALLOWED (400): 상시 모집인 경우 모집 시작일과 마감일을 지정할 수 없습니다. - INVALID_RECRUITMENT_DATE_REQUIRED (400): 상시 모집이 아닐 경우 모집 시작일과 마감일이 필수입니다. @@ -261,4 +319,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..803822ad 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 @@ -13,18 +13,31 @@ import gg.agit.konect.domain.club.dto.ClubApplyQuestionsReplaceRequest; import gg.agit.konect.domain.club.dto.ClubApplyQuestionsResponse; import gg.agit.konect.domain.club.dto.ClubApplyRequest; +import gg.agit.konect.domain.club.dto.ClubBasicInfoUpdateRequest; import gg.agit.konect.domain.club.dto.ClubCondition; import gg.agit.konect.domain.club.dto.ClubCreateRequest; import gg.agit.konect.domain.club.dto.ClubDetailResponse; +import gg.agit.konect.domain.club.dto.ClubDetailUpdateRequest; 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.ClubProfileUpdateRequest; 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.ClubTagsResponse; 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 +49,8 @@ public class ClubController implements ClubApi { private final ClubService clubService; + private final ClubPositionService clubPositionService; + private final ClubMemberManagementService clubMemberManagementService; @Override public ResponseEntity getClubs( @@ -65,12 +80,38 @@ public ResponseEntity createClub( } @Override - public ResponseEntity updateClub( + public ResponseEntity updateProfile( @PathVariable(name = "clubId") Integer clubId, - @Valid @RequestBody ClubUpdateRequest request, + @Valid @RequestBody ClubProfileUpdateRequest request, @UserId Integer userId ) { - ClubDetailResponse response = clubService.updateClub(clubId, userId, request); + clubService.updateProfile(clubId, userId, request); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity updateDetails( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubDetailUpdateRequest request, + @UserId Integer userId + ) { + clubService.updateDetails(clubId, userId, request); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity updateBasicInfo( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubBasicInfoUpdateRequest request, + @UserId Integer userId + ) { + clubService.updateBasicInfo(clubId, userId, request); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity getTags() { + ClubTagsResponse response = clubService.getTags(); return ResponseEntity.ok(response); } @@ -115,9 +156,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 +239,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/ClubBasicInfoUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubBasicInfoUpdateRequest.java new file mode 100644 index 00000000..87a25f4d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubBasicInfoUpdateRequest.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import gg.agit.konect.domain.club.enums.ClubCategory; +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 ClubBasicInfoUpdateRequest( + @Schema(description = "동아리 이름", example = "BCSD Lab", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "동아리 이름은 필수 입력입니다.") + @Size(max = 50, message = "동아리 이름은 50자 이하여야 합니다.") + String name, + + @Schema(description = "동아리 분과", example = "ACADEMIC", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "동아리 분과는 필수 입력입니다.") + ClubCategory clubCategory +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailResponse.java index 4aa0ac08..dd8e1925 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailResponse.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailResponse.java @@ -10,7 +10,7 @@ import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.model.ClubRecruitment; -import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.club.model.ClubTagMap; import io.swagger.v3.oas.annotations.media.Schema; public record ClubDetailResponse( @@ -51,8 +51,11 @@ public record ClubDetailResponse( @Schema(description = "동아리 모집 정보", requiredMode = REQUIRED) InnerRecruitment recruitment, - @Schema(description = "동아리 대표 임원진", requiredMode = REQUIRED) - List representatives, + @Schema(description = "동아리 회장 이름", example = "김철수", requiredMode = REQUIRED) + String presidentName, + + @Schema(description = "동아리 태그 목록", requiredMode = REQUIRED) + List tags, @Schema(description = "동아리 소속 여부", example = "true", requiredMode = REQUIRED) Boolean isMember, @@ -80,30 +83,19 @@ public static InnerRecruitment from(ClubRecruitment clubRecruitment) { } } - public record InnerRepresentative( - @Schema(description = "동아리 대표 임원진 이름", example = "김철수", requiredMode = REQUIRED) - String name, - - @Schema(description = "동아리 대표 임원진 전화번호", example = "01012345678", requiredMode = REQUIRED) - String phone, - - @Schema(description = "동아리 대표 임원진 이메일", example = "example@koreatech.ac.kr", requiredMode = REQUIRED) - String email - ) { - public static InnerRepresentative from(ClubMember clubMember) { - User user = clubMember.getUser(); - return new InnerRepresentative(user.getName(), user.getPhoneNumber(), user.getEmail()); - } - } - public static ClubDetailResponse of( Club club, Integer memberCount, ClubRecruitment clubRecruitment, - List clubPresidents, + ClubMember president, + List clubTagMaps, Boolean isMember, Boolean isApplied ) { + List tags = clubTagMaps.stream() + .map(tagMap -> ClubTagResponse.from(tagMap.getTag())) + .toList(); + return new ClubDetailResponse( club.getId(), club.getName(), @@ -114,9 +106,8 @@ public static ClubDetailResponse of( club.getClubCategory().getDescription(), memberCount, InnerRecruitment.from(clubRecruitment), - clubPresidents.stream() - .map(InnerRepresentative::from) - .toList(), + president.getUser().getName(), + tags, isMember, isApplied ); diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailUpdateRequest.java new file mode 100644 index 00000000..59769e31 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailUpdateRequest.java @@ -0,0 +1,18 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ClubDetailUpdateRequest( + @Schema(description = "동아리 방 위치", example = "학생회관 101호", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "동아리 위치는 필수 입력입니다.") + @Size(max = 255, message = "동아리 위치는 255자 이하여야 합니다.") + String location, + + @Schema(description = "동아리 상세 소개", example = "BCSD에서 얻을 수 있는 경험\n1. IT 실무 경험", + requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "상세 소개는 필수 입력입니다.") + String introduce +) { +} 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/ClubProfileUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubProfileUpdateRequest.java new file mode 100644 index 00000000..42191333 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubProfileUpdateRequest.java @@ -0,0 +1,28 @@ +package gg.agit.konect.domain.club.dto; + +import java.util.List; + +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 ClubProfileUpdateRequest( + @Schema(description = "동아리 한 줄 소개", example = "즐겁게 일하고 열심히 노는 IT 특성화 동아리", + requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "한 줄 소개는 필수 입력입니다.") + @Size(max = 20, message = "한 줄 소개는 20자 이하여야 합니다.") + String introduce, + + @Schema(description = "동아리 로고 이미지 URL", example = "https://example.com/logo.png", + requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "이미지 URL은 필수 입력입니다.") + @Size(max = 255, message = "이미지 URL은 255자 이하여야 합니다.") + String imageUrl, + + @Schema(description = "동아리 태그 ID 목록", example = "[1, 2, 3]", + requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "태그 목록은 필수 입력입니다.") + List tagIds +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubTagResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubTagResponse.java new file mode 100644 index 00000000..c9d67f7a --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubTagResponse.java @@ -0,0 +1,16 @@ +package gg.agit.konect.domain.club.dto; + +import gg.agit.konect.domain.club.model.ClubTag; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubTagResponse( + @Schema(description = "태그 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + Integer id, + + @Schema(description = "태그 이름", example = "웹개발", requiredMode = Schema.RequiredMode.REQUIRED) + String name +) { + public static ClubTagResponse from(ClubTag clubTag) { + return new ClubTagResponse(clubTag.getId(), clubTag.getName()); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubTagsResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubTagsResponse.java new file mode 100644 index 00000000..44ff85e1 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubTagsResponse.java @@ -0,0 +1,18 @@ +package gg.agit.konect.domain.club.dto; + +import java.util.List; + +import gg.agit.konect.domain.club.model.ClubTag; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubTagsResponse( + @Schema(description = "태그 목록", requiredMode = Schema.RequiredMode.REQUIRED) + List tags +) { + public static ClubTagsResponse from(List clubTags) { + List tags = clubTags.stream() + .map(ClubTagResponse::from) + .toList(); + return new ClubTagsResponse(tags); + } +} 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/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index 397ff54c..f0c76468 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -147,22 +147,21 @@ public void replaceFeeInfo( updateFeeInfo(feeAmount, feeBank, feeAccountNumber, feeAccountHolder, feeDeadline); } - public void update( - String name, - String description, - String imageUrl, - String location, - ClubCategory clubCategory, - String introduce - ) { - this.name = name; - this.description = description; + public void updateProfile(String introduce, String imageUrl) { + this.description = introduce; this.imageUrl = imageUrl; + } + + public void updateDetails(String location, String introduce) { this.location = location; - this.clubCategory = clubCategory; this.introduce = introduce; } + public void updateBasicInfo(String name, ClubCategory clubCategory) { // 어드민 계정으로만 사용 가능 + this.name = name; + this.clubCategory = clubCategory; + } + private boolean isFeeInfoEmpty( Integer feeAmount, String feeBank, 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..3494a47e 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; @@ -58,8 +59,16 @@ public boolean isPresident() { public boolean isSameUser(Integer userId) { return this.user.getId().equals(userId); } - - public void updatePosition(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/ClubMembers.java b/src/main/java/gg/agit/konect/domain/club/model/ClubMembers.java index 8f675c29..6af1f0a0 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/ClubMembers.java +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubMembers.java @@ -1,7 +1,11 @@ package gg.agit.konect.domain.club.model; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_PRESIDENT; + import java.util.List; +import gg.agit.konect.global.exception.CustomException; + public record ClubMembers( List members ) { @@ -15,6 +19,13 @@ public List getPresidents() { .toList(); } + public ClubMember getPresident() { + return members.stream() + .filter(ClubMember::isPresident) + .findFirst() + .orElseThrow(() -> CustomException.of(NOT_FOUND_CLUB_PRESIDENT)); + } + public int getCount() { return members.size(); } 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/repository/ClubTagMapRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubTagMapRepository.java new file mode 100644 index 00000000..6c7017bd --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubTagMapRepository.java @@ -0,0 +1,20 @@ +package gg.agit.konect.domain.club.repository; + +import java.util.List; + +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.model.ClubTagMap; +import gg.agit.konect.domain.club.model.ClubTagMapId; + +public interface ClubTagMapRepository extends Repository { + + @Query("SELECT ctm FROM ClubTagMap ctm JOIN FETCH ctm.tag WHERE ctm.club.id = :clubId") + List findAllByClubId(@Param("clubId") Integer clubId); + + void deleteByClubId(Integer clubId); + + ClubTagMap save(ClubTagMap clubTagMap); +} diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubTagRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubTagRepository.java new file mode 100644 index 00000000..aa6cee3c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubTagRepository.java @@ -0,0 +1,26 @@ +package gg.agit.konect.domain.club.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import gg.agit.konect.domain.club.model.ClubTag; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +public interface ClubTagRepository extends Repository { + + Optional findById(Integer id); + + default ClubTag getById(Integer id) { + return findById(id) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CLUB)); + } + + List findAll(); + + List findAllByIdIn(List ids); + + ClubTag save(ClubTag clubTag); +} 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..99571b11 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 @@ -7,6 +7,7 @@ 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; @@ -25,17 +26,21 @@ import gg.agit.konect.domain.club.dto.ClubApplyQuestionsReplaceRequest; import gg.agit.konect.domain.club.dto.ClubApplyQuestionsResponse; import gg.agit.konect.domain.club.dto.ClubApplyRequest; +import gg.agit.konect.domain.club.dto.ClubBasicInfoUpdateRequest; import gg.agit.konect.domain.club.dto.ClubCondition; import gg.agit.konect.domain.club.dto.ClubCreateRequest; import gg.agit.konect.domain.club.dto.ClubDetailResponse; +import gg.agit.konect.domain.club.dto.ClubDetailUpdateRequest; 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.ClubProfileUpdateRequest; 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.ClubTagsResponse; import gg.agit.konect.domain.club.dto.ClubsResponse; import gg.agit.konect.domain.club.enums.ClubPositionGroup; import gg.agit.konect.domain.club.model.Club; @@ -49,6 +54,8 @@ import gg.agit.konect.domain.club.model.ClubRecruitment; import gg.agit.konect.domain.club.model.ClubRecruitmentImage; import gg.agit.konect.domain.club.model.ClubSummaryInfo; +import gg.agit.konect.domain.club.model.ClubTag; +import gg.agit.konect.domain.club.model.ClubTagMap; import gg.agit.konect.domain.club.repository.ClubApplyAnswerRepository; import gg.agit.konect.domain.club.repository.ClubApplyQuestionRepository; import gg.agit.konect.domain.club.repository.ClubApplyRepository; @@ -57,6 +64,8 @@ import gg.agit.konect.domain.club.repository.ClubQueryRepository; import gg.agit.konect.domain.club.repository.ClubRecruitmentRepository; import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.repository.ClubTagMapRepository; +import gg.agit.konect.domain.club.repository.ClubTagRepository; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.exception.CustomException; @@ -82,6 +91,8 @@ public class ClubService { private final ClubApplyAnswerRepository clubApplyAnswerRepository; private final UserRepository userRepository; private final BankRepository bankRepository; + private final ClubTagRepository clubTagRepository; + private final ClubTagMapRepository clubTagMapRepository; public ClubsResponse getClubs(ClubCondition condition, Integer userId) { User user = userRepository.getById(userId); @@ -97,14 +108,15 @@ public ClubDetailResponse getClubDetail(Integer clubId, Integer userId) { Club club = clubRepository.getById(clubId); ClubMembers clubMembers = ClubMembers.from(clubMemberRepository.findAllByClubId(club.getId())); - List clubPresidents = clubMembers.getPresidents(); + ClubMember president = clubMembers.getPresident(); Integer memberCount = clubMembers.getCount(); ClubRecruitment recruitment = club.getClubRecruitment(); + List clubTagMaps = clubTagMapRepository.findAllByClubId(clubId); boolean isMember = clubMembers.contains(userId); Boolean isApplied = isMember || clubApplyRepository.existsByClubIdAndUserId(clubId, userId); - return ClubDetailResponse.of(club, memberCount, recruitment, clubPresidents, isMember, isApplied); + return ClubDetailResponse.of(club, memberCount, recruitment, president, clubTagMaps, isMember, isApplied); } @Transactional @@ -114,14 +126,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) @@ -135,7 +150,7 @@ public ClubDetailResponse createClub(Integer userId, ClubCreateRequest request) } @Transactional - public ClubDetailResponse updateClub(Integer clubId, Integer userId, ClubUpdateRequest request) { + public void updateProfile(Integer clubId, Integer userId, ClubProfileUpdateRequest request) { userRepository.getById(userId); Club club = clubRepository.getById(clubId); @@ -143,16 +158,52 @@ public ClubDetailResponse updateClub(Integer clubId, Integer userId, ClubUpdateR throw CustomException.of(FORBIDDEN_CLUB_MANAGER_ACCESS); } - club.update( - request.name(), - request.description(), - request.imageUrl(), - request.location(), - request.clubCategory(), - request.introduce() - ); + club.updateProfile(request.introduce(), request.imageUrl()); - return getClubDetail(clubId, userId); + clubTagMapRepository.deleteByClubId(clubId); + + List tags = clubTagRepository.findAllByIdIn(request.tagIds()); + if (tags.size() != request.tagIds().size()) { + throw CustomException.of(NOT_FOUND_CLUB); + } + + tags.forEach(tag -> { + ClubTagMap tagMap = ClubTagMap.builder() + .club(club) + .tag(tag) + .build(); + clubTagMapRepository.save(tagMap); + }); + } + + @Transactional + public void updateDetails(Integer clubId, Integer userId, ClubDetailUpdateRequest request) { + userRepository.getById(userId); + Club club = clubRepository.getById(clubId); + + if (!hasClubManageAccess(clubId, userId, MANAGER_ALLOWED_GROUPS)) { + throw CustomException.of(FORBIDDEN_CLUB_MANAGER_ACCESS); + } + + club.updateDetails(request.location(), request.introduce()); + } + + @Transactional + public void updateBasicInfo(Integer clubId, Integer userId, ClubBasicInfoUpdateRequest request) { + userRepository.getById(userId); + Club club = clubRepository.getById(clubId); + + // TODO: 어드민 권한 체크 로직 추가 필요 (현재는 미구현) + // if (!isAdmin(userId)) { + // throw CustomException.of(FORBIDDEN_CLUB_MANAGER_ACCESS); + // } + + club.updateBasicInfo(request.name(), request.clubCategory()); + } + + public ClubTagsResponse getTags() { + List tags = clubTagRepository.findAll(); + return ClubTagsResponse.from(tags); } public ClubMembershipsResponse getJoinedClubs(Integer userId) { @@ -215,13 +266,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); } @@ -360,6 +417,7 @@ private List createQuestions( questionRequest.isRequired()) ); } + return questionsToCreate; } 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;