diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java index cc81d0e1..e27a96e0 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java @@ -8,11 +8,13 @@ import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanDeleteResponse; import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanDetailResponse; import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanListResponse; +import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanParticipantListResponse; import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanResponse; import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanShareLinkResponse; import com.back.web7_9_codecrete_be.domain.plans.dto.response.ScheduleResponse; import com.back.web7_9_codecrete_be.domain.plans.dto.response.ScheduleListResponse; import com.back.web7_9_codecrete_be.domain.plans.dto.response.ScheduleDeleteResponse; +import com.back.web7_9_codecrete_be.domain.plans.entity.PlanParticipant; import com.back.web7_9_codecrete_be.domain.plans.service.PlanService; import com.back.web7_9_codecrete_be.domain.users.entity.User; import com.back.web7_9_codecrete_be.global.rq.Rq; @@ -219,13 +221,13 @@ public RsData updateParticipantRole( * @param participantId 참가자 ID * @return 성공 메시지 (200 OK) */ - @DeleteMapping("/kick/{planId}/{participantId}") - @Operation(summary = "계획 공유 인원 추방", description = "계획에 참여 중인 사용자를 추방합니다. 계획의 소유자만 다른 참가자를 추방할 수 있으며, 소유자 자신은 추방할 수 없습니다.") + @DeleteMapping("/{planId}/participants/{participantId}/kick") + @Operation(summary = "계획 공유 인원 추방", description = "계획에 참여 중인 사용자를 추방합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 다른 참가자를 추방할 수 있으며, 소유자는 추방할 수 없습니다.") public RsData kickParticipant( @PathVariable Long planId, @PathVariable Long participantId) { User user = rq.getUser(); - // TODO: 구현 필요 + planService.kickParticipant(planId, participantId, user); return RsData.success("참가자 추방 성공", null); } @@ -235,14 +237,45 @@ public RsData kickParticipant( * @param planId 계획 ID * @return 성공 메시지 (200 OK) */ - @DeleteMapping("/quit/{planId}") - @Operation(summary = "계획 공유 나가기", description = "공유된 계획에서 나갑니다. 계획의 소유자가 아닌 참가자만 사용할 수 있으며, 나가기 시 해당 계획의 참가자 목록에서 제거됩니다.") + @DeleteMapping("/{planId}/quit") + @Operation(summary = "계획 공유 나가기", description = "공유된 계획에서 나갑니다. 계획의 소유자가 아닌 참가자만 사용할 수 있으며, 나가기 시 참가자 상태가 LEFT로 변경됩니다.") public RsData quitPlan(@PathVariable Long planId) { User user = rq.getUser(); - // TODO: 구현 필요 + planService.quitPlan(planId, user); return RsData.success("계획 나가기 성공", null); } + /** + * 초대 거절 + * + * @param shareToken 공유 토큰 + * @return 성공 메시지 (200 OK) + */ + @PostMapping("/share/{shareToken}/decline") + @Operation(summary = "초대 거절", description = "공유 링크를 통해 받은 초대를 거절합니다. 참가자 상태가 DECLINED로 변경됩니다.") + public RsData declinePlanInvitation(@PathVariable String shareToken) { + User user = rq.getUser(); + planService.declinePlanInvitation(shareToken, user); + return RsData.success("초대 거절 성공", null); + } + + /** + * 참가자 목록 조회 + * + * @param planId 계획 ID + * @param inviteStatus 초대 상태 필터 (선택적) + * @return 참가자 목록 (200 OK) + */ + @GetMapping("/{planId}/participants") + @Operation(summary = "참가자 목록 조회", description = "계획의 참가자 목록을 조회합니다. 상태별 필터링이 가능하며, 참가자 정보(닉네임, 이메일, 프로필 이미지 등)를 포함합니다.") + public RsData getParticipantList( + @PathVariable Long planId, + @RequestParam(required = false) PlanParticipant.InviteStatus inviteStatus) { + User user = rq.getUser(); + PlanParticipantListResponse response = planService.getParticipantList(planId, user, inviteStatus); + return RsData.success("참가자 목록 조회 성공", response); + } + /** * 공유 링크 생성 (UUID 기반 13자) * diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanParticipantListResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanParticipantListResponse.java new file mode 100644 index 00000000..c997f6e3 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanParticipantListResponse.java @@ -0,0 +1,39 @@ +package com.back.web7_9_codecrete_be.domain.plans.dto.response; + +import com.back.web7_9_codecrete_be.domain.plans.entity.PlanParticipant; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +@Schema(description = "참가자 목록 조회 응답 DTO") +public class PlanParticipantListResponse { + @Schema(description = "계획 ID", example = "1") + private Long planId; + @Schema(description = "참가자 목록") + private List participants; + + @Getter + @Builder + @Schema(description = "참가자 상세 정보") + public static class ParticipantDetailInfo { + @Schema(description = "참가자 정보 ID", example = "1") + private Long participantId; + @Schema(description = "사용자 ID", example = "1") + private Long userId; + @Schema(description = "사용자 닉네임", example = "홍길동") + private String nickname; + @Schema(description = "사용자 이메일", example = "user@example.com") + private String email; + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg") + private String profileImage; + @Schema(description = "초대 상태", example = "ACCEPTED") + private PlanParticipant.InviteStatus inviteStatus; + @Schema(description = "참여자 역할", example = "EDITOR") + private PlanParticipant.ParticipantRole role; + } +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanParticipantRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanParticipantRepository.java index a7d09edb..b1a4ea78 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanParticipantRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanParticipantRepository.java @@ -1,9 +1,11 @@ package com.back.web7_9_codecrete_be.domain.plans.repository; import com.back.web7_9_codecrete_be.domain.plans.entity.PlanParticipant; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -24,4 +26,13 @@ public interface PlanParticipantRepository extends JpaRepository findByUser_IdAndPlan_PlanId(Long userId, Long planId); + + /** + * 특정 플랜의 모든 참가자 조회 (User 정보 포함) + * @EntityGraph를 사용하여 User 정보를 함께 조회하여 N+1 문제 방지 + * @param planId 플랜 ID + * @return 참가자 목록 + */ + @EntityGraph(attributePaths = {"user"}) + List findByPlan_PlanId(Long planId); } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java index 63654f92..b191721e 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java @@ -820,7 +820,142 @@ public void deleteShareLink(Long planId, User user) { planRepository.save(plan); } - // 계획 공유 거절 - // 계획 공유 인원 추방 - // 계획 공유 나가기 + /** + * 초대 거절 + * 공유 링크를 통해 받은 초대를 거절합니다. + * + * @param shareToken 공유 토큰 + * @param user 현재 로그인한 사용자 + * @throws BusinessException 공유 링크가 유효하지 않거나 참가자가 아닌 경우 + */ + @Transactional + public void declinePlanInvitation(String shareToken, User user) { + // shareToken으로 Plan 찾기 + Plan plan = planRepository.findByShareToken(shareToken) + .orElseThrow(() -> new BusinessException(PlanErrorCode.INVALID_SHARE_TOKEN)); + + // 만료 시간 검증 + if (plan.isShareTokenExpired()) { + throw new BusinessException(PlanErrorCode.SHARE_TOKEN_EXPIRED); + } + + // 자기 자신의 플랜은 거절할 수 없음 + if (plan.getUserId().equals(user.getId())) { + throw new BusinessException(PlanErrorCode.USER_ALREADY_PARTICIPANT); + } + + // 참가자 조회 + PlanParticipant participant = planParticipantRepository + .findByUser_IdAndPlan_PlanId(user.getId(), plan.getPlanId()) + .orElseThrow(() -> new BusinessException(PlanErrorCode.PARTICIPANT_NOT_FOUND)); + + // 상태를 DECLINED로 변경 + participant.updateInviteStatus(PlanParticipant.InviteStatus.DECLINED); + planParticipantRepository.save(participant); + } + + /** + * 참가자 강퇴 + * OWNER 또는 EDITOR 권한이 있는 사용자가 다른 참가자를 강퇴합니다. + * + * @param planId 계획 ID + * @param participantId 참가자 ID + * @param user 현재 로그인한 사용자 (권한 체크용) + * @throws BusinessException 계획을 찾을 수 없거나 권한이 없거나 소유자는 강퇴할 수 없는 경우 + */ + @Transactional + public void kickParticipant(Long planId, Long participantId, User user) { + // 권한 체크 (수정 권한 확인: OWNER 또는 EDITOR) + Plan plan = findPlanWithEditPermissionCheck(planId, user); + + // 참가자 조회 + PlanParticipant participant = planParticipantRepository.findById(participantId) + .orElseThrow(() -> new BusinessException(PlanErrorCode.PARTICIPANT_NOT_FOUND)); + + // 참가자가 해당 Plan에 속해있는지 확인 + if (!participant.getPlan().getPlanId().equals(planId)) { + throw new BusinessException(PlanErrorCode.PARTICIPANT_NOT_FOUND); + } + + // 소유자는 강퇴할 수 없음 + if (participant.getRole() == PlanParticipant.ParticipantRole.OWNER || + plan.getUserId().equals(participant.getUserId())) { + throw new BusinessException(PlanErrorCode.CANNOT_REMOVE_OWNER); + } + + // 상태를 REMOVED로 변경 + participant.updateInviteStatus(PlanParticipant.InviteStatus.REMOVED); + planParticipantRepository.save(participant); + } + + /** + * 자진 나가기 + * 참가자가 계획에서 스스로 나갑니다. + * + * @param planId 계획 ID + * @param user 현재 로그인한 사용자 + * @throws BusinessException 계획을 찾을 수 없거나 소유자는 나갈 수 없는 경우 + */ + @Transactional + public void quitPlan(Long planId, User user) { + Plan plan = planRepository.findById(planId) + .orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_NOT_FOUND)); + + // 소유자는 나갈 수 없음 + if (plan.getUserId().equals(user.getId())) { + throw new BusinessException(PlanErrorCode.CANNOT_LEAVE_OWNER); + } + + // 참가자 조회 + PlanParticipant participant = planParticipantRepository + .findByUser_IdAndPlan_PlanId(user.getId(), planId) + .orElseThrow(() -> new BusinessException(PlanErrorCode.PARTICIPANT_NOT_FOUND)); + + // 상태를 LEFT로 변경 + participant.updateInviteStatus(PlanParticipant.InviteStatus.LEFT); + planParticipantRepository.save(participant); + } + + /** + * 참가자 목록 조회 + * 계획의 참가자 목록을 조회합니다. 상태별 필터링이 가능합니다. + * + * @param planId 계획 ID + * @param user 현재 로그인한 사용자 (권한 체크용) + * @param inviteStatus 필터링할 초대 상태 (선택적, null이면 모든 상태) + * @return 참가자 목록 + * @throws BusinessException 계획을 찾을 수 없거나 권한이 없는 경우 + */ + public PlanParticipantListResponse getParticipantList(Long planId, User user, PlanParticipant.InviteStatus inviteStatus) { + // 권한 체크 (참가자 여부 확인) + findPlanWithParticipantCheck(planId, user); + + // 참가자 목록 조회 (User 정보 포함) + List participants = planParticipantRepository.findByPlan_PlanId(planId); + + // 상태별 필터링 + if (inviteStatus != null) { + participants = participants.stream() + .filter(p -> p.getInviteStatus() == inviteStatus) + .collect(Collectors.toList()); + } + + // DTO 변환 + List participantInfos = participants.stream() + .map(participant -> PlanParticipantListResponse.ParticipantDetailInfo.builder() + .participantId(participant.getParticipantId()) + .userId(participant.getUserId()) + .nickname(participant.getUser().getNickname()) + .email(participant.getUser().getEmail()) + .profileImage(participant.getUser().getProfileImage()) + .inviteStatus(participant.getInviteStatus()) + .role(participant.getRole()) + .build()) + .collect(Collectors.toList()); + + return PlanParticipantListResponse.builder() + .planId(planId) + .participants(participantInfos) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java index 70515c4b..25699764 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java @@ -22,7 +22,11 @@ public enum PlanErrorCode implements ErrorCode { USER_ALREADY_PARTICIPANT(HttpStatus.BAD_REQUEST, "P-110", "이미 참가자로 등록된 사용자입니다."), INVALID_SHARE_TOKEN(HttpStatus.NOT_FOUND, "P-111", "유효하지 않은 공유 링크입니다."), SHARE_TOKEN_NOT_GENERATED(HttpStatus.BAD_REQUEST, "P-112", "공유 링크가 생성되지 않았습니다."), - SHARE_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST, "P-113", "공유 링크가 만료되었습니다."); + SHARE_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST, "P-113", "공유 링크가 만료되었습니다."), + PARTICIPANT_NOT_FOUND(HttpStatus.NOT_FOUND, "P-114", "참가자를 찾을 수 없습니다."), + CANNOT_REMOVE_OWNER(HttpStatus.BAD_REQUEST, "P-115", "소유자는 강퇴할 수 없습니다."), + CANNOT_LEAVE_OWNER(HttpStatus.BAD_REQUEST, "P-116", "소유자는 계획에서 나갈 수 없습니다."), + INVALID_INVITE_STATUS(HttpStatus.BAD_REQUEST, "P-117", "유효하지 않은 초대 상태입니다."); private final HttpStatus status; private final String code;