Skip to content

Commit 0b0ba79

Browse files
Merge pull request #208 from prgrms-web-devcourse-final-project/feat/#179
[Plan] 계획 공유에 대한 응답 및 참가자 관리
2 parents 46d71fd + f062323 commit 0b0ba79

5 files changed

Lines changed: 232 additions & 10 deletions

File tree

src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanDeleteResponse;
99
import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanDetailResponse;
1010
import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanListResponse;
11+
import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanParticipantListResponse;
1112
import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanResponse;
1213
import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanShareLinkResponse;
1314
import com.back.web7_9_codecrete_be.domain.plans.dto.response.ScheduleResponse;
1415
import com.back.web7_9_codecrete_be.domain.plans.dto.response.ScheduleListResponse;
1516
import com.back.web7_9_codecrete_be.domain.plans.dto.response.ScheduleDeleteResponse;
17+
import com.back.web7_9_codecrete_be.domain.plans.entity.PlanParticipant;
1618
import com.back.web7_9_codecrete_be.domain.plans.service.PlanService;
1719
import com.back.web7_9_codecrete_be.domain.users.entity.User;
1820
import com.back.web7_9_codecrete_be.global.rq.Rq;
@@ -219,13 +221,13 @@ public RsData<Void> updateParticipantRole(
219221
* @param participantId 참가자 ID
220222
* @return 성공 메시지 (200 OK)
221223
*/
222-
@DeleteMapping("/kick/{planId}/{participantId}")
223-
@Operation(summary = "계획 공유 인원 추방", description = "계획에 참여 중인 사용자를 추방합니다. 계획의 소유자만 다른 참가자를 추방할 수 있으며, 소유자 자신은 추방할 수 없습니다.")
224+
@DeleteMapping("/{planId}/participants/{participantId}/kick")
225+
@Operation(summary = "계획 공유 인원 추방", description = "계획에 참여 중인 사용자를 추방합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 다른 참가자를 추방할 수 있으며, 소유자는 추방할 수 없습니다.")
224226
public RsData<Void> kickParticipant(
225227
@PathVariable Long planId,
226228
@PathVariable Long participantId) {
227229
User user = rq.getUser();
228-
// TODO: 구현 필요
230+
planService.kickParticipant(planId, participantId, user);
229231
return RsData.success("참가자 추방 성공", null);
230232
}
231233

@@ -235,14 +237,45 @@ public RsData<Void> kickParticipant(
235237
* @param planId 계획 ID
236238
* @return 성공 메시지 (200 OK)
237239
*/
238-
@DeleteMapping("/quit/{planId}")
239-
@Operation(summary = "계획 공유 나가기", description = "공유된 계획에서 나갑니다. 계획의 소유자가 아닌 참가자만 사용할 수 있으며, 나가기 시 해당 계획의 참가자 목록에서 제거됩니다.")
240+
@DeleteMapping("/{planId}/quit")
241+
@Operation(summary = "계획 공유 나가기", description = "공유된 계획에서 나갑니다. 계획의 소유자가 아닌 참가자만 사용할 수 있으며, 나가기 시 참가자 상태가 LEFT로 변경됩니다.")
240242
public RsData<Void> quitPlan(@PathVariable Long planId) {
241243
User user = rq.getUser();
242-
// TODO: 구현 필요
244+
planService.quitPlan(planId, user);
243245
return RsData.success("계획 나가기 성공", null);
244246
}
245247

248+
/**
249+
* 초대 거절
250+
*
251+
* @param shareToken 공유 토큰
252+
* @return 성공 메시지 (200 OK)
253+
*/
254+
@PostMapping("/share/{shareToken}/decline")
255+
@Operation(summary = "초대 거절", description = "공유 링크를 통해 받은 초대를 거절합니다. 참가자 상태가 DECLINED로 변경됩니다.")
256+
public RsData<Void> declinePlanInvitation(@PathVariable String shareToken) {
257+
User user = rq.getUser();
258+
planService.declinePlanInvitation(shareToken, user);
259+
return RsData.success("초대 거절 성공", null);
260+
}
261+
262+
/**
263+
* 참가자 목록 조회
264+
*
265+
* @param planId 계획 ID
266+
* @param inviteStatus 초대 상태 필터 (선택적)
267+
* @return 참가자 목록 (200 OK)
268+
*/
269+
@GetMapping("/{planId}/participants")
270+
@Operation(summary = "참가자 목록 조회", description = "계획의 참가자 목록을 조회합니다. 상태별 필터링이 가능하며, 참가자 정보(닉네임, 이메일, 프로필 이미지 등)를 포함합니다.")
271+
public RsData<PlanParticipantListResponse> getParticipantList(
272+
@PathVariable Long planId,
273+
@RequestParam(required = false) PlanParticipant.InviteStatus inviteStatus) {
274+
User user = rq.getUser();
275+
PlanParticipantListResponse response = planService.getParticipantList(planId, user, inviteStatus);
276+
return RsData.success("참가자 목록 조회 성공", response);
277+
}
278+
246279
/**
247280
* 공유 링크 생성 (UUID 기반 13자)
248281
*
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.back.web7_9_codecrete_be.domain.plans.dto.response;
2+
3+
import com.back.web7_9_codecrete_be.domain.plans.entity.PlanParticipant;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
8+
import java.util.List;
9+
10+
@Getter
11+
@Builder
12+
@Schema(description = "참가자 목록 조회 응답 DTO")
13+
public class PlanParticipantListResponse {
14+
@Schema(description = "계획 ID", example = "1")
15+
private Long planId;
16+
@Schema(description = "참가자 목록")
17+
private List<ParticipantDetailInfo> participants;
18+
19+
@Getter
20+
@Builder
21+
@Schema(description = "참가자 상세 정보")
22+
public static class ParticipantDetailInfo {
23+
@Schema(description = "참가자 정보 ID", example = "1")
24+
private Long participantId;
25+
@Schema(description = "사용자 ID", example = "1")
26+
private Long userId;
27+
@Schema(description = "사용자 닉네임", example = "홍길동")
28+
private String nickname;
29+
@Schema(description = "사용자 이메일", example = "user@example.com")
30+
private String email;
31+
@Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg")
32+
private String profileImage;
33+
@Schema(description = "초대 상태", example = "ACCEPTED")
34+
private PlanParticipant.InviteStatus inviteStatus;
35+
@Schema(description = "참여자 역할", example = "EDITOR")
36+
private PlanParticipant.ParticipantRole role;
37+
}
38+
}
39+

src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanParticipantRepository.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package com.back.web7_9_codecrete_be.domain.plans.repository;
22

33
import com.back.web7_9_codecrete_be.domain.plans.entity.PlanParticipant;
4+
import org.springframework.data.jpa.repository.EntityGraph;
45
import org.springframework.data.jpa.repository.JpaRepository;
56
import org.springframework.stereotype.Repository;
67

8+
import java.util.List;
79
import java.util.Optional;
810

911
@Repository
@@ -24,4 +26,13 @@ public interface PlanParticipantRepository extends JpaRepository<PlanParticipant
2426
* @return PlanParticipant
2527
*/
2628
Optional<PlanParticipant> findByUser_IdAndPlan_PlanId(Long userId, Long planId);
29+
30+
/**
31+
* 특정 플랜의 모든 참가자 조회 (User 정보 포함)
32+
* @EntityGraph를 사용하여 User 정보를 함께 조회하여 N+1 문제 방지
33+
* @param planId 플랜 ID
34+
* @return 참가자 목록
35+
*/
36+
@EntityGraph(attributePaths = {"user"})
37+
List<PlanParticipant> findByPlan_PlanId(Long planId);
2738
}

src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -820,7 +820,142 @@ public void deleteShareLink(Long planId, User user) {
820820
planRepository.save(plan);
821821
}
822822

823-
// 계획 공유 거절
824-
// 계획 공유 인원 추방
825-
// 계획 공유 나가기
823+
/**
824+
* 초대 거절
825+
* 공유 링크를 통해 받은 초대를 거절합니다.
826+
*
827+
* @param shareToken 공유 토큰
828+
* @param user 현재 로그인한 사용자
829+
* @throws BusinessException 공유 링크가 유효하지 않거나 참가자가 아닌 경우
830+
*/
831+
@Transactional
832+
public void declinePlanInvitation(String shareToken, User user) {
833+
// shareToken으로 Plan 찾기
834+
Plan plan = planRepository.findByShareToken(shareToken)
835+
.orElseThrow(() -> new BusinessException(PlanErrorCode.INVALID_SHARE_TOKEN));
836+
837+
// 만료 시간 검증
838+
if (plan.isShareTokenExpired()) {
839+
throw new BusinessException(PlanErrorCode.SHARE_TOKEN_EXPIRED);
840+
}
841+
842+
// 자기 자신의 플랜은 거절할 수 없음
843+
if (plan.getUserId().equals(user.getId())) {
844+
throw new BusinessException(PlanErrorCode.USER_ALREADY_PARTICIPANT);
845+
}
846+
847+
// 참가자 조회
848+
PlanParticipant participant = planParticipantRepository
849+
.findByUser_IdAndPlan_PlanId(user.getId(), plan.getPlanId())
850+
.orElseThrow(() -> new BusinessException(PlanErrorCode.PARTICIPANT_NOT_FOUND));
851+
852+
// 상태를 DECLINED로 변경
853+
participant.updateInviteStatus(PlanParticipant.InviteStatus.DECLINED);
854+
planParticipantRepository.save(participant);
855+
}
856+
857+
/**
858+
* 참가자 강퇴
859+
* OWNER 또는 EDITOR 권한이 있는 사용자가 다른 참가자를 강퇴합니다.
860+
*
861+
* @param planId 계획 ID
862+
* @param participantId 참가자 ID
863+
* @param user 현재 로그인한 사용자 (권한 체크용)
864+
* @throws BusinessException 계획을 찾을 수 없거나 권한이 없거나 소유자는 강퇴할 수 없는 경우
865+
*/
866+
@Transactional
867+
public void kickParticipant(Long planId, Long participantId, User user) {
868+
// 권한 체크 (수정 권한 확인: OWNER 또는 EDITOR)
869+
Plan plan = findPlanWithEditPermissionCheck(planId, user);
870+
871+
// 참가자 조회
872+
PlanParticipant participant = planParticipantRepository.findById(participantId)
873+
.orElseThrow(() -> new BusinessException(PlanErrorCode.PARTICIPANT_NOT_FOUND));
874+
875+
// 참가자가 해당 Plan에 속해있는지 확인
876+
if (!participant.getPlan().getPlanId().equals(planId)) {
877+
throw new BusinessException(PlanErrorCode.PARTICIPANT_NOT_FOUND);
878+
}
879+
880+
// 소유자는 강퇴할 수 없음
881+
if (participant.getRole() == PlanParticipant.ParticipantRole.OWNER ||
882+
plan.getUserId().equals(participant.getUserId())) {
883+
throw new BusinessException(PlanErrorCode.CANNOT_REMOVE_OWNER);
884+
}
885+
886+
// 상태를 REMOVED로 변경
887+
participant.updateInviteStatus(PlanParticipant.InviteStatus.REMOVED);
888+
planParticipantRepository.save(participant);
889+
}
890+
891+
/**
892+
* 자진 나가기
893+
* 참가자가 계획에서 스스로 나갑니다.
894+
*
895+
* @param planId 계획 ID
896+
* @param user 현재 로그인한 사용자
897+
* @throws BusinessException 계획을 찾을 수 없거나 소유자는 나갈 수 없는 경우
898+
*/
899+
@Transactional
900+
public void quitPlan(Long planId, User user) {
901+
Plan plan = planRepository.findById(planId)
902+
.orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_NOT_FOUND));
903+
904+
// 소유자는 나갈 수 없음
905+
if (plan.getUserId().equals(user.getId())) {
906+
throw new BusinessException(PlanErrorCode.CANNOT_LEAVE_OWNER);
907+
}
908+
909+
// 참가자 조회
910+
PlanParticipant participant = planParticipantRepository
911+
.findByUser_IdAndPlan_PlanId(user.getId(), planId)
912+
.orElseThrow(() -> new BusinessException(PlanErrorCode.PARTICIPANT_NOT_FOUND));
913+
914+
// 상태를 LEFT로 변경
915+
participant.updateInviteStatus(PlanParticipant.InviteStatus.LEFT);
916+
planParticipantRepository.save(participant);
917+
}
918+
919+
/**
920+
* 참가자 목록 조회
921+
* 계획의 참가자 목록을 조회합니다. 상태별 필터링이 가능합니다.
922+
*
923+
* @param planId 계획 ID
924+
* @param user 현재 로그인한 사용자 (권한 체크용)
925+
* @param inviteStatus 필터링할 초대 상태 (선택적, null이면 모든 상태)
926+
* @return 참가자 목록
927+
* @throws BusinessException 계획을 찾을 수 없거나 권한이 없는 경우
928+
*/
929+
public PlanParticipantListResponse getParticipantList(Long planId, User user, PlanParticipant.InviteStatus inviteStatus) {
930+
// 권한 체크 (참가자 여부 확인)
931+
findPlanWithParticipantCheck(planId, user);
932+
933+
// 참가자 목록 조회 (User 정보 포함)
934+
List<PlanParticipant> participants = planParticipantRepository.findByPlan_PlanId(planId);
935+
936+
// 상태별 필터링
937+
if (inviteStatus != null) {
938+
participants = participants.stream()
939+
.filter(p -> p.getInviteStatus() == inviteStatus)
940+
.collect(Collectors.toList());
941+
}
942+
943+
// DTO 변환
944+
List<PlanParticipantListResponse.ParticipantDetailInfo> participantInfos = participants.stream()
945+
.map(participant -> PlanParticipantListResponse.ParticipantDetailInfo.builder()
946+
.participantId(participant.getParticipantId())
947+
.userId(participant.getUserId())
948+
.nickname(participant.getUser().getNickname())
949+
.email(participant.getUser().getEmail())
950+
.profileImage(participant.getUser().getProfileImage())
951+
.inviteStatus(participant.getInviteStatus())
952+
.role(participant.getRole())
953+
.build())
954+
.collect(Collectors.toList());
955+
956+
return PlanParticipantListResponse.builder()
957+
.planId(planId)
958+
.participants(participantInfos)
959+
.build();
960+
}
826961
}

src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ public enum PlanErrorCode implements ErrorCode {
2222
USER_ALREADY_PARTICIPANT(HttpStatus.BAD_REQUEST, "P-110", "이미 참가자로 등록된 사용자입니다."),
2323
INVALID_SHARE_TOKEN(HttpStatus.NOT_FOUND, "P-111", "유효하지 않은 공유 링크입니다."),
2424
SHARE_TOKEN_NOT_GENERATED(HttpStatus.BAD_REQUEST, "P-112", "공유 링크가 생성되지 않았습니다."),
25-
SHARE_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST, "P-113", "공유 링크가 만료되었습니다.");
25+
SHARE_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST, "P-113", "공유 링크가 만료되었습니다."),
26+
PARTICIPANT_NOT_FOUND(HttpStatus.NOT_FOUND, "P-114", "참가자를 찾을 수 없습니다."),
27+
CANNOT_REMOVE_OWNER(HttpStatus.BAD_REQUEST, "P-115", "소유자는 강퇴할 수 없습니다."),
28+
CANNOT_LEAVE_OWNER(HttpStatus.BAD_REQUEST, "P-116", "소유자는 계획에서 나갈 수 없습니다."),
29+
INVALID_INVITE_STATUS(HttpStatus.BAD_REQUEST, "P-117", "유효하지 않은 초대 상태입니다.");
2630

2731
private final HttpStatus status;
2832
private final String code;

0 commit comments

Comments
 (0)