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 646ee078..5f5632e3 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 @@ -1,18 +1,25 @@ package com.back.web7_9_codecrete_be.domain.plans.controller; import com.back.web7_9_codecrete_be.domain.plans.dto.request.PlanAddRequest; +import com.back.web7_9_codecrete_be.domain.plans.dto.request.PlanParticipantRoleUpdateRequest; import com.back.web7_9_codecrete_be.domain.plans.dto.request.PlanUpdateRequest; +import com.back.web7_9_codecrete_be.domain.plans.dto.request.ScheduleAddRequest; +import com.back.web7_9_codecrete_be.domain.plans.dto.request.ScheduleUpdateRequest; 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.PlanResponse; +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.service.PlanService; +import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.global.rq.Rq; import com.back.web7_9_codecrete_be.global.rsData.RsData; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -25,6 +32,7 @@ public class PlanController { private final PlanService planService; + private final Rq rq; /** * 계획 목록 조회 @@ -33,8 +41,9 @@ public class PlanController { */ @GetMapping("/list") @Operation(summary = "계획 목록 조회", description = "사용자의 계획 목록을 조회합니다.") - public RsData> getPlanList(@RequestParam Long userId) { - List planList = planService.getPlanList(userId); + public RsData> getPlanList() { + User user = rq.getUser(); + List planList = planService.getPlanList(user); return RsData.success("계획 목록 조회 성공", planList); } @@ -46,26 +55,25 @@ public RsData> getPlanList(@RequestParam Long userId) { */ @GetMapping("/{planId}") @Operation(summary = "계획 상세 조회", description = "특정 계획의 상세 정보를 조회합니다.") - public RsData getPlanDetail(@PathVariable Long planId, - @RequestParam Long userId) { - PlanDetailResponse planDetail = planService.getPlanDetail(planId, userId); + public RsData getPlanDetail(@PathVariable Long planId) { + User user = rq.getUser(); + PlanDetailResponse planDetail = planService.getPlanDetail(planId, user); return RsData.success("계획 상세 조회 성공", planDetail); } /** * 계획 생성 * - * @param concertId 콘서트 ID * @param request 계획 생성 요청 DTO * @return 생성된 계획 정보 (201 Created) */ - @PostMapping("/{concertId}") + @PostMapping @Operation(summary = "계획 생성", description = "새로운 계획을 생성합니다.") public RsData createPlan( - @PathVariable Long concertId, @Valid @RequestBody PlanAddRequest request) { - PlanResponse planResponse = planService.createPlan(concertId, request); - return RsData.success(HttpStatus.CREATED, "계획 생성 성공", planResponse); + User user = rq.getUser(); + PlanResponse planResponse = planService.createPlan(user, request); + return RsData.success("계획 생성 성공", planResponse); } /** @@ -80,7 +88,8 @@ public RsData createPlan( public RsData updatePlan( @PathVariable Long planId, @Valid @RequestBody PlanUpdateRequest request) { - PlanResponse planResponse = planService.updatePlan(planId, request); + User user = rq.getUser(); + PlanResponse planResponse = planService.updatePlan(planId, user, request); return RsData.success("계획 수정 성공", planResponse); } @@ -92,12 +101,118 @@ public RsData updatePlan( */ @DeleteMapping("/delete/{planId}") @Operation(summary = "계획 삭제", description = "기존 계획을 삭제합니다.") - public RsData deletePlan(@PathVariable Long planId) { - PlanDeleteResponse deleteResponse = planService.deletePlan(planId); + public RsData deletePlan( + @PathVariable Long planId) { + User user = rq.getUser(); + PlanDeleteResponse deleteResponse = planService.deletePlan(planId, user); return RsData.success("계획 삭제 성공", deleteResponse); } + /** + * 일정 추가 + * + * @param planId 계획 ID + * @param request 일정 추가 요청 DTO + * @return 생성된 일정 정보 (201 Created) + */ + @PostMapping("/{planId}/schedules") + @Operation(summary = "일정 추가", description = "계획에 새로운 일정을 추가합니다.") + public RsData addSchedule( + @PathVariable Long planId, + @Valid @RequestBody ScheduleAddRequest request) { + User user = rq.getUser(); + ScheduleResponse response = planService.addSchedule(planId, user, request); + return RsData.success("일정 추가 성공", response); + } + + /** + * 일정 목록 조회 (타임라인) + * + * @param planId 계획 ID + * @return 일정 목록 (200 OK) + */ + @GetMapping("/{planId}/schedules") + @Operation(summary = "일정 목록 조회", description = "계획의 일정 목록을 타임라인 형태로 조회합니다.") + public RsData getSchedules( + @PathVariable Long planId) { + User user = rq.getUser(); + ScheduleListResponse response = planService.getSchedules(planId, user); + return RsData.success("일정 목록 조회 성공", response); + } + + /** + * 일정 상세 조회 + * + * @param planId 계획 ID + * @param scheduleId 일정 ID + * @return 일정 상세 정보 (200 OK) + */ + @GetMapping("/{planId}/schedules/{scheduleId}") + @Operation(summary = "일정 상세 조회", description = "특정 일정의 상세 정보를 조회합니다.") + public RsData getSchedule( + @PathVariable Long planId, + @PathVariable Long scheduleId) { + User user = rq.getUser(); + ScheduleResponse response = planService.getSchedule(planId, scheduleId, user); + return RsData.success("일정 상세 조회 성공", response); + } + + /** + * 일정 수정 + * + * @param planId 계획 ID + * @param scheduleId 일정 ID + * @param request 일정 수정 요청 DTO + * @return 수정된 일정 정보 (200 OK) + */ + @PatchMapping("/{planId}/schedules/{scheduleId}") + @Operation(summary = "일정 수정", description = "기존 일정의 정보를 수정합니다.") + public RsData updateSchedule( + @PathVariable Long planId, + @PathVariable Long scheduleId, + @Valid @RequestBody ScheduleUpdateRequest request) { + User user = rq.getUser(); + ScheduleResponse response = planService.updateSchedule(planId, scheduleId, user, request); + return RsData.success("일정 수정 성공", response); + } + + /** + * 일정 삭제 + * + * @param planId 계획 ID + * @param scheduleId 일정 ID + * @return 삭제된 일정 ID (200 OK) + */ + @DeleteMapping("/{planId}/schedules/{scheduleId}") + @Operation(summary = "일정 삭제", description = "기존 일정을 삭제합니다.") + public RsData deleteSchedule( + @PathVariable Long planId, + @PathVariable Long scheduleId) { + User user = rq.getUser(); + ScheduleDeleteResponse response = planService.deleteSchedule(planId, scheduleId, user); + return RsData.success("일정 삭제 성공", response); + } + + /** + * 참가자 역할 수정 + * + * @param planId 계획 ID + * @param participantId 참가자 ID + * @param request 역할 수정 요청 DTO + * @return 성공 메시지 (200 OK) + */ + @PatchMapping("/{planId}/participants/{participantId}/role") + @Operation(summary = "참가자 역할 수정", description = "참가자의 역할을 수정합니다. (Owner, Editor, Viewer)") + public RsData updateParticipantRole( + @PathVariable Long planId, + @PathVariable Long participantId, + @Valid @RequestBody PlanParticipantRoleUpdateRequest request) { + User user = rq.getUser(); + planService.updateParticipantRole(planId, participantId, user, request); + return RsData.success("참가자 역할 수정 성공", null); + } + // POST /api/v1/plans/invite/{planID} - 계획 공유 초대 // POST /api/v1/plans/accept/{planID} - 계획 공유 수락 // POST /api/v1/plans/deny/{planID} - 계획 공유 거절 diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanAddRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanAddRequest.java index a87d4bfa..82066219 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanAddRequest.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanAddRequest.java @@ -1,19 +1,26 @@ package com.back.web7_9_codecrete_be.domain.plans.dto.request; +import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDate; + @Getter @NoArgsConstructor public class PlanAddRequest { + @NotNull(message = "콘서트 ID는 필수입니다.") + private Long concertId; + @NotBlank(message = "제목은 필수입니다.") - @Size(max = 30, message = "제목은 30자 이하여야 합니다.") + @Size(max = 100, message = "제목은 100자 이하여야 합니다.") private String title; - @NotBlank(message = "날짜는 필수입니다.") - @Size(max = 30, message = "날짜는 30자 이하여야 합니다.") - private String date; + @NotNull(message = "날짜는 필수입니다.") + @FutureOrPresent(message = "날짜는 현재 또는 미래 날짜여야 합니다.") + private LocalDate planDate; } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanKickParticipantRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanParticipantInviteRequest.java similarity index 85% rename from src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanKickParticipantRequest.java rename to src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanParticipantInviteRequest.java index 31b23130..5162fb0f 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanKickParticipantRequest.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanParticipantInviteRequest.java @@ -7,7 +7,7 @@ @Getter @NoArgsConstructor -public class PlanKickParticipantRequest { +public class PlanParticipantInviteRequest { @NotNull(message = "사용자 ID는 필수입니다.") private Long userId; -} \ No newline at end of file +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanInviteRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanParticipantKickRequest.java similarity index 86% rename from src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanInviteRequest.java rename to src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanParticipantKickRequest.java index 161c9be8..932cb752 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanInviteRequest.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanParticipantKickRequest.java @@ -7,7 +7,7 @@ @Getter @NoArgsConstructor -public class PlanInviteRequest { +public class PlanParticipantKickRequest { @NotNull(message = "사용자 ID는 필수입니다.") private Long userId; -} \ No newline at end of file +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanParticipantRoleUpdateRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanParticipantRoleUpdateRequest.java new file mode 100644 index 00000000..ec5e751d --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanParticipantRoleUpdateRequest.java @@ -0,0 +1,14 @@ +package com.back.web7_9_codecrete_be.domain.plans.dto.request; + +import com.back.web7_9_codecrete_be.domain.plans.entity.PlanParticipant; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PlanParticipantRoleUpdateRequest { + + @NotNull(message = "역할은 필수입니다.") + private PlanParticipant.ParticipantRole role; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanUpdateRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanUpdateRequest.java index c0262bce..bab779c0 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanUpdateRequest.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/PlanUpdateRequest.java @@ -1,19 +1,19 @@ package com.back.web7_9_codecrete_be.domain.plans.dto.request; -import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDate; + @Getter @NoArgsConstructor public class PlanUpdateRequest { - @NotBlank(message = "제목은 필수입니다.") - @Size(max = 30, message = "제목은 30자 이하여야 합니다.") + @Size(max = 100, message = "제목은 100자 이하여야 합니다.") private String title; - @NotBlank(message = "날짜는 필수입니다.") - @Size(max = 30, message = "날짜는 30자 이하여야 합니다.") - private String date; + @FutureOrPresent(message = "날짜는 현재 또는 미래 날짜여야 합니다.") + private LocalDate planDate; } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/ScheduleAddRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/ScheduleAddRequest.java new file mode 100644 index 00000000..79b53e95 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/ScheduleAddRequest.java @@ -0,0 +1,109 @@ +package com.back.web7_9_codecrete_be.domain.plans.dto.request; + +import com.back.web7_9_codecrete_be.domain.plans.entity.Schedule; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +public class ScheduleAddRequest { + + @NotNull(message = "일정 타입은 필수입니다.") + private Schedule.ScheduleType scheduleType; + + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 100, message = "제목은 100자 이하여야 합니다.") + private String title; + + @NotNull(message = "시작 일시는 필수입니다.") + private LocalDateTime startAt; + + @NotNull(message = "소요 시간(분)은 필수입니다.") + @Positive(message = "소요 시간은 양수여야 합니다.") + private Integer duration; + + @NotBlank(message = "위치는 필수입니다.") + @Size(max = 255, message = "위치는 255자 이하여야 합니다.") + private String location; + + // 위도/경도는 선택적 (지도 표시가 필요한 경우에만 입력) + @Min(value = -90, message = "위도는 -90 이상이어야 합니다.") + @Max(value = 90, message = "위도는 90 이하여야 합니다.") + private Double locationLat; + + @Min(value = -180, message = "경도는 -180 이상이어야 합니다.") + @Max(value = 180, message = "경도는 180 이하여야 합니다.") + private Double locationLon; + + @NotNull(message = "예상 비용은 필수입니다.") + @PositiveOrZero(message = "예상 비용은 0 이상이어야 합니다.") + private Integer estimatedCost; + + @NotBlank(message = "상세 정보는 필수입니다.") + private String details; + + // 정렬 순서 (미지정 시 자동 계산) + @Positive(message = "정렬 순서는 양수여야 합니다.") + private Integer sequenceOrder; + + // 교통 수단인 경우 사용 + @Min(value = -90, message = "출발지 위도는 -90 이상이어야 합니다.") + @Max(value = 90, message = "출발지 위도는 90 이하여야 합니다.") + private Double startPlaceLat; + + @Min(value = -180, message = "출발지 경도는 -180 이상이어야 합니다.") + @Max(value = 180, message = "출발지 경도는 180 이하여야 합니다.") + private Double startPlaceLon; + + @Min(value = -90, message = "도착지 위도는 -90 이상이어야 합니다.") + @Max(value = 90, message = "도착지 위도는 90 이하여야 합니다.") + private Double endPlaceLat; + + @Min(value = -180, message = "도착지 경도는 -180 이상이어야 합니다.") + @Max(value = 180, message = "도착지 경도는 180 이하여야 합니다.") + private Double endPlaceLon; + + @Positive(message = "거리는 양수여야 합니다.") + private Integer distance; + + private Schedule.TransportType transportType; + + /** + * 위도와 경도는 함께 제공되어야 함 (둘 다 null이거나 둘 다 값이 있어야 함) + * 단, TRANSPORT 타입일 때는 locationLat/Lon을 사용하지 않음 (endPlaceLat/Lon 사용) + */ + @AssertTrue(message = "위도와 경도는 함께 제공되어야 합니다.") + private boolean isValidLocationCoordinates() { + // TRANSPORT 타입일 때는 locationLat/Lon을 사용하지 않음 + if (scheduleType == Schedule.ScheduleType.TRANSPORT) { + return locationLat == null && locationLon == null; + } + // 일반 일정: 둘 다 null이거나 둘 다 값이 있어야 함 + return (locationLat == null && locationLon == null) || + (locationLat != null && locationLon != null); + } + + /** + * TRANSPORT 타입일 때 교통 관련 필드들이 필수인지 검증 + */ + @AssertTrue(message = "교통 수단 타입일 경우 출발지/도착지 좌표, 거리, 교통 수단 종류는 필수입니다.") + private boolean isValidTransportFields() { + if (scheduleType == null || scheduleType != Schedule.ScheduleType.TRANSPORT) { + return true; // TRANSPORT가 아니면 검증 통과 + } + // TRANSPORT 타입일 때 필수 필드 검증 + return startPlaceLat != null && startPlaceLon != null && + endPlaceLat != null && endPlaceLon != null && + distance != null && transportType != null; + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/ScheduleUpdateRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/ScheduleUpdateRequest.java new file mode 100644 index 00000000..9f9af0ae --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/request/ScheduleUpdateRequest.java @@ -0,0 +1,107 @@ +package com.back.web7_9_codecrete_be.domain.plans.dto.request; + +import com.back.web7_9_codecrete_be.domain.plans.entity.Schedule; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +public class ScheduleUpdateRequest { + + // 부분 업데이트 지원: 모든 필드를 optional로 변경 + private Schedule.ScheduleType scheduleType; + + @Size(max = 100, message = "제목은 100자 이하여야 합니다.") + private String title; + + private LocalDateTime startAt; + + @Positive(message = "소요 시간은 양수여야 합니다.") + private Integer duration; + + @Size(max = 255, message = "위치는 255자 이하여야 합니다.") + private String location; + + @Min(value = -90, message = "위도는 -90 이상이어야 합니다.") + @Max(value = 90, message = "위도는 90 이하여야 합니다.") + private Double locationLat; + + @Min(value = -180, message = "경도는 -180 이상이어야 합니다.") + @Max(value = 180, message = "경도는 180 이하여야 합니다.") + private Double locationLon; + + @PositiveOrZero(message = "예상 비용은 0 이상이어야 합니다.") + private Integer estimatedCost; + + private String details; + + // 정렬 순서 (미지정 시 기존 값 유지) + @Positive(message = "정렬 순서는 양수여야 합니다.") + private Integer sequenceOrder; + + // 교통 수단인 경우 사용 + @Min(value = -90, message = "출발지 위도는 -90 이상이어야 합니다.") + @Max(value = 90, message = "출발지 위도는 90 이하여야 합니다.") + private Double startPlaceLat; + + @Min(value = -180, message = "출발지 경도는 -180 이상이어야 합니다.") + @Max(value = 180, message = "출발지 경도는 180 이하여야 합니다.") + private Double startPlaceLon; + + @Min(value = -90, message = "도착지 위도는 -90 이상이어야 합니다.") + @Max(value = 90, message = "도착지 위도는 90 이하여야 합니다.") + private Double endPlaceLat; + + @Min(value = -180, message = "도착지 경도는 -180 이상이어야 합니다.") + @Max(value = 180, message = "도착지 경도는 180 이하여야 합니다.") + private Double endPlaceLon; + + @Positive(message = "거리는 양수여야 합니다.") + private Integer distance; + + private Schedule.TransportType transportType; + + /** + * 위도와 경도는 함께 제공되어야 함 (둘 다 null이거나 둘 다 값이 있어야 함) + * 단, TRANSPORT 타입일 때는 locationLat/Lon을 사용하지 않음 (endPlaceLat/Lon 사용) + * 부분 업데이트 시에도 적용 + */ + @AssertTrue(message = "위도와 경도는 함께 제공되어야 합니다. TRANSPORT 타입일 경우 locationLat/Lon은 사용할 수 없습니다.") + private boolean isValidLocationCoordinates() { + // scheduleType이 null이면 기존 타입을 확인할 수 없으므로 통과 (Service 레벨에서 처리) + if (scheduleType == null) { + return true; + } + // TRANSPORT 타입일 때는 locationLat/Lon을 사용하지 않음 + if (scheduleType == Schedule.ScheduleType.TRANSPORT) { + return locationLat == null && locationLon == null; + } + // 일반 일정: 둘 다 null이거나 둘 다 값이 있어야 함 + return (locationLat == null && locationLon == null) || + (locationLat != null && locationLon != null); + } + + /** + * TRANSPORT 타입일 때 교통 관련 필드들이 필수인지 검증 + * 부분 업데이트 시에도 TRANSPORT 타입으로 변경하는 경우 필수 필드 검증 + */ + @AssertTrue(message = "교통 수단 타입일 경우 출발지/도착지 좌표, 거리, 교통 수단 종류는 필수입니다.") + private boolean isValidTransportFields() { + // scheduleType이 null이거나 TRANSPORT가 아니면 검증 통과 + if (scheduleType == null || scheduleType != Schedule.ScheduleType.TRANSPORT) { + return true; + } + // TRANSPORT 타입일 때 필수 필드 검증 (부분 업데이트 지원) + return startPlaceLat != null && startPlaceLon != null && + endPlaceLat != null && endPlaceLon != null && + distance != null && transportType != null; + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanDetailResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanDetailResponse.java index dcd12923..1562f05d 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanDetailResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanDetailResponse.java @@ -1,7 +1,7 @@ package com.back.web7_9_codecrete_be.domain.plans.dto.response; import com.back.web7_9_codecrete_be.domain.plans.entity.PlanParticipant; -import com.back.web7_9_codecrete_be.domain.plans.entity.Route; +import com.back.web7_9_codecrete_be.domain.plans.entity.Schedule; import lombok.Builder; import lombok.Getter; @@ -14,12 +14,14 @@ public class PlanDetailResponse { private Long id; private Long concertId; + private Long createdBy; private String title; - private String date; + private java.time.LocalDate planDate; private LocalDateTime createdDate; private LocalDateTime modifiedDate; private List participants; - private List routes; + private List schedules; + private Integer totalDuration; @Getter @Builder @@ -32,14 +34,25 @@ public static class ParticipantInfo { @Getter @Builder - public static class RouteInfo { - private Long routeId; + public static class ScheduleInfo { + private Long id; + private Schedule.ScheduleType scheduleType; + private String title; + private java.time.LocalDateTime startAt; + private Integer duration; + private String location; + private Double locationLat; + private Double locationLon; + private Integer estimatedCost; + private String details; + private Integer sequenceOrder; private Double startPlaceLat; private Double startPlaceLon; private Double endPlaceLat; private Double endPlaceLon; private Integer distance; - private Integer duration; - private Route.RouteType routeType; + private Schedule.TransportType transportType; + private LocalDateTime createdDate; + private LocalDateTime modifiedDate; } } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanListResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanListResponse.java index fab2a2bf..c1f04d3b 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanListResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanListResponse.java @@ -3,6 +3,7 @@ import lombok.Builder; import lombok.Getter; +import java.time.LocalDate; import java.time.LocalDateTime; @@ -11,8 +12,11 @@ public class PlanListResponse { private Long id; private Long concertId; + private Long createdBy; private String title; - private String date; + private LocalDate planDate; private LocalDateTime createdDate; private LocalDateTime modifiedDate; + private Integer scheduleCount; // 일정 항목 개수 + private Integer totalDuration; // 총 소요 시간 } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanResponse.java index 3b7213dd..49be541e 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanResponse.java @@ -3,6 +3,7 @@ import lombok.Builder; import lombok.Getter; +import java.time.LocalDate; import java.time.LocalDateTime; @@ -11,8 +12,9 @@ public class PlanResponse { private Long id; private Long concertId; + private Long createdBy; private String title; - private String date; + private LocalDate planDate; private LocalDateTime createdDate; private LocalDateTime modifiedDate; } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/ScheduleDeleteResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/ScheduleDeleteResponse.java new file mode 100644 index 00000000..eaf78077 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/ScheduleDeleteResponse.java @@ -0,0 +1,10 @@ +package com.back.web7_9_codecrete_be.domain.plans.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ScheduleDeleteResponse { + private Long scheduleId; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/ScheduleListResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/ScheduleListResponse.java new file mode 100644 index 00000000..f6a4a61b --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/ScheduleListResponse.java @@ -0,0 +1,14 @@ +package com.back.web7_9_codecrete_be.domain.plans.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class ScheduleListResponse { + private Long planId; + private List schedules; + private Integer totalDuration; // 총 소요 시간 (분) +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/ScheduleResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/ScheduleResponse.java new file mode 100644 index 00000000..f63384ea --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/ScheduleResponse.java @@ -0,0 +1,32 @@ +package com.back.web7_9_codecrete_be.domain.plans.dto.response; + +import com.back.web7_9_codecrete_be.domain.plans.entity.Schedule; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class ScheduleResponse { + private Long id; + private Schedule.ScheduleType scheduleType; + private String title; + private LocalDateTime startAt; + private Integer duration; + private String location; + private Double locationLat; + private Double locationLon; + private Integer estimatedCost; + private String details; + private Integer sequenceOrder; + // 교통 수단 정보 + private Double startPlaceLat; + private Double startPlaceLon; + private Double endPlaceLat; + private Double endPlaceLon; + private Integer distance; + private Schedule.TransportType transportType; + private LocalDateTime createdDate; + private LocalDateTime modifiedDate; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java index e09cb72a..38b8995c 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java @@ -1,6 +1,7 @@ package com.back.web7_9_codecrete_be.domain.plans.entity; import com.back.web7_9_codecrete_be.domain.concerts.entity.Concert; +import com.back.web7_9_codecrete_be.domain.users.entity.User; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; @@ -9,6 +10,7 @@ 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; @@ -22,17 +24,22 @@ public class Plan { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Column(name = "plan_id") + private Long planId; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "concert_id", nullable = false) private Concert concert; - @Column(name = "title", nullable = false, length = 30) + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "title", nullable = false, length = 100) private String title; - @Column(name = "date", nullable = false, length = 30) - private String date; + @Column(name = "plan_date", nullable = false) + private LocalDate planDate; @CreationTimestamp @Column(name = "created_date", nullable = false, updatable = false) @@ -47,18 +54,24 @@ public class Plan { private List participants = new ArrayList<>(); @OneToMany(mappedBy = "plan", cascade = CascadeType.ALL, orphanRemoval = true) - private List routes = new ArrayList<>(); + private List schedules = new ArrayList<>(); @Builder - public Plan(Concert concert, String title, String date) { + public Plan(Concert concert, User user, String title, LocalDate planDate) { this.concert = concert; + this.user = user; this.title = title; - this.date = date; + this.planDate = planDate; + } + + // 편의 메서드: userId를 반환 (기존 코드 호환성) + public Long getUserId() { + return user != null ? user.getId() : null; } - public void update(String title, String date) { + public void update(String title, LocalDate planDate) { this.title = title; - this.date = date; + this.planDate = planDate; } public void addParticipant(PlanParticipant participant) { @@ -66,8 +79,8 @@ public void addParticipant(PlanParticipant participant) { participant.setPlan(this); } - public void addRoute(Route route) { - this.routes.add(route); - route.setPlan(this); + public void addSchedule(Schedule schedule) { + this.schedules.add(schedule); + schedule.setPlan(this); } } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/PlanParticipant.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/PlanParticipant.java index 8e58af99..715a27d3 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/PlanParticipant.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/PlanParticipant.java @@ -1,21 +1,29 @@ package com.back.web7_9_codecrete_be.domain.plans.entity; +import com.back.web7_9_codecrete_be.domain.users.entity.User; import jakarta.persistence.*; import lombok.*; @Entity -@Table(name = "plan_participant") +@Table(name = "plan_participant", + uniqueConstraints = @UniqueConstraint(name = "uk_plan_participant_user_plan", columnNames = {"user_id", "plan_id"}), + indexes = { + @Index(name = "idx_plan_participant_user", columnList = "user_id"), + @Index(name = "idx_plan_participant_plan", columnList = "plan_id") + }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class PlanParticipant { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Column(name = "participant_id") + private Long participantId; - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; @Setter @ManyToOne(fetch = FetchType.LAZY) @@ -30,14 +38,18 @@ public class PlanParticipant { @Column(name = "role", nullable = false) private ParticipantRole role; - @Builder - public PlanParticipant(Long userId, Plan plan, InviteStatus inviteStatus, ParticipantRole role) { - this.userId = userId; + public PlanParticipant(User user, Plan plan, InviteStatus inviteStatus, ParticipantRole role) { + this.user = user; this.plan = plan; this.inviteStatus = inviteStatus; this.role = role; } + + // 편의 메서드: userId를 반환 (기존 코드 호환성) + public Long getUserId() { + return user != null ? user.getId() : null; + } public void updateInviteStatus(InviteStatus inviteStatus) { this.inviteStatus = inviteStatus; @@ -48,13 +60,17 @@ public void updateRole(ParticipantRole role) { } public enum InviteStatus { + JOINED, // 참가 PENDING, // 대기 중 ACCEPTED, // 수락 - DECLINED // 거절 + DECLINED, // 거절 + LEFT, // 나가기 + REMOVED // 강퇴 } public enum ParticipantRole { - HOST, // 주최자 - PARTICIPANT // 참가자 + OWNER, // 소유자 + EDITOR, // 편집자 + VIEWER // 뷰어 } } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Route.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Route.java deleted file mode 100644 index 877a7ac7..00000000 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Route.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.back.web7_9_codecrete_be.domain.plans.entity; - -import jakarta.persistence.*; -import lombok.*; - - -@Entity -@Table(name = "route") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Route { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "route_id") - private Long routeId; - - @Setter - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "plan_id", nullable = false) - private Plan plan; - - @Column(name = "start_place_lat", nullable = false) - private Double startPlaceLat; - - @Column(name = "start_place_lon", nullable = false) - private Double startPlaceLon; - - @Column(name = "end_place_lat", nullable = false) - private Double endPlaceLat; - - @Column(name = "end_place_lon", nullable = false) - private Double endPlaceLon; - - @Column(name = "distance", nullable = false) - private Integer distance; - - @Column(name = "duration", nullable = false) - private Integer duration; - - @Enumerated(EnumType.STRING) - @Column(name = "route_type", nullable = false) - private RouteType routeType; - - - @Builder - public Route(Plan plan, Double startPlaceLat, Double startPlaceLon, - Double endPlaceLat, Double endPlaceLon, Integer distance, - Integer duration, RouteType routeType) { - this.plan = plan; - this.startPlaceLat = startPlaceLat; - this.startPlaceLon = startPlaceLon; - this.endPlaceLat = endPlaceLat; - this.endPlaceLon = endPlaceLon; - this.distance = distance; - this.duration = duration; - this.routeType = routeType; - } - - public void updateRoute(Double startPlaceLat, Double startPlaceLon, - Double endPlaceLat, Double endPlaceLon, - Integer distance, Integer duration, RouteType routeType) { - this.startPlaceLat = startPlaceLat; - this.startPlaceLon = startPlaceLon; - this.endPlaceLat = endPlaceLat; - this.endPlaceLon = endPlaceLon; - this.distance = distance; - this.duration = duration; - this.routeType = routeType; - } - - public enum RouteType { - 도보, - 대중교통, - 차 - } -} \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Schedule.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Schedule.java new file mode 100644 index 00000000..4567de35 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Schedule.java @@ -0,0 +1,155 @@ +package com.back.web7_9_codecrete_be.domain.plans.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + + +@Entity +@Table(name = "schedule") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Schedule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "schedule_id") + private Long scheduleId; + + @Setter + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "plan_id", nullable = false) + private Plan plan; + + @Enumerated(EnumType.STRING) + @Column(name = "schedule_type", nullable = false) + private ScheduleType scheduleType; + + @Column(name = "title", length = 100, nullable = false) + private String title; + + @Column(name = "start_at", nullable = false) + private LocalDateTime startAt; + + @Column(name = "duration", nullable = false) + private Integer duration; + + @Column(name = "location", length = 255, nullable = false) + private String location; + + // 위도/경도는 선택적 (지도 표시가 필요한 경우에만 입력) + @Column(name = "location_lat") + private Double locationLat; + + @Column(name = "location_lon") + private Double locationLon; + + @Column(name = "estimated_cost", nullable = false) + private Integer estimatedCost; + + @Column(name = "details", columnDefinition = "TEXT", nullable = false) + private String details; + + @Column(name = "sequence_order", nullable = false) + private Integer sequenceOrder; + + // 교통 수단 정보 (schedule_type = 'TRANSPORT'인 경우 사용) + @Column(name = "start_place_lat") + private Double startPlaceLat; + + @Column(name = "start_place_lon") + private Double startPlaceLon; + + @Column(name = "end_place_lat") + private Double endPlaceLat; + + @Column(name = "end_place_lon") + private Double endPlaceLon; + + @Column(name = "distance") + private Integer distance; + + @Enumerated(EnumType.STRING) + @Column(name = "transport_type") + private TransportType transportType; + + @CreationTimestamp + @Column(name = "created_date", nullable = false, updatable = false) + private LocalDateTime createdDate; + + @UpdateTimestamp + @Column(name = "modified_date", nullable = false) + private LocalDateTime modifiedDate; + + + @Builder + public Schedule(Plan plan, ScheduleType scheduleType, String title, + LocalDateTime startAt, Integer duration, + String location, Double locationLat, Double locationLon, + Integer estimatedCost, String details, + Integer sequenceOrder, + Double startPlaceLat, Double startPlaceLon, + Double endPlaceLat, Double endPlaceLon, + Integer distance, TransportType transportType) { + this.plan = plan; + this.scheduleType = scheduleType; + this.title = title; + this.startAt = startAt; + this.duration = duration; + this.location = location; + this.locationLat = locationLat; + this.locationLon = locationLon; + this.estimatedCost = estimatedCost; + this.details = details; + this.sequenceOrder = sequenceOrder; + this.startPlaceLat = startPlaceLat; + this.startPlaceLon = startPlaceLon; + this.endPlaceLat = endPlaceLat; + this.endPlaceLon = endPlaceLon; + this.distance = distance; + this.transportType = transportType; + } + + public void update(ScheduleType scheduleType, String title, + LocalDateTime startAt, Integer duration, + String location, Double locationLat, Double locationLon, + Integer estimatedCost, String details, + Integer sequenceOrder, + Double startPlaceLat, Double startPlaceLon, + Double endPlaceLat, Double endPlaceLon, + Integer distance, TransportType transportType) { + this.scheduleType = scheduleType; + this.title = title; + this.startAt = startAt; + this.duration = duration; + this.location = location; + this.locationLat = locationLat; + this.locationLon = locationLon; + this.estimatedCost = estimatedCost; + this.details = details; + this.sequenceOrder = sequenceOrder; + this.startPlaceLat = startPlaceLat; + this.startPlaceLon = startPlaceLon; + this.endPlaceLat = endPlaceLat; + this.endPlaceLon = endPlaceLon; + this.distance = distance; + this.transportType = transportType; + } + + public enum ScheduleType { + TRANSPORT, // 교통 + MEAL, // 식사 + WAITING, // 대기 + ACTIVITY, // 활동 + OTHER // 기타 + } + + public enum TransportType { + 도보, + 대중교통, + 차 + } +} \ No newline at end of file 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 bd12d3fb..ef640b94 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 @@ -4,7 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; - @Repository public interface PlanParticipantRepository extends JpaRepository { diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java index 60ebf0a0..2f95d728 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java @@ -13,17 +13,29 @@ public interface PlanRepository extends JpaRepository { /** - * @EntityGraph 사용 시: - * - Plan + participants + routes를 LEFT OUTER JOIN으로 한 번에 조회. - * - 총 1번의 쿼리만 실행되어 N + 1 문제 방지 & 성능 향상. + * - Plan 상세 조회 및 권한 체크 시 + * @param id Plan ID + * @return Plan 엔티티 (concert, participants, schedules 포함) + * @EntityGraph: + * - Plan + concert + participants + schedules를 LEFT OUTER JOIN으로 한 번에 조회 + * - 총 1번의 쿼리만 실행되어 N + 1 문제 방지 & 성능 향상 */ - @EntityGraph(attributePaths = {"participants", "routes"}) + @EntityGraph(attributePaths = {"concert", "participants", "schedules"}) Optional findById(Long id); /** - * @param userId - * @return userId가 참가자로 연결된 모든 Plan을 조회 + * - 특정 사용자가 참가자로 포함된 모든 Plan 조회 + * @param userId 참가자로 포함된 사용자 ID + * @return 해당 사용자가 참가자인 모든 Plan 목록 (concert, participants, schedules 포함) */ - @EntityGraph(attributePaths = {"participants"}) - List findDistinctByParticipants_UserId(Long userId); + @EntityGraph(attributePaths = {"concert", "participants", "schedules"}) + List findDistinctByParticipants_User_Id(Long userId); + + /** + * - 사용자가 소유자인 Plan들을 조회 + * @param userId Plan을 생성한 사용자 ID + * @return 해당 사용자가 생성한 모든 Plan 목록 (schedules만 포함) + */ + @EntityGraph(attributePaths = {"schedules"}) + List findByUser_Id(Long userId); } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/RouteRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/RouteRepository.java deleted file mode 100644 index fea93199..00000000 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/RouteRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.back.web7_9_codecrete_be.domain.plans.repository; - -import com.back.web7_9_codecrete_be.domain.plans.entity.Route; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - - -@Repository -public interface RouteRepository extends JpaRepository { - -} \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/ScheduleRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/ScheduleRepository.java new file mode 100644 index 00000000..26eacd1f --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/ScheduleRepository.java @@ -0,0 +1,27 @@ +package com.back.web7_9_codecrete_be.domain.plans.repository; + +import com.back.web7_9_codecrete_be.domain.plans.entity.Schedule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ScheduleRepository extends JpaRepository { + + /** + * 특정 Plan의 모든 일정을 순서대로 조회 + */ + List findByPlan_PlanIdOrderBySequenceOrderAsc(Long planId); + + /** + * 특정 Plan에 속한 Schedule의 개수 조회 (다음 순서 계산용) + */ + long countByPlan_PlanId(Long planId); + + /** + * 특정 Plan과 ID로 일정 조회 + */ + Optional findByScheduleIdAndPlan_PlanId(Long scheduleId, Long planId); +} 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 8d55aca3..5239008c 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 @@ -2,20 +2,22 @@ import com.back.web7_9_codecrete_be.domain.concerts.entity.Concert; import com.back.web7_9_codecrete_be.domain.concerts.repository.ConcertRepository; -import com.back.web7_9_codecrete_be.domain.plans.dto.request.PlanAddRequest; -import com.back.web7_9_codecrete_be.domain.plans.dto.request.PlanUpdateRequest; -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.PlanDeleteResponse; +import com.back.web7_9_codecrete_be.domain.plans.dto.request.*; +import com.back.web7_9_codecrete_be.domain.plans.dto.response.*; import com.back.web7_9_codecrete_be.domain.plans.entity.Plan; +import com.back.web7_9_codecrete_be.domain.plans.entity.PlanParticipant; +import com.back.web7_9_codecrete_be.domain.plans.entity.Schedule; +import com.back.web7_9_codecrete_be.domain.plans.repository.PlanParticipantRepository; import com.back.web7_9_codecrete_be.domain.plans.repository.PlanRepository; +import com.back.web7_9_codecrete_be.domain.plans.repository.ScheduleRepository; +import com.back.web7_9_codecrete_be.domain.users.entity.User; import com.back.web7_9_codecrete_be.global.error.code.PlanErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.List; import java.util.stream.Collectors; @@ -27,32 +29,43 @@ public class PlanService { private final PlanRepository planRepository; private final ConcertRepository concertRepository; + private final ScheduleRepository scheduleRepository; + private final PlanParticipantRepository planParticipantRepository; /** * 계획 생성 - * - * @param concertId 콘서트 ID + * @param user 현재 로그인한 사용자 * @param request 계획 생성 요청 DTO * @return 생성된 계획 정보 */ @Transactional - public PlanResponse createPlan(Long concertId, PlanAddRequest request) { - Concert concert = concertRepository.findById(concertId) - .orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_NOT_FOUND)); + public PlanResponse createPlan(User user, PlanAddRequest request) { + Concert concert = concertRepository.findById(request.getConcertId()) + .orElseThrow(() -> new BusinessException(PlanErrorCode.CONCERT_NOT_FOUND)); Plan plan = Plan.builder() .concert(concert) + .user(user) .title(request.getTitle()) - .date(request.getDate()) + .planDate(request.getPlanDate()) + .build(); + + PlanParticipant owner = PlanParticipant.builder() + .user(user) + .plan(plan) + .inviteStatus(PlanParticipant.InviteStatus.JOINED) + .role(PlanParticipant.ParticipantRole.OWNER) .build(); + plan.addParticipant(owner); plan = planRepository.save(plan); return PlanResponse.builder() - .id(plan.getId()) + .id(plan.getPlanId()) .concertId(plan.getConcert().getConcertId()) + .createdBy(plan.getUserId()) .title(plan.getTitle()) - .date(plan.getDate()) + .planDate(plan.getPlanDate()) .createdDate(plan.getCreatedDate()) .modifiedDate(plan.getModifiedDate()) .build(); @@ -61,21 +74,36 @@ public PlanResponse createPlan(Long concertId, PlanAddRequest request) { /** * 계획 목록 조회 + * 소유자(OWNER)와 참가자(EDITOR/VIEWER)인 Plan을 모두 조회 * + * @param user 현재 로그인한 사용자 * @return 계획 목록 */ - public List getPlanList(Long userId) { - List plans = planRepository.findDistinctByParticipants_UserId(userId); + public List getPlanList(User user) { + Long userId = user.getId(); + List plans = planRepository.findDistinctByParticipants_User_Id(userId); return plans.stream() - .map(plan -> PlanListResponse.builder() - .id(plan.getId()) - .concertId(plan.getConcert().getConcertId()) - .title(plan.getTitle()) - .date(plan.getDate()) - .createdDate(plan.getCreatedDate()) - .modifiedDate(plan.getModifiedDate()) - .build()) + .map(plan -> { + // 일정 개수 및 총 소요 시간 계산 + int scheduleCount = plan.getSchedules().size(); + int totalDuration = plan.getSchedules().stream() + .filter(item -> item.getDuration() != null) + .mapToInt(Schedule::getDuration) + .sum(); + + return PlanListResponse.builder() + .id(plan.getPlanId()) + .concertId(plan.getConcert().getConcertId()) + .createdBy(plan.getUserId()) + .title(plan.getTitle()) + .planDate(plan.getPlanDate()) + .createdDate(plan.getCreatedDate()) + .modifiedDate(plan.getModifiedDate()) + .scheduleCount(scheduleCount) + .totalDuration(totalDuration) + .build(); + }) .collect(Collectors.toList()); } @@ -84,101 +112,508 @@ public List getPlanList(Long userId) { * 계획 상세 조회 * * @param planId 계획 ID + * @param user 현재 로그인한 사용자 * @return 계획 상세 정보 (참가자, 경로 포함) * @throws BusinessException 계획을 찾을 수 없는 경우 */ - public PlanDetailResponse getPlanDetail(Long planId, Long userId) { - Plan plan = planRepository.findById(planId) - .orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_NOT_FOUND)); - - boolean isParticipant = plan.getParticipants().stream() - .anyMatch(participant -> participant.getUserId().equals(userId)); + public PlanDetailResponse getPlanDetail(Long planId, User user) { + Plan plan = findPlanWithParticipantCheck(planId, user); - if (!isParticipant) { - throw new BusinessException(PlanErrorCode.PLAN_FORBIDDEN); - } - - // @EntityGraph로 이미 로드된 participants와 routes를 DTO로 변환. List participants = plan.getParticipants().stream() .map(participant -> PlanDetailResponse.ParticipantInfo.builder() - .id(participant.getId()) + .id(participant.getParticipantId()) .userId(participant.getUserId()) .inviteStatus(participant.getInviteStatus()) .role(participant.getRole()) .build()) .collect(Collectors.toList()); - List routes = plan.getRoutes().stream() - .map(route -> PlanDetailResponse.RouteInfo.builder() - .routeId(route.getRouteId()) - .startPlaceLat(route.getStartPlaceLat()) - .startPlaceLon(route.getStartPlaceLon()) - .endPlaceLat(route.getEndPlaceLat()) - .endPlaceLon(route.getEndPlaceLon()) - .distance(route.getDistance()) - .duration(route.getDuration()) - .routeType(route.getRouteType()) + // 타임라인 형태로 일정 정렬 (sequenceOrder 기준) - Repository 메서드 사용으로 일관성 향상 + List sortedSchedules = scheduleRepository + .findByPlan_PlanIdOrderBySequenceOrderAsc(planId); + + List schedules = sortedSchedules.stream() + .map(item -> PlanDetailResponse.ScheduleInfo.builder() + .id(item.getScheduleId()) + .scheduleType(item.getScheduleType()) + .title(item.getTitle()) + .startAt(item.getStartAt()) + .duration(item.getDuration()) + .location(item.getLocation()) + .locationLat(item.getLocationLat()) + .locationLon(item.getLocationLon()) + .estimatedCost(item.getEstimatedCost()) + .details(item.getDetails()) + .sequenceOrder(item.getSequenceOrder()) + .startPlaceLat(item.getStartPlaceLat()) + .startPlaceLon(item.getStartPlaceLon()) + .endPlaceLat(item.getEndPlaceLat()) + .endPlaceLon(item.getEndPlaceLon()) + .distance(item.getDistance()) + .transportType(item.getTransportType()) + .createdDate(item.getCreatedDate()) + .modifiedDate(item.getModifiedDate()) .build()) .collect(Collectors.toList()); + // 총 소요 시간 계산 + Integer totalDuration = sortedSchedules.stream() + .filter(item -> item.getDuration() != null) + .mapToInt(Schedule::getDuration) + .sum(); + return PlanDetailResponse.builder() - .id(plan.getId()) + .id(plan.getPlanId()) .concertId(plan.getConcert().getConcertId()) + .createdBy(plan.getUserId()) .title(plan.getTitle()) - .date(plan.getDate()) + .planDate(plan.getPlanDate()) .createdDate(plan.getCreatedDate()) .modifiedDate(plan.getModifiedDate()) .participants(participants) - .routes(routes) + .schedules(schedules) + .totalDuration(totalDuration) .build(); } + /** * 계획 수정 * * @param planId 계획 ID + * @param user 현재 로그인한 사용자 * @param request 계획 수정 요청 DTO * @return 수정된 계획 정보 * @throws BusinessException 계획을 찾을 수 없는 경우 */ @Transactional - public PlanResponse updatePlan(Long planId, PlanUpdateRequest request) { - Plan plan = planRepository.findById(planId) - .orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_NOT_FOUND)); + public PlanResponse updatePlan(Long planId, User user, PlanUpdateRequest request) { + Plan plan = findPlanWithEditPermissionCheck(planId, user); - // 엔티티의 비즈니스 로직을 통해 수정 - plan.update(request.getTitle(), request.getDate()); + // 부분 업데이트 (null인 경우 기존 값 유지) + String title = request.getTitle() != null ? request.getTitle() : plan.getTitle(); + LocalDate planDate = request.getPlanDate() != null ? request.getPlanDate() : plan.getPlanDate(); + + // 엔티티의 로직을 통해 수정 + plan.update(title, planDate); return PlanResponse.builder() - .id(plan.getId()) + .id(plan.getPlanId()) .concertId(plan.getConcert().getConcertId()) + .createdBy(plan.getUserId()) .title(plan.getTitle()) - .date(plan.getDate()) + .planDate(plan.getPlanDate()) .createdDate(plan.getCreatedDate()) .modifiedDate(plan.getModifiedDate()) .build(); } + /** * 계획 삭제 * * @param planId 계획 ID + * @param user 현재 로그인한 사용자 * @return 삭제된 계획 ID * @throws BusinessException 계획을 찾을 수 없는 경우 */ @Transactional - public PlanDeleteResponse deletePlan(Long planId) { - Plan plan = planRepository.findById(planId) - .orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_NOT_FOUND)); + public PlanDeleteResponse deletePlan(Long planId, User user) { + Plan plan = findPlanWithEditPermissionCheck(planId, user); + Long deletedPlanId = plan.getPlanId(); - // Plan 삭제 시 cascade 설정으로 인해 participants와 routes도 함께 삭제. + // Plan 삭제 시 cascade 설정으로 인해 participants와 schedules도 함께 삭제. planRepository.delete(plan); return PlanDeleteResponse.builder() + .planId(deletedPlanId) + .build(); + } + + + /** + * 일정 추가 + * + * @param planId 계획 ID + * @param user 현재 로그인한 사용자 + * @param request 일정 추가 요청 DTO + * @return 생성된 일정 정보 + */ + @Transactional + public ScheduleResponse addSchedule(Long planId, User user, ScheduleAddRequest request) { + Plan plan = findPlanWithEditPermissionCheck(planId, user); + + // TRANSPORT 타입일 때 locationLat/Lon은 사용하지 않음 (endPlaceLat/Lon 사용) + if (request.getScheduleType() == Schedule.ScheduleType.TRANSPORT) { + if (request.getLocationLat() != null || request.getLocationLon() != null) { + throw new BusinessException(PlanErrorCode.SCHEDULE_INVALID_LOCATION_FOR_TRANSPORT); + } + // TRANSPORT 타입인 경우 필수 필드 검증 + if (request.getStartPlaceLat() == null || request.getStartPlaceLon() == null || + request.getEndPlaceLat() == null || request.getEndPlaceLon() == null || + request.getDistance() == null || request.getTransportType() == null) { + throw new BusinessException(PlanErrorCode.SCHEDULE_INVALID_TRANSPORT_FIELDS); + } + } + + // 순서가 지정되지 않은 경우, 마지막 순서 + 1로 설정 + Integer sequenceOrder = request.getSequenceOrder(); + if (sequenceOrder == null) { + long count = scheduleRepository.countByPlan_PlanId(planId); + sequenceOrder = (int) count + 1; + } + + Schedule schedule = Schedule.builder() + .plan(plan) + .scheduleType(request.getScheduleType()) + .title(request.getTitle()) + .startAt(request.getStartAt()) + .duration(request.getDuration()) + .location(request.getLocation()) + .locationLat(request.getLocationLat()) + .locationLon(request.getLocationLon()) + .estimatedCost(request.getEstimatedCost()) + .details(request.getDetails()) + .sequenceOrder(sequenceOrder) + .startPlaceLat(request.getStartPlaceLat()) + .startPlaceLon(request.getStartPlaceLon()) + .endPlaceLat(request.getEndPlaceLat()) + .endPlaceLon(request.getEndPlaceLon()) + .distance(request.getDistance()) + .transportType(request.getTransportType()) + .build(); + + plan.addSchedule(schedule); + // cascade 설정으로 인해 plan 저장 시 schedule도 함께 저장됨 + planRepository.save(plan); + + return toScheduleResponse(schedule); + } + + /** + * 일정 목록 조회 (타임라인 형태) + * + * @param planId 계획 ID + * @param user 현재 로그인한 사용자 + * @return 일정 목록 (순서대로 정렬) + */ + public ScheduleListResponse getSchedules(Long planId, User user) { + // 권한 체크 (참가자 여부 확인) + findPlanWithParticipantCheck(planId, user); + + List schedules = scheduleRepository + .findByPlan_PlanIdOrderBySequenceOrderAsc(planId); + + List scheduleResponses = schedules.stream() + .map(this::toScheduleResponse) + .collect(Collectors.toList()); + + // 총 소요 시간 계산 + Integer totalDuration = schedules.stream() + .filter(item -> item.getDuration() != null) + .mapToInt(Schedule::getDuration) + .sum(); + + return ScheduleListResponse.builder() .planId(planId) + .schedules(scheduleResponses) + .totalDuration(totalDuration) .build(); } + /** + * 일정 상세 조회 + * + * @param planId 계획 ID + * @param scheduleId 일정 ID + * @param user 현재 로그인한 사용자 + * @return 일정 상세 정보 + */ + public ScheduleResponse getSchedule(Long planId, Long scheduleId, User user) { + // 권한 체크 (참가자 여부 확인) + findPlanWithParticipantCheck(planId, user); + + Schedule schedule = scheduleRepository + .findByScheduleIdAndPlan_PlanId(scheduleId, planId) + .orElseThrow(() -> new BusinessException(PlanErrorCode.SCHEDULE_NOT_FOUND)); + + return toScheduleResponse(schedule); + } + + + /** + * 일정 수정 + * + * @param planId 계획 ID + * @param scheduleId 일정 ID + * @param user 현재 로그인한 사용자 + * @param request 일정 수정 요청 DTO + * @return 수정된 일정 정보 + */ + @Transactional + public ScheduleResponse updateSchedule(Long planId, Long scheduleId, User user, + ScheduleUpdateRequest request) { + // 권한 체크 (수정 권한 확인: OWNER 또는 EDITOR) + findPlanWithEditPermissionCheck(planId, user); + + Schedule schedule = scheduleRepository + .findByScheduleIdAndPlan_PlanId(scheduleId, planId) + .orElseThrow(() -> new BusinessException(PlanErrorCode.SCHEDULE_NOT_FOUND)); + + // 부분 업데이트 지원: null인 경우 기존 값 유지 + Schedule.ScheduleType newScheduleType = request.getScheduleType() != null + ? request.getScheduleType() + : schedule.getScheduleType(); + + // TRANSPORT 타입일 때 locationLat/Lon은 사용하지 않음 (endPlaceLat/Lon 사용) + if (newScheduleType == Schedule.ScheduleType.TRANSPORT) { + // locationLat/Lon이 제공된 경우 에러 + if (request.getLocationLat() != null || request.getLocationLon() != null) { + throw new BusinessException(PlanErrorCode.SCHEDULE_INVALID_LOCATION_FOR_TRANSPORT); + } + } + + // 일반 일정일 때 위도/경도는 쌍으로만 허용 (단독 입력 방지) + if (newScheduleType != Schedule.ScheduleType.TRANSPORT) { + boolean isLatProvided = request.getLocationLat() != null; + boolean isLonProvided = request.getLocationLon() != null; + if (isLatProvided ^ isLonProvided) { + throw new BusinessException(PlanErrorCode.SCHEDULE_INVALID_LOCATION_COORDINATES); + } + } + + // TRANSPORT 타입인 경우 필수 필드 검증 + if (newScheduleType == Schedule.ScheduleType.TRANSPORT) { + Double startPlaceLat = request.getStartPlaceLat() != null + ? request.getStartPlaceLat() + : schedule.getStartPlaceLat(); + Double startPlaceLon = request.getStartPlaceLon() != null + ? request.getStartPlaceLon() + : schedule.getStartPlaceLon(); + Double endPlaceLat = request.getEndPlaceLat() != null + ? request.getEndPlaceLat() + : schedule.getEndPlaceLat(); + Double endPlaceLon = request.getEndPlaceLon() != null + ? request.getEndPlaceLon() + : schedule.getEndPlaceLon(); + Integer distance = request.getDistance() != null + ? request.getDistance() + : schedule.getDistance(); + Schedule.TransportType transportType = request.getTransportType() != null + ? request.getTransportType() + : schedule.getTransportType(); + + if (startPlaceLat == null || startPlaceLon == null || + endPlaceLat == null || endPlaceLon == null || + distance == null || transportType == null) { + throw new BusinessException(PlanErrorCode.SCHEDULE_INVALID_TRANSPORT_FIELDS); + } + } + + schedule.update( + newScheduleType, + request.getTitle() != null ? request.getTitle() : schedule.getTitle(), + request.getStartAt() != null ? request.getStartAt() : schedule.getStartAt(), + request.getDuration() != null ? request.getDuration() : schedule.getDuration(), + request.getLocation() != null ? request.getLocation() : schedule.getLocation(), + request.getLocationLat() != null ? request.getLocationLat() : schedule.getLocationLat(), + request.getLocationLon() != null ? request.getLocationLon() : schedule.getLocationLon(), + request.getEstimatedCost() != null ? request.getEstimatedCost() : schedule.getEstimatedCost(), + request.getDetails() != null ? request.getDetails() : schedule.getDetails(), + request.getSequenceOrder() != null ? request.getSequenceOrder() : schedule.getSequenceOrder(), + request.getStartPlaceLat() != null ? request.getStartPlaceLat() : schedule.getStartPlaceLat(), + request.getStartPlaceLon() != null ? request.getStartPlaceLon() : schedule.getStartPlaceLon(), + request.getEndPlaceLat() != null ? request.getEndPlaceLat() : schedule.getEndPlaceLat(), + request.getEndPlaceLon() != null ? request.getEndPlaceLon() : schedule.getEndPlaceLon(), + request.getDistance() != null ? request.getDistance() : schedule.getDistance(), + request.getTransportType() != null ? request.getTransportType() : schedule.getTransportType() + ); + + return toScheduleResponse(schedule); + } + + + /** + * 일정 삭제 + * + * @param planId 계획 ID + * @param scheduleId 일정 ID + * @param user 현재 로그인한 사용자 + * @return 삭제된 일정 ID + */ + @Transactional + public ScheduleDeleteResponse deleteSchedule(Long planId, Long scheduleId, User user) { + // 권한 체크 (수정 권한 확인: OWNER 또는 EDITOR) + findPlanWithEditPermissionCheck(planId, user); + + Schedule schedule = scheduleRepository + .findByScheduleIdAndPlan_PlanId(scheduleId, planId) + .orElseThrow(() -> new BusinessException(PlanErrorCode.SCHEDULE_NOT_FOUND)); + + Integer deletedSequenceOrder = schedule.getSequenceOrder(); + scheduleRepository.delete(schedule); + + // 삭제된 일정 이후의 일정들의 sequenceOrder를 1씩 감소시켜 재정렬 + if (deletedSequenceOrder != null) { + List remainingSchedules = scheduleRepository + .findByPlan_PlanIdOrderBySequenceOrderAsc(planId); + + // 삭제된 순서보다 큰 일정들만 업데이트 + remainingSchedules.stream() + .filter(s -> s.getSequenceOrder() != null && s.getSequenceOrder() > deletedSequenceOrder) + .forEach(remainingSchedule -> { + remainingSchedule.update( + remainingSchedule.getScheduleType(), + remainingSchedule.getTitle(), + remainingSchedule.getStartAt(), + remainingSchedule.getDuration(), + remainingSchedule.getLocation(), + remainingSchedule.getLocationLat(), + remainingSchedule.getLocationLon(), + remainingSchedule.getEstimatedCost(), + remainingSchedule.getDetails(), + remainingSchedule.getSequenceOrder() - 1, + remainingSchedule.getStartPlaceLat(), + remainingSchedule.getStartPlaceLon(), + remainingSchedule.getEndPlaceLat(), + remainingSchedule.getEndPlaceLon(), + remainingSchedule.getDistance(), + remainingSchedule.getTransportType() + ); + }); + } + + return ScheduleDeleteResponse.builder() + .scheduleId(scheduleId) + .build(); + } + + /** + * Plan을 조회하고 참가자 권한을 체크하는 메서드 + * + * @param planId 계획 ID + * @param user 현재 로그인한 사용자 + * @return Plan 엔티티 + * @throws BusinessException 계획을 찾을 수 없거나 권한이 없는 경우 + */ + private Plan findPlanWithParticipantCheck(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)) { + return plan; + } + + // 소유자가 아닌 경우 PlanParticipant에서 참가 여부 확인 + plan.getParticipants().size(); + + boolean isParticipant = plan.getParticipants().stream() + .anyMatch(participant -> participant.getUserId().equals(userId)); + + if (!isParticipant) { + throw new BusinessException(PlanErrorCode.PLAN_FORBIDDEN); + } + + return plan; + } + + /** + * Plan을 조회하고 수정/삭제 권한을 체크 + * OWNER 또는 EDITOR만 수정/삭제 가능 + * + * @param planId 계획 ID + * @param user 현재 로그인한 사용자 + * @return Plan 엔티티 + * @throws BusinessException 계획을 찾을 수 없거나 권한이 없는 경우 + */ + private Plan findPlanWithEditPermissionCheck(Long planId, User user) { + Long userId = user.getId(); + Plan plan = planRepository.findById(planId) + .orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_NOT_FOUND)); + + // 소유자는 항상 OWNER 역할이므로 바로 통과 + if (plan.getUserId().equals(userId)) { + return plan; + } + + // 소유자가 아닌 경우 PlanParticipant에서 role 확인 + plan.getParticipants().size(); + + PlanParticipant participant = plan.getParticipants().stream() + .filter(p -> p.getUserId().equals(userId)) + .findFirst() + .orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_FORBIDDEN)); + + // VIEWER는 수정/삭제 불가 + if (participant.getRole() == PlanParticipant.ParticipantRole.VIEWER) { + throw new BusinessException(PlanErrorCode.PLAN_UNAUTHORIZED); + } + + return plan; + } + + private ScheduleResponse toScheduleResponse(Schedule schedule) { + return ScheduleResponse.builder() + .id(schedule.getScheduleId()) + .scheduleType(schedule.getScheduleType()) + .title(schedule.getTitle()) + .startAt(schedule.getStartAt()) + .duration(schedule.getDuration()) + .location(schedule.getLocation()) + .locationLat(schedule.getLocationLat()) + .locationLon(schedule.getLocationLon()) + .estimatedCost(schedule.getEstimatedCost()) + .details(schedule.getDetails()) + .sequenceOrder(schedule.getSequenceOrder()) + .startPlaceLat(schedule.getStartPlaceLat()) + .startPlaceLon(schedule.getStartPlaceLon()) + .endPlaceLat(schedule.getEndPlaceLat()) + .endPlaceLon(schedule.getEndPlaceLon()) + .distance(schedule.getDistance()) + .transportType(schedule.getTransportType()) + .createdDate(schedule.getCreatedDate()) + .modifiedDate(schedule.getModifiedDate()) + .build(); + } + + + /** + * 참가자 역할 수정 + * + * @param planId 계획 ID + * @param participantId 참가자 ID + * @param user 현재 로그인한 사용자 (권한 체크용) + * @param request 역할 수정 요청 DTO + * @throws BusinessException 계획을 찾을 수 없거나 권한이 없는 경우 + */ + @Transactional + public void updateParticipantRole(Long planId, Long participantId, User user, + PlanParticipantRoleUpdateRequest request) { + // 권한 체크 (수정 권한 확인: OWNER 또는 EDITOR) + findPlanWithEditPermissionCheck(planId, user); + + PlanParticipant participant = planParticipantRepository.findById(participantId) + .orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_NOT_FOUND)); + + // 참가자가 해당 Plan에 속해있는지 확인 + if (!participant.getPlan().getPlanId().equals(planId)) { + throw new BusinessException(PlanErrorCode.PLAN_NOT_FOUND); + } + + // OWNER 역할은 변경할 수 없음 + if (participant.getRole() == PlanParticipant.ParticipantRole.OWNER) { + throw new BusinessException(PlanErrorCode.PLAN_UNAUTHORIZED); + } + + // OWNER 역할로 변경할 수 없음 (소유자는 Plan의 userId로 결정됨) + if (request.getRole() == PlanParticipant.ParticipantRole.OWNER) { + throw new BusinessException(PlanErrorCode.PLAN_UNAUTHORIZED); + } + + participant.updateRole(request.getRole()); + } // 계획 공유 초대 // 계획 공유 수락 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 d63ee5e6..c4d9393e 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 @@ -10,11 +10,15 @@ public enum PlanErrorCode implements ErrorCode { PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "P-100", "계획을 찾을 수 없습니다."), - PLAN_FORBIDDEN(HttpStatus.FORBIDDEN, "P-101", "해당 계획에 접근할 수 없습니다."); + PLAN_FORBIDDEN(HttpStatus.FORBIDDEN, "P-101", "해당 계획에 접근할 수 없습니다."), + SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "P-102", "일정을 찾을 수 없습니다."), + PLAN_UNAUTHORIZED(HttpStatus.FORBIDDEN, "P-103", "해당 작업을 수행할 권한이 없습니다."), + SCHEDULE_INVALID_TRANSPORT_FIELDS(HttpStatus.BAD_REQUEST, "P-104", "교통 수단 타입일 경우 출발지/도착지 좌표, 거리, 교통 수단 종류는 필수입니다."), + SCHEDULE_INVALID_LOCATION_FOR_TRANSPORT(HttpStatus.BAD_REQUEST, "P-105", "교통 수단 타입일 경우 locationLat/Lon은 사용할 수 없습니다. endPlaceLat/Lon을 사용해주세요."), + SCHEDULE_INVALID_LOCATION_COORDINATES(HttpStatus.BAD_REQUEST, "P-107", "위도와 경도는 함께 제공되어야 합니다."), + CONCERT_NOT_FOUND(HttpStatus.NOT_FOUND, "P-106", "공연을 찾을 수 없습니다."); private final HttpStatus status; private final String code; private final String message; - -} - +} \ No newline at end of file