Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 @@ -9,6 +9,7 @@
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.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;
Expand Down Expand Up @@ -212,75 +213,75 @@ public RsData<Void> updateParticipantRole(
}

/**
* 계획 공유 초대
* 계획 공유 인원 추방
*
* @param planId 계획 ID
* @param participantId 참가자 ID
* @return 성공 메시지 (200 OK)
*/
@PostMapping("/invite/{planId}")
@Operation(summary = "계획 공유 초대", description = "다른 사용자에게 계획 공유를 초대합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 초대할 수 있습니다. 초대된 사용자는 수락 또는 거절할 수 있습니다.")
public RsData<Void> invitePlan(@PathVariable Long planId) {
@DeleteMapping("/kick/{planId}/{participantId}")
@Operation(summary = "계획 공유 인원 추방", description = "계획에 참여 중인 사용자를 추방합니다. 계획의 소유자만 다른 참가자를 추방할 수 있으며, 소유자 자신은 추방할 수 없습니다.")
public RsData<Void> kickParticipant(
@PathVariable Long planId,
@PathVariable Long participantId) {
User user = rq.getUser();
// TODO: 구현 필요
return RsData.success("계획 공유 초대 성공", null);
return RsData.success("참가자 추방 성공", null);
}

/**
* 계획 공유 수락
* 계획 공유 나가기
*
* @param planId 계획 ID
* @return 성공 메시지 (200 OK)
*/
@PostMapping("/accept/{planId}")
@Operation(summary = "계획 공유 수락", description = "받은 계획 공유 초대를 수락합니다. 수락 시 해당 계획에 참가자로 추가되며, 초대설정된 역할(Editor 또는 Viewer)로 참여하게 됩니다.")
public RsData<Void> acceptPlanInvite(@PathVariable Long planId) {
@DeleteMapping("/quit/{planId}")
@Operation(summary = "계획 공유 나가기", description = "공유된 계획에서 나갑니다. 계획의 소유자가 아닌 참가자만 사용할 수 있으며, 나가기해당 계획의 참가자 목록에서 제거됩니다.")
public RsData<Void> quitPlan(@PathVariable Long planId) {
User user = rq.getUser();
// TODO: 구현 필요
return RsData.success("계획 공유 수락 성공", null);
return RsData.success("계획 나가기 성공", null);
}

/**
* 계획 공유 거절
* 공유 링크 생성 (UUID 기반 13자)
*
* @param planId 계획 ID
* @return 성공 메시지 (200 OK)
* @return 공유 링크 정보 (200 OK)
*/
@PostMapping("/deny/{planId}")
@Operation(summary = "계획 공유 거절", description = "받은 계획 공유 초대를 거절합니다. 거절 시 해당 계획에 참가자로 추가되지 않으며, 초대 상태가 거절로 변경됩니다.")
public RsData<Void> denyPlanInvite(@PathVariable Long planId) {
@PostMapping("/{planId}/share/link")
@Operation(summary = "공유 링크 생성", description = "플랜 공유를 위한 UUID 기반 13자 토큰 링크를 생성합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 링크를 생성할 수 있습니다.")
public RsData<PlanShareLinkResponse> generateShareLink(@PathVariable Long planId) {
User user = rq.getUser();
// TODO: 구현 필요
return RsData.success("계획 공유 거절 성공", null);
PlanShareLinkResponse response = planService.generateShareLink(planId, user);
return RsData.success("공유 링크 생성 성공", response);
}

/**
* 계획 공유 인원 추방
* 공유 링크로 플랜 참가
*
* @param planId 계획 ID
* @param participantId 참가자 ID
* @return 성공 메시지 (200 OK)
* @param shareToken 공유 토큰 (UUID 기반 13자)
* @return 플랜 상세 정보 (200 OK)
*/
@DeleteMapping("/kick/{planId}/{participantId}")
@Operation(summary = "계획 공유 인원 추방", description = "계획에 참여 중인 사용자를 추방합니다. 계획의 소유자만 다른 참가자를 추방할 수 있으며, 소유자 자신은 추방할 수 없습니다.")
public RsData<Void> kickParticipant(
@PathVariable Long planId,
@PathVariable Long participantId) {
@PostMapping("/share/{shareToken}")
@Operation(summary = "공유 링크로 플랜 참가", description = "공유 링크 토큰을 통해 플랜에 참가합니다. 유효한 공유 토큰이 필요하며, 이미 참가자인 경우 상태만 업데이트됩니다.")
public RsData<PlanDetailResponse> joinPlanByShareToken(@PathVariable String shareToken) {
User user = rq.getUser();
// TODO: 구현 필요
return RsData.success("참가자 추방 성공", null);
PlanDetailResponse response = planService.joinPlanByShareToken(shareToken, user);
return RsData.success("플랜 참가 성공", response);
}

/**
* 계획 공유 나가기
* 공유 링크 삭제
*
* @param planId 계획 ID
* @return 성공 메시지 (200 OK)
*/
@DeleteMapping("/quit/{planId}")
@Operation(summary = "계획 공유 나가기", description = "공유된 계획에서 나갑니다. 계획의 소유자가 아닌 참가자만 사용할 수 있으며, 나가기 시 해당 계획의 참가자 목록에서 제거됩니다.")
public RsData<Void> quitPlan(@PathVariable Long planId) {
@DeleteMapping("/{planId}/share/link")
@Operation(summary = "공유 링크 삭제", description = "생성된 공유 링크를 삭제합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 링크를 삭제할 수 있습니다.")
public RsData<Void> deleteShareLink(@PathVariable Long planId) {
User user = rq.getUser();
// TODO: 구현 필요
return RsData.success("계획 나가기 성공", null);
planService.deleteShareLink(planId, user);
return RsData.success("공유 링크 삭제 성공", null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.back.web7_9_codecrete_be.domain.plans.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
@Schema(description = "플랜 공유 링크 응답 DTO")
public class PlanShareLinkResponse {

@Schema(description = "플랜 ID", example = "1")
private Long planId;

@Schema(description = "공유 토큰 (UUID 기반 13자)", example = "550e8400-e29b")
private String shareToken;

@Schema(description = "공유 링크", example = "/plans/share/550e8400-e29b")
private String shareLink;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UUID 쓰신 것은 좋아 보이는데, 너무 길어서 조금만 줄이시면 좋을 것 같습니다!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UUID 쓰신 것은 좋아 보이는데, 너무 길어서 조금만 줄이시면 좋을 것 같습니다!

바로 반영하겠습니다!

}

Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;


@Entity
Expand Down Expand Up @@ -49,11 +51,14 @@ public class Plan {
@Column(name = "modified_date", nullable = false)
private LocalDateTime modifiedDate;

@Column(name = "share_token", unique = true, length = 13)
private String shareToken;

@OneToMany(mappedBy = "plan", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PlanParticipant> participants = new ArrayList<>();

@OneToMany(mappedBy = "plan", cascade = CascadeType.ALL, orphanRemoval = true)
@BatchSize(size = 20)
private List<Schedule> schedules = new ArrayList<>();

@Builder
Expand Down Expand Up @@ -83,4 +88,12 @@ public void addSchedule(Schedule schedule) {
this.schedules.add(schedule);
schedule.setPlan(this);
}

public void generateShareToken() {
this.shareToken = UUID.randomUUID().toString().substring(0, 13);
}

public void clearShareToken() {
this.shareToken = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,24 @@
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface PlanParticipantRepository extends JpaRepository<PlanParticipant, Long> {

/**
* 특정 사용자와 플랜의 조합으로 참가자 존재 여부 확인
* @param userId 사용자 ID
* @param planId 플랜 ID
* @return 존재 여부
*/
boolean existsByUser_IdAndPlan_PlanId(Long userId, Long planId);

/**
* 특정 사용자와 플랜의 조합으로 참가자 조회
* @param userId 사용자 ID
* @param planId 플랜 ID
* @return PlanParticipant
*/
Optional<PlanParticipant> findByUser_IdAndPlan_PlanId(Long userId, Long planId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,19 @@ public interface PlanRepository extends JpaRepository<Plan, Long> {
/**
* - Plan 상세 조회 및 권한 체크 시
* @param id Plan ID
* @return Plan 엔티티 (concert, participants, schedules 포함)
* @EntityGraph:
* - Plan + concert + participants + schedules를 LEFT OUTER JOIN으로 한 번에 조회
* - 총 1번의 쿼리만 실행되어 N + 1 문제 방지 & 성능 향상
* @return Plan 엔티티 (concert, participants 포함)
* schedules는 @BatchSize로 배치 로드됨 (MultipleBagFetchException 방지)
*/
@EntityGraph(attributePaths = {"concert", "participants", "schedules"})
@EntityGraph(attributePaths = {"concert", "participants"})
Optional<Plan> findById(Long id);

/**
* - 특정 사용자가 참가자로 포함된 모든 Plan 조회
* @param userId 참가자로 포함된 사용자 ID
* @return 해당 사용자가 참가자인 모든 Plan 목록 (concert, participants, schedules 포함)
* @return 해당 사용자가 참가자인 모든 Plan 목록 (concert, participants 포함)
* schedules는 @BatchSize로 배치 로드됨 (MultipleBagFetchException 방지)
*/
@EntityGraph(attributePaths = {"concert", "participants", "schedules"})
@EntityGraph(attributePaths = {"concert", "participants"})
List<Plan> findDistinctByParticipants_User_Id(Long userId);

/**
Expand All @@ -38,4 +37,13 @@ public interface PlanRepository extends JpaRepository<Plan, Long> {
*/
@EntityGraph(attributePaths = {"schedules"})
List<Plan> findByUser_Id(Long userId);

/**
* - shareToken으로 Plan 조회
* @param shareToken 공유 토큰
* @return 해당 토큰을 가진 Plan 엔티티 (concert, participants 포함)
* schedules는 @BatchSize로 배치 로드됨 (MultipleBagFetchException 방지)
*/
@EntityGraph(attributePaths = {"concert", "participants"})
Optional<Plan> findByShareToken(String shareToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ public PlanResponse updatePlan(Long planId, User user, PlanUpdateRequest request
*/
@Transactional
public PlanDeleteResponse deletePlan(Long planId, User user) {
Plan plan = findPlanWithEditPermissionCheck(planId, user);
Plan plan = findPlanWithOwnerCheck(planId, user);
Long deletedPlanId = plan.getPlanId();

// Plan 삭제 시 cascade 설정으로 인해 participants와 schedules도 함께 삭제.
Expand Down Expand Up @@ -460,6 +460,7 @@ public ScheduleDeleteResponse deleteSchedule(Long planId, Long scheduleId, User
.build();
}


/**
* Plan을 조회하고 참가자 권한을 체크하는 메서드
*
Expand Down Expand Up @@ -491,6 +492,28 @@ private Plan findPlanWithParticipantCheck(Long planId, User user) {
return plan;
}

/**
* Plan을 조회하고 소유자 권한을 체크
* 소유자만 삭제 가능
*
* @param planId 계획 ID
* @param user 현재 로그인한 사용자
* @return Plan 엔티티
* @throws BusinessException 계획을 찾을 수 없거나 소유자가 아닌 경우
*/
private Plan findPlanWithOwnerCheck(Long planId, User user) {
Long userId = user.getId();
Plan plan = planRepository.findById(planId)
.orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_NOT_FOUND));

// 소유자만 삭제 가능
if (!plan.getUserId().equals(userId)) {
throw new BusinessException(PlanErrorCode.PLAN_UNAUTHORIZED);
}

return plan;
}

/**
* Plan을 조회하고 수정/삭제 권한을 체크
* OWNER 또는 EDITOR만 수정/삭제 가능
Expand Down Expand Up @@ -667,7 +690,95 @@ public void updateParticipantRole(Long planId, Long participantId, User user,
participant.updateRole(request.getRole());
}

// 계획 공유 초대
/**
* 공유 링크 생성 (UUID 기반 13자)
*
* @param planId 계획 ID
* @param user 현재 로그인한 사용자 (권한 체크용)
* @return 공유 링크 응답 DTO
* @throws BusinessException 계획을 찾을 수 없거나 권한이 없는 경우
*/
@Transactional
public PlanShareLinkResponse generateShareLink(Long planId, User user) {
// 권한 체크 (수정 권한 확인: OWNER 또는 EDITOR)
Plan plan = findPlanWithEditPermissionCheck(planId, user);

// shareToken이 이미 있으면 재사용, 없으면 생성
if (plan.getShareToken() == null) {
plan.generateShareToken();
planRepository.save(plan);
}

return PlanShareLinkResponse.builder()
.planId(plan.getPlanId())
.shareToken(plan.getShareToken())
.shareLink("/plans/share/" + plan.getShareToken())
.build();
}

/**
* 공유 링크로 플랜 참가
*
* @param shareToken 공유 토큰 (UUID 기반 13자)
* @param user 현재 로그인한 사용자
* @return 플랜 상세 정보
* @throws BusinessException 공유 링크가 유효하지 않은 경우, 이미 참가자인 경우
*/
@Transactional
public PlanDetailResponse joinPlanByShareToken(String shareToken, User user) {
// shareToken으로 Plan 찾기
Plan plan = planRepository.findByShareToken(shareToken)
.orElseThrow(() -> new BusinessException(PlanErrorCode.INVALID_SHARE_TOKEN));

// 자기 자신의 플랜은 참가할 수 없음
if (plan.getUserId().equals(user.getId())) {
throw new BusinessException(PlanErrorCode.USER_ALREADY_PARTICIPANT);
}

// DB 레벨에서 이미 참가자인지 확인 (유니크 제약조건 검증)
boolean isAlreadyParticipant = planParticipantRepository.existsByUser_IdAndPlan_PlanId(
user.getId(), plan.getPlanId());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exist로 검증한 부분이 좋네요

if (isAlreadyParticipant) {
// 이미 참가자인 경우 상태를 ACCEPTED로 변경
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 계획 수락부분에서 변경하시려고 Join에서 accepted로 설정하게 해두신건가요...?

Copy link
Copy Markdown
Collaborator Author

@kms152000 kms152000 Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 계획 수락부분에서 변경하시려고 Join에서 accepted로 설정하게 해두신건가요...?

아.. PENDING이 맞는데 초반 설계 단계에서 급하게 하느라 지나갔네요ㅎㅎ 조언 감사합니다!~
수락 기능도 있어서 링크 클릭 시 조회하는 것으로 하겠습니다.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아래에 계획 수락 부분이 있었는데 accepted라서 당황했어요 ㅋㅋㅋㅋㅋ 혹시 다른 로직에서 검증한다고 일부러 accepted 해두신줄 알았습니다

PlanParticipant participant = planParticipantRepository
.findByUser_IdAndPlan_PlanId(user.getId(), plan.getPlanId())
.orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_NOT_FOUND));

participant.updateInviteStatus(PlanParticipant.InviteStatus.ACCEPTED);
planParticipantRepository.save(participant);
} else {
// 새로운 참가자 추가 (기본 역할은 VIEWER, 상태는 ACCEPTED)
PlanParticipant participant = PlanParticipant.builder()
.user(user)
.plan(plan)
.inviteStatus(PlanParticipant.InviteStatus.ACCEPTED)
.role(PlanParticipant.ParticipantRole.VIEWER)
.build();

plan.addParticipant(participant);
planRepository.save(plan);
}

return getPlanDetail(plan.getPlanId(), user);
}

/**
* 공유 링크 삭제 (shareToken 제거)
*
* @param planId 계획 ID
* @param user 현재 로그인한 사용자 (권한 체크용)
* @throws BusinessException 계획을 찾을 수 없거나 권한이 없는 경우
*/
@Transactional
public void deleteShareLink(Long planId, User user) {
// 권한 체크 (수정 권한 확인: OWNER 또는 EDITOR)
Plan plan = findPlanWithEditPermissionCheck(planId, user);

plan.clearShareToken();
planRepository.save(plan);
}

// 계획 공유 수락
// 계획 공유 거절
// 계획 공유 인원 추방
Expand Down
Loading