diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java b/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java index 8a056779..b2deae21 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java @@ -32,7 +32,7 @@ public class ExerciseValidator { public void validateCreateExercise(Long memberId, ExerciseCreateDTO.Request request, Party party) { validatePartyIsActive(party); - validateMemberPermission(memberId, party); + validateSubManagerPermission(memberId, party); validateExerciseTime(request); } @@ -62,7 +62,7 @@ public void validateCancelGuestInvitation(Exercise exercise, Guest guest, Member public void validateCancelCommonParticipationByManager(Exercise exercise, Member manager) { validateAlreadyStarted(exercise, ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL); - validateMemberPermission(manager.getId(), exercise.getParty()); + validateSubManagerPermission(manager.getId(), exercise.getParty()); } public void validateCancelGuestParticipationByManager(Guest guest, Exercise exercise){ @@ -70,11 +70,11 @@ public void validateCancelGuestParticipationByManager(Guest guest, Exercise exer } public void validateDeleteExercise(Exercise exercise, Long memberId) { - validateMemberPermission(memberId, exercise.getParty()); + validateManagerPermission(memberId, exercise.getParty()); } public void validateUpdateExercise(Exercise exercise, Member member, ExerciseUpdateDTO.Request request) { - validateMemberPermission(member.getId(), exercise.getParty()); + validateManagerPermission(member.getId(), exercise.getParty()); validateAlreadyStarted(exercise, ExerciseErrorCode.EXERCISE_ALREADY_STARTED_UPDATE); validateUpdateTime(request, exercise); } @@ -87,7 +87,7 @@ private void validatePartyIsActive(Party party) { } } - private void validateMemberPermission(Long memberId, Party party) { + private void validateManagerPermission(Long memberId, Party party) { boolean isOwner = party.getOwnerId().equals(memberId); boolean isManager = memberPartyRepository.existsByPartyIdAndMemberIdAndRole( party.getId(), memberId, Role.party_MANAGER); @@ -96,6 +96,17 @@ private void validateMemberPermission(Long memberId, Party party) { throw new ExerciseException(ExerciseErrorCode.INSUFFICIENT_PERMISSION); } + private void validateSubManagerPermission(Long memberId, Party party) { + boolean isOwner = party.getOwnerId().equals(memberId); + boolean isManager = memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), memberId, Role.party_MANAGER); + boolean isSubManager = memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), memberId, Role.party_SUBMANAGER); + + if (!isOwner && !isManager && !isSubManager) + throw new ExerciseException(ExerciseErrorCode.INSUFFICIENT_PERMISSION); + } + private void validateExerciseTime(ExerciseCreateDTO.Request request) { LocalDate date = request.toParsedDate(); LocalTime startTime = request.toParsedStartTime(); diff --git a/src/main/java/umc/cockple/demo/domain/member/domain/MemberParty.java b/src/main/java/umc/cockple/demo/domain/member/domain/MemberParty.java index 06ccfda6..65504680 100644 --- a/src/main/java/umc/cockple/demo/domain/member/domain/MemberParty.java +++ b/src/main/java/umc/cockple/demo/domain/member/domain/MemberParty.java @@ -67,4 +67,8 @@ public boolean isLeader() { if (this.role == Role.party_MANAGER) return true; return false; } + + public void changeRole(Role newRole) { + this.role = newRole; + } } diff --git a/src/main/java/umc/cockple/demo/domain/member/exception/MemberErrorCode.java b/src/main/java/umc/cockple/demo/domain/member/exception/MemberErrorCode.java index e0333069..9728d5f0 100644 --- a/src/main/java/umc/cockple/demo/domain/member/exception/MemberErrorCode.java +++ b/src/main/java/umc/cockple/demo/domain/member/exception/MemberErrorCode.java @@ -23,6 +23,7 @@ public enum MemberErrorCode implements BaseErrorCode { ALREADY_WITHDRAW(HttpStatus.UNAUTHORIZED, "MEMBER301", "이미 탈퇴한 회원입니다."), MANAGER_CANNOT_LEAVE(HttpStatus.BAD_REQUEST, "MEMBER401", "모임장은 탈퇴할 수 없습니다. 모임 삭제를 먼저 해주세요."), + SUBMANAGER_CANNOT_LEAVE(HttpStatus.BAD_REQUEST, "MEMBER402", "부모임장은 탈퇴할 수 없습니다. 모임 탈퇴를 먼저 해주세요."), // 회원 주소 관련 ADDRESS_NOT_FOUND(HttpStatus.NOT_FOUND, "MEM_ADDR201", "해당 주소를 찾을 수 없습니다."), diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java index 414aa1c3..a88a7379 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java @@ -54,4 +54,6 @@ List findAllPartyIdsByMemberAndPartyIds(@Param("memberId") Long memberId, WHERE mp.party.id = :partyId """) List findAllByPartyIdWithMember(@Param("partyId") Long partyId); + + Optional findByPartyIdAndRole(Long partyId, Role role); } diff --git a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java index e59f39ae..c57fafa6 100644 --- a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java @@ -16,6 +16,7 @@ import umc.cockple.demo.domain.member.repository.*; import umc.cockple.demo.domain.member.enums.MemberStatus; import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.global.enums.Role; import java.util.List; import umc.cockple.demo.global.oauth2.service.KakaoOauthService; @@ -283,5 +284,16 @@ private void validateCanWithdraw(Member member) { if (isLeader) { throw new MemberException(MemberErrorCode.MANAGER_CANNOT_LEAVE); } + + // 활성화 된 모임의 부모임장인 경우 -> 탈퇴 불가 + boolean isSubOwner = member.getMemberParties().stream() + .anyMatch(memberParty -> + memberParty.getRole() == Role.party_SUBMANAGER + && memberParty.getStatus() == MemberPartyStatus.ACTIVE + ); + + if (isSubOwner) { + throw new MemberException(MemberErrorCode.SUBMANAGER_CANNOT_LEAVE); + } } } diff --git a/src/main/java/umc/cockple/demo/domain/notification/enums/NotificationTarget.java b/src/main/java/umc/cockple/demo/domain/notification/enums/NotificationTarget.java index a5a21263..2c609688 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/enums/NotificationTarget.java +++ b/src/main/java/umc/cockple/demo/domain/notification/enums/NotificationTarget.java @@ -8,7 +8,9 @@ public enum NotificationTarget { PARTY_MODIFY(NotificationType.CHANGE), PARTY_INVITE(NotificationType.INVITE), PARTY_INVITE_APPROVED(NotificationType.SIMPLE), - PARTY_JOINREQUEST_APPROVED(NotificationType.CHANGE); + PARTY_JOINREQUEST_APPROVED(NotificationType.CHANGE), + PARTY_SUBOWNER_ASSIGNED(NotificationType.SIMPLE), + PARTY_SUBOWNER_RELEASED(NotificationType.SIMPLE); private final NotificationType defaultType; diff --git a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java index 750238c5..78f59d9e 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java @@ -42,7 +42,6 @@ public class NotificationCommandService { private final NotificationMessageGenerator notificationMessageGenerator; private final ObjectMapper objectMapper; - // 알림 타입 변경 (초대 수락, 거절에 사용) public Response markAsReadNotification(Long memberId, Long notificationId, NotificationType type) { Notification notification = findByNotificationId(notificationId); @@ -96,7 +95,11 @@ public void createNotification(CreateNotificationRequestDTO dto) { title = "새로운 모임"; } else if (dto.target() == NotificationTarget.PARTY_INVITE_APPROVED) { content = notificationMessageGenerator.generateInviteApprovedMessage(dto.subjectName()); - }else { + } else if (dto.target() == NotificationTarget.PARTY_SUBOWNER_ASSIGNED) { + content = notificationMessageGenerator.generateSubOwnerAssignedMessage(dto.subjectName()); + } else if (dto.target() == NotificationTarget.PARTY_SUBOWNER_RELEASED) { + content = notificationMessageGenerator.generateSubOwnerReleasedMessage(dto.subjectName()); + } else { content = notificationMessageGenerator.generateJoinRequestApprovedMessage(); } diff --git a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationMessageGenerator.java b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationMessageGenerator.java index c6846a42..9e7e66cb 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationMessageGenerator.java +++ b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationMessageGenerator.java @@ -36,4 +36,12 @@ public String generateExerciseChangedMessage(String dateStr) { public String generateExerciseAttendChangedMessage() { return "운동 참석으로 변경되었어요!"; } + + public String generateSubOwnerAssignedMessage(String nickname) { + return String.format("'%s'님이 부모임장으로 지정되었습니다.", nickname); + } + + public String generateSubOwnerReleasedMessage(String nickname) { + return String.format("'%s'님의 부모임장 권한이 해제되었습니다.", nickname); + } } diff --git a/src/main/java/umc/cockple/demo/domain/party/controller/PartyController.java b/src/main/java/umc/cockple/demo/domain/party/controller/PartyController.java index 8ed33f4a..f8fc6bef 100644 --- a/src/main/java/umc/cockple/demo/domain/party/controller/PartyController.java +++ b/src/main/java/umc/cockple/demo/domain/party/controller/PartyController.java @@ -213,6 +213,22 @@ public BaseResponse removeMember( return BaseResponse.success(CommonSuccessCode.OK); } + @PatchMapping("/parties/{partyId}/members/{memberId}/role") + @Operation(summary = "멤버 역할(부모임장) 설정", description = "모임장이 특정 멤버를 부모임장으로 지정하거나 해제합니다.") + @ApiResponse(responseCode = "200", description = "역할 변경 성공") + @ApiResponse(responseCode = "400", description = "유효하지 않은 역할 값") + @ApiResponse(responseCode = "403", description = "모임장 권한 없음 또는 모임장 역할 변경 시도") + @ApiResponse(responseCode = "404", description = "존재하지 않는 모임 또는 멤버") + public BaseResponse updateMemberRole( + @PathVariable Long partyId, + @PathVariable Long memberId, + @RequestBody @Valid PartyMemberRoleDTO.Request request) { + Long currentMemberId = SecurityUtil.getCurrentMemberId(); + + partyCommandService.updateMemberRole(partyId, memberId, currentMemberId, request); + return BaseResponse.success(CommonSuccessCode.OK); + } + @PostMapping("/parties/{partyId}/join-requests") @Operation(summary = "모임 가입 신청", description = "사용자가 특정 모임에 가입을 신청합니다") diff --git a/src/main/java/umc/cockple/demo/domain/party/dto/PartyMemberRoleDTO.java b/src/main/java/umc/cockple/demo/domain/party/dto/PartyMemberRoleDTO.java new file mode 100644 index 00000000..d3c45458 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/party/dto/PartyMemberRoleDTO.java @@ -0,0 +1,13 @@ +package umc.cockple.demo.domain.party.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import umc.cockple.demo.global.enums.Role; + +public class PartyMemberRoleDTO { + + @Schema(name = "PartyMemberRoleRequest") + public record Request( + @NotNull(message = "역할 값은 필수입니다.") Role role) { + } +} diff --git a/src/main/java/umc/cockple/demo/domain/party/exception/PartyErrorCode.java b/src/main/java/umc/cockple/demo/domain/party/exception/PartyErrorCode.java index 293d091e..e844ab09 100644 --- a/src/main/java/umc/cockple/demo/domain/party/exception/PartyErrorCode.java +++ b/src/main/java/umc/cockple/demo/domain/party/exception/PartyErrorCode.java @@ -24,6 +24,7 @@ public enum PartyErrorCode implements BaseErrorCode { INVALID_ORDER_TYPE(HttpStatus.BAD_REQUEST, "PARTY106", "유효하지 않은 정렬 기준입니다. (최신순, 오래된 순, 운동 많은 순 중 하나여야 합니다.)"), INVALID_KEYWORD(HttpStatus.BAD_REQUEST, "PARTY107", "유효하지 않은 키워드입니다."), MALE_LEVEL_NOT_NEEDED(HttpStatus.BAD_REQUEST, "PARTY108", "여복 모임은 남자 급수를 설정할 수 없습니다."), + INVALID_ROLE_VALUE(HttpStatus.BAD_REQUEST, "PARTY411", "유효하지 않은 역할 값입니다. (party_SUBMANAGER 또는 party_MEMBER를 입력해주세요.)"), PARTY_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTY201", "존재하지 않는 모임입니다."), JoinRequest_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTY202", "존재하지 않는 가입신청입니다."), @@ -34,6 +35,7 @@ public enum PartyErrorCode implements BaseErrorCode { INSUFFICIENT_PERMISSION(HttpStatus.FORBIDDEN, "PARTY301", "해당 작업을 수행할 권한이 없습니다."), INVALID_ACTION_FOR_OWNER(HttpStatus.FORBIDDEN, "PARTY302", "모임장은 수행할 수 없는 작업입니다."), NOT_YOUR_INVITATION(HttpStatus.FORBIDDEN, "PARTY303", "해당 사용자의 초대가 아닙니다."), + INVALID_ACTION_FOR_SUBOWNER(HttpStatus.FORBIDDEN, "PARTY304", "부모임장은 수행할 수 없는 작업입니다."), ALREADY_MEMBER(HttpStatus.CONFLICT, "PARTY401", "이미 가입된 모임입니다."), JOIN_REQUEST_ALREADY_EXISTS(HttpStatus.CONFLICT, "PARTY402", "처리 대기중인 가입 신청이 존재합니다."), @@ -44,8 +46,8 @@ public enum PartyErrorCode implements BaseErrorCode { PARTY_IS_DELETED(HttpStatus.BAD_REQUEST, "PARTY407", "이미 삭제된 모임입니다."), CANNOT_REMOVE_SELF(HttpStatus.BAD_REQUEST, "PARTY408", "자기 자신을 모임에서 삭제할 수 없습니다."), INVITATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "PARTY409", "처리 대기중인 초대가 존재합니다."), - INVITATION_ALREADY_ACTIONS(HttpStatus.CONFLICT, "PARTY410", "이미 처리된 모임 초대입니다.") - ; + INVITATION_ALREADY_ACTIONS(HttpStatus.CONFLICT, "PARTY410", "이미 처리된 모임 초대입니다."), + CANNOT_ASSIGN_TO_OWNER(HttpStatus.FORBIDDEN, "PARTY411", "모임장의 역할은 변경할 수 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandService.java b/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandService.java index cc210e87..177c114c 100644 --- a/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandService.java @@ -13,4 +13,5 @@ public interface PartyCommandService { void actionInvitation(Long memberId, PartyInviteActionDTO.Request request, Long invitationId); void updateParty(Long partyId, Long memberId, PartyUpdateDTO.Request request); void addKeyword(Long partyId, Long memberID, PartyKeywordDTO.Request request); + void updateMemberRole(Long partyId, Long targetMemberId, Long currentMemberId, PartyMemberRoleDTO.Request request); } diff --git a/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java b/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java index 5ef17a78..f3624188 100644 --- a/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java @@ -130,6 +130,8 @@ public void leaveParty(Long partyId, Long memberId) { validatePartyIsActive(party); //모임장인 경우, 탈퇴가 불가능하도록 검증 validateIsNotOwner(party, memberId); + //부모임장인 경우, 탈퇴가 불가능하도록 검증 + validateIsNotSubOwner(party, memberId); //해당 모임의 멤버인지 검증 및 조회 MemberParty memberParty = findMemberPartyOrThrow(party, member); @@ -170,6 +172,53 @@ public void removeMember(Long partyId, Long memberIdToRemove, Long currentMember log.info("모임 멤버 삭제 완료 - partyId: {}, removed: {}", partyId, memberIdToRemove); } + @Override + public void updateMemberRole(Long partyId, Long targetMemberId, Long currentMemberId, + PartyMemberRoleDTO.Request request) { + log.info("멤버 역할 변경 시작 - partyId: {}, targetMemberId: {}, currentMemberId: {}", partyId, targetMemberId, + currentMemberId); + + Role newRole = request.role(); + + // 모임, 사용자 조회 + Party party = findPartyOrThrow(partyId); + Member targetMember = findMemberOrThrow(targetMemberId); + MemberParty targetMemberParty = findMemberPartyOrThrow(party, targetMember); + + // 모임 활성화 검증 + validatePartyIsActive(party); + // 모임장 권한 검증 + validateOwnerPermission(party, currentMemberId); + // 대상이 모임장인 경우 변경 불가 + if (targetMemberParty.getRole() == Role.party_MANAGER) { + throw new PartyException(PartyErrorCode.CANNOT_ASSIGN_TO_OWNER); + } + // 이미 같은 역할인 경우 + if (targetMemberParty.getRole() == newRole) { + return; + } + + // SUBOWNER 지정 시, 기존 부모임장 자동 해제 + if (newRole == Role.party_SUBMANAGER) { + memberPartyRepository.findByPartyIdAndRole(partyId, Role.party_SUBMANAGER) + .ifPresent(mp -> { + mp.changeRole(Role.party_MEMBER); + createRoleNotification(partyId, NotificationTarget.PARTY_SUBOWNER_RELEASED, + mp.getMember().getNickname()); + }); + } + + // 역할 변경 + targetMemberParty.changeRole(newRole); + + // 알림 발송 (전체 멤버 대상) + NotificationTarget notifTarget = (newRole == Role.party_SUBMANAGER) + ? NotificationTarget.PARTY_SUBOWNER_ASSIGNED + : NotificationTarget.PARTY_SUBOWNER_RELEASED; + createRoleNotification(partyId, notifTarget, targetMember.getNickname()); + + log.info("멤버 역할 변경 완료 - partyId: {}, targetMemberId: {}, newRole: {}", partyId, targetMemberId, newRole); + } @Override public PartyJoinCreateDTO.Response createJoinRequest(Long partyId, Long memberId) { @@ -336,6 +385,16 @@ private void validateIsNotOwner(Party party, Long memberId) { } } + // 부모임장은 권한이 없음을 검증 + private void validateIsNotSubOwner(Party party, Long memberId) { + memberPartyRepository.findByPartyIdAndRole(party.getId(), Role.party_SUBMANAGER) + .ifPresent(mp -> { + if (mp.getMember().getId().equals(memberId)) { + throw new PartyException(PartyErrorCode.INVALID_ACTION_FOR_SUBOWNER); + } + }); + } + //모임 생성 검증 private void validateCreateParty(Member owner, PartyCreateDTO.Command command) { ParticipationType partyType = command.partyType(); @@ -530,7 +589,7 @@ private void createNotification(Member member, Long partyId, NotificationTarget notificationCommandService.createNotification(dto); } - //알림 생성 (INVITE 타입) + //알림 생성 (초대) private void createInviteNotification(Member member, Long partyId, NotificationTarget notificationTarget, Long inviteId) { CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() .member(member) @@ -541,6 +600,7 @@ private void createInviteNotification(Member member, Long partyId, NotificationT notificationCommandService.createNotification(dto); } + //알림 생성 (초대 수락) private void createInviteApprovedNotification(Member member, Long partyId, NotificationTarget notificationTarget, Member invitee) { CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() .member(member) @@ -550,4 +610,18 @@ private void createInviteApprovedNotification(Member member, Long partyId, Notif .build(); notificationCommandService.createNotification(dto); } + + //전체 알림 생성 (역할) + private void createRoleNotification(Long partyId, NotificationTarget notificationTarget, String subjectNickname) { + List allMembers = memberPartyRepository.findAllByPartyIdWithMember(partyId); + allMembers.forEach(mp -> { + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(mp.getMember()) + .partyId(partyId) + .target(notificationTarget) + .subjectName(subjectNickname) + .build(); + notificationCommandService.createNotification(dto); + }); + } } \ No newline at end of file