Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -219,13 +221,13 @@ public RsData<Void> updateParticipantRole(
* @param participantId 참가자 ID
* @return 성공 메시지 (200 OK)
*/
@DeleteMapping("/kick/{planId}/{participantId}")
@Operation(summary = "계획 공유 인원 추방", description = "계획에 참여 중인 사용자를 추방합니다. 계획의 소유자만 다른 참가자를 추방할 수 있으며, 소유자 자신은 추방할 수 없습니다.")
@DeleteMapping("/{planId}/participants/{participantId}/kick")
@Operation(summary = "계획 공유 인원 추방", description = "계획에 참여 중인 사용자를 추방합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 다른 참가자를 추방할 수 있으며, 소유자는 추방할 수 없습니다.")
public RsData<Void> kickParticipant(
@PathVariable Long planId,
@PathVariable Long participantId) {
User user = rq.getUser();
// TODO: 구현 필요
planService.kickParticipant(planId, participantId, user);
return RsData.success("참가자 추방 성공", null);
}

Expand All @@ -235,14 +237,45 @@ public RsData<Void> kickParticipant(
* @param planId 계획 ID
* @return 성공 메시지 (200 OK)
*/
@DeleteMapping("/quit/{planId}")
@Operation(summary = "계획 공유 나가기", description = "공유된 계획에서 나갑니다. 계획의 소유자가 아닌 참가자만 사용할 수 있으며, 나가기 시 해당 계획의 참가자 목록에서 제거됩니다.")
@DeleteMapping("/{planId}/quit")
@Operation(summary = "계획 공유 나가기", description = "공유된 계획에서 나갑니다. 계획의 소유자가 아닌 참가자만 사용할 수 있으며, 나가기 시 참가자 상태가 LEFT로 변경됩니다.")
public RsData<Void> 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<Void> 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<PlanParticipantListResponse> 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자)
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ParticipantDetailInfo> 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;
}
}

Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,4 +26,13 @@ public interface PlanParticipantRepository extends JpaRepository<PlanParticipant
* @return PlanParticipant
*/
Optional<PlanParticipant> findByUser_IdAndPlan_PlanId(Long userId, Long planId);

/**
* 특정 플랜의 모든 참가자 조회 (User 정보 포함)
* @EntityGraph를 사용하여 User 정보를 함께 조회하여 N+1 문제 방지
* @param planId 플랜 ID
* @return 참가자 목록
*/
@EntityGraph(attributePaths = {"user"})
List<PlanParticipant> findByPlan_PlanId(Long planId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlanParticipant> participants = planParticipantRepository.findByPlan_PlanId(planId);

// 상태별 필터링
if (inviteStatus != null) {
participants = participants.stream()
.filter(p -> p.getInviteStatus() == inviteStatus)
.collect(Collectors.toList());
}

// DTO 변환
List<PlanParticipantListResponse.ParticipantDetailInfo> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down