diff --git a/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java b/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java index 1745ccd0..638d9e56 100644 --- a/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java +++ b/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java @@ -1,30 +1,89 @@ package com.back.domain.study.plan.controller; -import com.back.domain.study.plan.dto.StudyPlanCreateRequest; +import com.back.domain.study.plan.dto.StudyPlanRequest; +import com.back.domain.study.plan.dto.StudyPlanListResponse; import com.back.domain.study.plan.dto.StudyPlanResponse; +import com.back.domain.study.plan.entity.StudyPlanException; import com.back.domain.study.plan.service.StudyPlanService; import com.back.global.common.dto.RsData; +import com.back.global.security.CustomUserDetails; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.util.List; + @RestController @RequiredArgsConstructor @RequestMapping("/api/plans") public class StudyPlanController { private final StudyPlanService studyPlanService; - + // ==================== 생성 =================== @PostMapping public ResponseEntity> createStudyPlan( // 로그인 유저 정보 받기 @AuthenticationPrincipal CustomUserDetails user, - @RequestBody StudyPlanCreateRequest request) { + @RequestBody StudyPlanRequest request) { //커스텀 디테일 구현 시 사용 int userId = user.getId(); Long userId = 1L; // 임시로 userId를 1로 설정 StudyPlanResponse response = studyPlanService.createStudyPlan(userId, request); return ResponseEntity.ok(RsData.success("학습 계획이 성공적으로 생성되었습니다.", response)); } + // ==================== 조회 =================== + // 특정 날짜의 계획들 조회. date 형식: YYYY-MM-DD + @GetMapping("/date/{date}") + public ResponseEntity> getStudyPlansForDate( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + // 유저 아이디 추출. 지금은 임시값 적용 + // Long userId = user.getId(); + Long userId = 1L; // 임시로 userId를 1로 설정 + + List plans = studyPlanService.getStudyPlansForDate(userId, date); + StudyPlanListResponse response = new StudyPlanListResponse(date, plans, plans.size()); + + return ResponseEntity.ok(RsData.success("해당 날짜의 계획을 조회했습니다.", response)); + } + + // 기간별 계획 조회. start, end 형식: YYYY-MM-DD + @GetMapping + public ResponseEntity>> getStudyPlansForPeriod( + // @AuthenticationPrincipal CustomUserDetails user, + @RequestParam("start") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam("end") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + // Long userId = user.getId(); + Long userId = 1L; // 임시 + + List plans = studyPlanService.getStudyPlansForPeriod(userId, startDate, endDate); + + return ResponseEntity.ok(RsData.success("기간별 계획을 조회했습니다.", plans)); + } + + + + // ==================== 수정 =================== + // 플랜 아이디는 원본의 아이디를 받음 + // 가상인지 원본인지는 서비스에서 원본과 날짜를 대조해 판단 + // 수정 적용 범위를 쿼리 파라미터로 받음 (THIS_ONLY, FROM_THIS_DATE) + @PutMapping("/{planId}") + public ResponseEntity> updateStudyPlan( + // @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long planId, + @RequestBody StudyPlanRequest request, + @RequestParam(required = false, defaultValue = "THIS_ONLY") StudyPlanException.ApplyScope applyScope) { + // Long userId = user.getId(); + Long userId = 1L; // 임시 + + StudyPlanResponse response = studyPlanService.updateStudyPlan(userId, planId, request, applyScope); + return ResponseEntity.ok(RsData.success("학습 계획이 성공적으로 수정되었습니다.", response)); + } + + + + // ==================== 삭제 =================== @DeleteMapping("/{planId}") public ResponseEntity> deleteStudyPlan(@PathVariable Long planId) { //studyPlanService.deleteStudyPlan(planId); @@ -33,5 +92,4 @@ public ResponseEntity> deleteStudyPlan(@PathVariable Long planId) { - } diff --git a/src/main/java/com/back/domain/study/plan/dto/StudyPlanListResponse.java b/src/main/java/com/back/domain/study/plan/dto/StudyPlanListResponse.java new file mode 100644 index 00000000..cb7de60c --- /dev/null +++ b/src/main/java/com/back/domain/study/plan/dto/StudyPlanListResponse.java @@ -0,0 +1,19 @@ +package com.back.domain.study.plan.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class StudyPlanListResponse { + private LocalDate date; + private List plans; + private int totalCount; +} diff --git a/src/main/java/com/back/domain/study/plan/dto/StudyPlanCreateRequest.java b/src/main/java/com/back/domain/study/plan/dto/StudyPlanRequest.java similarity index 96% rename from src/main/java/com/back/domain/study/plan/dto/StudyPlanCreateRequest.java rename to src/main/java/com/back/domain/study/plan/dto/StudyPlanRequest.java index 5b5bfbcd..f0c8b8cd 100644 --- a/src/main/java/com/back/domain/study/plan/dto/StudyPlanCreateRequest.java +++ b/src/main/java/com/back/domain/study/plan/dto/StudyPlanRequest.java @@ -14,7 +14,7 @@ @Setter @NoArgsConstructor @AllArgsConstructor -public class StudyPlanCreateRequest { +public class StudyPlanRequest { private String subject; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") diff --git a/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java b/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java index 1f204e29..8f4e1cf2 100644 --- a/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java +++ b/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java @@ -10,6 +10,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; @@ -31,9 +32,6 @@ public class StudyPlanResponse { private Color color; - private Long parentPlanId; - private List childPlans; - // RepeatRule 정보 private RepeatRuleResponse repeatRule; @@ -47,14 +45,14 @@ public static class RepeatRuleResponse { private String byDay; // "MON" 형태의 문자열 @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") - private LocalDateTime until; + private LocalDate untilDate; public RepeatRuleResponse(com.back.domain.study.plan.entity.RepeatRule repeatRule) { if (repeatRule != null) { this.frequency = repeatRule.getFrequency(); this.repeatInterval = repeatRule.getRepeatInterval(); this.byDay = repeatRule.getByDay(); - this.until = repeatRule.getUntilDate(); + this.untilDate = repeatRule.getUntilDate(); } } diff --git a/src/main/java/com/back/domain/study/plan/entity/RepeatRule.java b/src/main/java/com/back/domain/study/plan/entity/RepeatRule.java index 1d7c7120..fe1d7268 100644 --- a/src/main/java/com/back/domain/study/plan/entity/RepeatRule.java +++ b/src/main/java/com/back/domain/study/plan/entity/RepeatRule.java @@ -7,7 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDateTime; +import java.time.LocalDate; @Entity @Getter @@ -25,10 +25,9 @@ public class RepeatRule extends BaseEntity { @Column(name = "interval_value", nullable = false) private int RepeatInterval; - //필요 시 요일 지정. 여러 요일 지정 시 ,로 구분 - //현재는 요일 하나만 지정하는 형태로 구현 + //요일은 계획 날짜에 따라 자동 설정 @Column(name = "by_day") private String byDay; - private LocalDateTime untilDate; + private LocalDate untilDate; } diff --git a/src/main/java/com/back/domain/study/plan/entity/RepeatRuleEmbeddable.java b/src/main/java/com/back/domain/study/plan/entity/RepeatRuleEmbeddable.java new file mode 100644 index 00000000..e480d588 --- /dev/null +++ b/src/main/java/com/back/domain/study/plan/entity/RepeatRuleEmbeddable.java @@ -0,0 +1,25 @@ +package com.back.domain.study.plan.entity; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +@Embeddable +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class RepeatRuleEmbeddable { + @Enumerated(EnumType.STRING) + private Frequency frequency; + + private Integer intervalValue; + private String byDay; + private LocalDate untilDate; // LocalDateTime → LocalDate 변경 +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/study/plan/entity/StudyPlanException.java b/src/main/java/com/back/domain/study/plan/entity/StudyPlanException.java index 5c94a3c1..d150cd5c 100644 --- a/src/main/java/com/back/domain/study/plan/entity/StudyPlanException.java +++ b/src/main/java/com/back/domain/study/plan/entity/StudyPlanException.java @@ -7,6 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; +import java.time.LocalDate; import java.time.LocalDateTime; @Entity @@ -60,4 +61,13 @@ public enum ApplyScope { THIS_ONLY, // 이 날짜만 FROM_THIS_DATE // 이 날짜부터 이후 모든 날짜 } + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "frequency", column = @Column(name = "modified_frequency")), + @AttributeOverride(name = "intervalValue", column = @Column(name = "modified_repeat_interval")), + @AttributeOverride(name = "byDay", column = @Column(name = "modified_by_day")), + @AttributeOverride(name = "untilDate", column = @Column(name = "modified_until_date")) + }) + private RepeatRuleEmbeddable modifiedRepeatRule; } \ No newline at end of file diff --git a/src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java b/src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java new file mode 100644 index 00000000..1a634b79 --- /dev/null +++ b/src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java @@ -0,0 +1,36 @@ +package com.back.domain.study.plan.repository; + +import com.back.domain.study.plan.entity.StudyPlanException; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface StudyPlanExceptionRepository extends JpaRepository { + // FROM_THIS_DATE 범위의 예외들 중 특정 날짜 이전의 예외 조회 + @Query("SELECT spe FROM StudyPlanException spe WHERE spe.studyPlan.id = :planId " + + "AND spe.applyScope = :applyScope " + + "AND spe.exceptionDate <= :targetDate " + + "ORDER BY spe.exceptionDate DESC") + List findByStudyPlanIdAndApplyScopeAndExceptionDateBefore( + @Param("planId") Long planId, + @Param("applyScope") StudyPlanException.ApplyScope applyScope, + @Param("targetDate") LocalDateTime targetDate); +// 특정 계획의 특정 기간 동안(start~end)의 예외를 조회 + @Query("SELECT spe FROM StudyPlanException spe WHERE spe.studyPlan.id = :planId " + + "AND spe.exceptionDate BETWEEN :startDate AND :endDate " + + "ORDER BY spe.exceptionDate") + List findByStudyPlanIdAndExceptionDateBetween(@Param("planId") Long planId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate); + // 특정 계획의 특정 날짜 예외 조회 + @Query("SELECT spe FROM StudyPlanException spe WHERE spe.studyPlan.id = :planId " + + "AND DATE(spe.exceptionDate) = DATE(:targetDate)") + Optional findByPlanIdAndDate(@Param("planId") Long planId, + @Param("targetDate") LocalDateTime targetDate); +} diff --git a/src/main/java/com/back/domain/study/plan/repository/StudyPlanRepository.java b/src/main/java/com/back/domain/study/plan/repository/StudyPlanRepository.java index 53c35c4c..9c04caa9 100644 --- a/src/main/java/com/back/domain/study/plan/repository/StudyPlanRepository.java +++ b/src/main/java/com/back/domain/study/plan/repository/StudyPlanRepository.java @@ -4,7 +4,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -@Repository -public interface StudyPlanRepository extends JpaRepository { +import java.util.List; +@Repository +public interface StudyPlanRepository extends JpaRepository { + List findByUserId(Long userId); } diff --git a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java index 306bf9d8..418df4a3 100644 --- a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java +++ b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java @@ -1,26 +1,43 @@ package com.back.domain.study.plan.service; -import com.back.domain.study.plan.dto.StudyPlanCreateRequest; +import com.back.domain.study.plan.dto.StudyPlanRequest; import com.back.domain.study.plan.dto.StudyPlanResponse; import com.back.domain.study.plan.entity.RepeatRule; +import com.back.domain.study.plan.entity.RepeatRuleEmbeddable; import com.back.domain.study.plan.entity.StudyPlan; +import com.back.domain.study.plan.entity.StudyPlanException; +import com.back.domain.study.plan.repository.StudyPlanExceptionRepository; import com.back.domain.study.plan.repository.StudyPlanRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class StudyPlanService{ private final StudyPlanRepository studyPlanRepository; + private final StudyPlanExceptionRepository studyPlanExceptionRepository; + // ==================== 생성 =================== @Transactional - public StudyPlanResponse createStudyPlan(Long userId, StudyPlanCreateRequest request) { - + public StudyPlanResponse createStudyPlan(Long userId, StudyPlanRequest request) { + /*User user = UserRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + */ StudyPlan studyPlan = new StudyPlan(); + //studyPlan.setUser(user); studyPlan.setSubject(request.getSubject()); studyPlan.setStartDate(request.getStartDate()); @@ -29,35 +46,420 @@ public StudyPlanResponse createStudyPlan(Long userId, StudyPlanCreateRequest req // 반복 규칙 설정 if (request.getRepeatRule() != null) { - StudyPlanCreateRequest.RepeatRuleRequest repeatRuleRequest = request.getRepeatRule(); - RepeatRule repeatRule = new RepeatRule(); - repeatRule.setStudyPlan(studyPlan); - repeatRule.setFrequency(repeatRuleRequest.getFrequency()); - repeatRule.setRepeatInterval(repeatRuleRequest.getIntervalValue() != null ? repeatRuleRequest.getIntervalValue() : 1); - - // byDay 문자열 그대로 저장 - if (repeatRuleRequest.getByDay() != null && !repeatRuleRequest.getByDay().isEmpty()) { - repeatRule.setByDay(repeatRuleRequest.getByDay()); + RepeatRule repeatRule = createRepeatRule(request.getRepeatRule(), studyPlan); + studyPlan.setRepeatRule(repeatRule); + } + + StudyPlan savedPlan = studyPlanRepository.save(studyPlan); + return new StudyPlanResponse(savedPlan); + } + private RepeatRule createRepeatRule(StudyPlanRequest.RepeatRuleRequest request, StudyPlan studyPlan) { + RepeatRule repeatRule = new RepeatRule(); + repeatRule.setStudyPlan(studyPlan); + repeatRule.setFrequency(request.getFrequency()); + repeatRule.setRepeatInterval(request.getIntervalValue() != null ? request.getIntervalValue() : 1); + + if (request.getByDay() != null && !request.getByDay().isEmpty()) { + repeatRule.setByDay(request.getByDay()); + } + + if (request.getUntilDate() != null && !request.getUntilDate().isEmpty()) { + try { + LocalDate untilDate = LocalDate.parse(request.getUntilDate()); + repeatRule.setUntilDate(untilDate); + } catch (Exception e) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + } + + return repeatRule; + } + + // ==================== 조회 =================== + //특정 날짜 계획 조회 + public List getStudyPlansForDate(Long userId, LocalDate date) { + //원복 계획들 + 단발성 계획 조회 + List userPlans = studyPlanRepository.findByUserId(userId); + List result = new ArrayList<>(); + + for (StudyPlan plan : userPlans) { + if (plan.getRepeatRule() == null) { + // 단발성 계획 또는 원본(시작 날짜가 타겟 날짜랑 일치) + // 바로 추가 + if (plan.getStartDate().toLocalDate().isEqual(date)) { + result.add(new StudyPlanResponse(plan)); + } + } else { + // 반복성 계획 - 가상 계획 생성 후 추가 + StudyPlanResponse virtualPlan = createVirtualPlanForDate(plan, date); + if (virtualPlan != null) { + result.add(virtualPlan); + } + } + } + + return result; + } + + // 기간별 계획 조회 + public List getStudyPlansForPeriod(Long userId, LocalDate start, LocalDate end) { + List userPlans = studyPlanRepository.findByUserId(userId); + List result = new ArrayList<>(); + + LocalDate currentDate = start; + // 날짜 범위 내에서 반복 + while (!currentDate.isAfter(end)) { + for (StudyPlan plan : userPlans) { + if (plan.getRepeatRule() == null) { + // 단발성 계획은 그대로 추가 + if (plan.getStartDate().toLocalDate().isEqual(currentDate)) { + result.add(new StudyPlanResponse(plan)); + } + } else { + // 반복성 계획은 가상 계획화 후 추가 + StudyPlanResponse virtualPlan = createVirtualPlanForDate(plan, currentDate); + if (virtualPlan != null) { + result.add(virtualPlan); + } + } + } + currentDate = currentDate.plusDays(1); + } + + return result.stream() + .sorted(Comparator.comparing(StudyPlanResponse::getStartDate)) + .collect(Collectors.toList()); + } + + // 반복 계획을 위한 가상 계획 생성 + private StudyPlanResponse createVirtualPlanForDate(StudyPlan originalPlan, LocalDate targetDate) { + RepeatRule repeatRule = originalPlan.getRepeatRule(); + LocalDate planStartDate = originalPlan.getStartDate().toLocalDate(); + + // 대상 날짜가 계획 시작일 이전이면 null 반환 + if (targetDate.isBefore(planStartDate)) { + return null; + } + + // untilDate 확인. 방어적 검증을 위해 null 체크 한번 더 + if (repeatRule.getUntilDate() != null && + targetDate.isAfter(repeatRule.getUntilDate())) { + return null; + } + + // 반복 패턴 확인 후 타겟 날짜가 해당되는지 확인 + if (!shouldRepeatOnDate(originalPlan, targetDate)) { + return null; + } + + // 해당 날짜 계획의 예외 확인 + StudyPlanException exception = getEffectiveException(originalPlan.getId(), targetDate); + if (exception != null) { + //삭제 타입의 경우 null + if (exception.getExceptionType() == StudyPlanException.ExceptionType.DELETED) { + return null; } + // 수정 타입의 경우 수정된 내용으로 가상 정보 생성 후 반환 + return createModifiedVirtualPlan(originalPlan, exception, targetDate); + } + + //예외 사항 없으면 기본 가상 계획 생성 + return createBasicVirtualPlan(originalPlan, targetDate); + } + + //해당 날짜에 반복이 되는지 확인 + private boolean shouldRepeatOnDate(StudyPlan originalPlan, LocalDate targetDate) { + RepeatRule repeatRule = originalPlan.getRepeatRule(); + LocalDate startDate = originalPlan.getStartDate().toLocalDate(); + + switch (repeatRule.getFrequency()) { + case DAILY: + long daysBetween = ChronoUnit.DAYS.between(startDate, targetDate); + return daysBetween % repeatRule.getRepeatInterval() == 0; + + case WEEKLY: + if (repeatRule.getByDay() != null && !repeatRule.getByDay().isEmpty()) { + String targetDayOfWeek = targetDate.getDayOfWeek().name().substring(0, 3); + if (!repeatRule.getByDay().contains(targetDayOfWeek)) { + return false; + } + } + long weeksBetween = ChronoUnit.WEEKS.between(startDate, targetDate); + return weeksBetween % repeatRule.getRepeatInterval() == 0; + + case MONTHLY: + long monthsBetween = ChronoUnit.MONTHS.between(startDate, targetDate); + return monthsBetween % repeatRule.getRepeatInterval() == 0 && + startDate.getDayOfMonth() == targetDate.getDayOfMonth(); + + default: + return false; + } + } + + //타켓 날짜에 적용될 예외 정보 가져오기 + private StudyPlanException getEffectiveException(Long planId, LocalDate targetDate) { + // 해당 날짜에 직접적인 예외가 있는지 확인 + Optional directException = studyPlanExceptionRepository + .findByPlanIdAndDate(planId, targetDate.atStartOfDay()); + if (directException.isPresent()) { + return directException.get(); + } + + // FROM_THIS_DATE 범위의 예외가 있는지 확인 + List scopeExceptions = studyPlanExceptionRepository + .findByStudyPlanIdAndApplyScopeAndExceptionDateBefore( + planId, + StudyPlanException.ApplyScope.FROM_THIS_DATE, + targetDate.atStartOfDay() + ); - // untilDate 문자열을 LocalDateTime으로 변환 - if (repeatRuleRequest.getUntilDate() != null && !repeatRuleRequest.getUntilDate().isEmpty()) { + return scopeExceptions.stream() + .max(Comparator.comparing(StudyPlanException::getExceptionDate)) + .orElse(null); + } + + private StudyPlanResponse createModifiedVirtualPlan(StudyPlan originalPlan, StudyPlanException exception, LocalDate targetDate) { + StudyPlanResponse response = createBasicVirtualPlan(originalPlan, targetDate); + + // 수정된 내용 적용 + if (exception.getModifiedSubject() != null) { + response.setSubject(exception.getModifiedSubject()); + } + if (exception.getModifiedStartDate() != null) { + response.setStartDate(adjustTimeForDate(exception.getModifiedStartDate(), targetDate)); + } + if (exception.getModifiedEndDate() != null) { + response.setEndDate(adjustTimeForDate(exception.getModifiedEndDate(), targetDate)); + } + if (exception.getModifiedColor() != null) { + response.setColor(exception.getModifiedColor()); + } + + // 반복 규칙 수정 적용 + if (exception.getModifiedRepeatRule() != null) { + RepeatRuleEmbeddable modifiedRule = exception.getModifiedRepeatRule(); + StudyPlanResponse.RepeatRuleResponse newRepeatRule = new StudyPlanResponse.RepeatRuleResponse(); + newRepeatRule.setFrequency(modifiedRule.getFrequency()); + newRepeatRule.setRepeatInterval(modifiedRule.getIntervalValue()); + newRepeatRule.setByDay(modifiedRule.getByDay()); + newRepeatRule.setUntilDate(modifiedRule.getUntilDate()); + + response.setRepeatRule(newRepeatRule); + } + + return response; + } + + private StudyPlanResponse createBasicVirtualPlan(StudyPlan originalPlan, LocalDate targetDate) { + StudyPlanResponse response = new StudyPlanResponse(); + response.setId(originalPlan.getId()); + response.setSubject(originalPlan.getSubject()); + response.setColor(originalPlan.getColor()); + + // 시간은 유지하되 날짜만 변경 + response.setStartDate(adjustTimeForDate(originalPlan.getStartDate(), targetDate)); + response.setEndDate(adjustTimeForDate(originalPlan.getEndDate(), targetDate)); + + if (originalPlan.getRepeatRule() != null) { + response.setRepeatRule(new StudyPlanResponse.RepeatRuleResponse(originalPlan.getRepeatRule())); + } + + return response; + } + //시간은 유지, 날짜만 변경하는 메서드 + private LocalDateTime adjustTimeForDate(LocalDateTime originalDateTime, LocalDate targetDate) { + return LocalDateTime.of(targetDate, originalDateTime.toLocalTime()); + } + + // ==================== 수정 =================== + private enum UpdateType { + ORIGINAL_PLAN_UPDATE, // 원본 계획 수정 + REPEAT_INSTANCE_CREATE, // 새로운 예외 생성 + REPEAT_INSTANCE_UPDATE // 기존 예외 수정 + } + @Transactional + public StudyPlanResponse updateStudyPlan(Long userId, Long planId, StudyPlanRequest request, StudyPlanException.ApplyScope applyScope) { + StudyPlan originalPlan = studyPlanRepository.findById(planId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND)); + + validateUserAccess(originalPlan, userId); + + // 1. 단발성 계획인 경우 applyScope 무시하고 원본 수정 + if (originalPlan.getRepeatRule() == null) { + return updateOriginalPlan(originalPlan, request); + } + + // 2. 반복 계획인 경우 - 원본 계획과 요청 데이터 비교하여 수정 타입 판단 + UpdateType updateType = determineUpdateType(originalPlan, request); + + switch (updateType) { + case ORIGINAL_PLAN_UPDATE: + return updateOriginalPlan(originalPlan, request); + + case REPEAT_INSTANCE_CREATE: + return createRepeatException(originalPlan, request, applyScope); + + case REPEAT_INSTANCE_UPDATE: + return updateExistingException(originalPlan, request, applyScope); + + default: + throw new CustomException(ErrorCode.BAD_REQUEST); + } + } + + // 원본과 요청(가상)을 비교 + private UpdateType determineUpdateType(StudyPlan originalPlan, StudyPlanRequest request) { + LocalDate requestDate = request.getStartDate().toLocalDate(); + LocalDate originalDate = originalPlan.getStartDate().toLocalDate(); + + // 1-1. 반복 계획에서 요청 날짜가 원본 날짜와 같음 -> 원본이므로 원본 수정 + if (requestDate.equals(originalDate)) { + return UpdateType.ORIGINAL_PLAN_UPDATE; + } + + // 1-2. 반복 계획에서 다른 날짜인 경우 -> 기존 예외 확인 + Optional existingException = studyPlanExceptionRepository + .findByPlanIdAndDate(originalPlan.getId(), requestDate.atStartOfDay()); + + if (existingException.isPresent()) { + return UpdateType.REPEAT_INSTANCE_UPDATE; // 기존 예외 수정 + } else { + return UpdateType.REPEAT_INSTANCE_CREATE; // 새 예외 생성 + } +} + + // 원본 계획 수정 + private StudyPlanResponse updateOriginalPlan(StudyPlan originalPlan, StudyPlanRequest request) { + // 원본 계획 직접 수정 + if (request.getSubject() != null) originalPlan.setSubject(request.getSubject()); + if (request.getStartDate() != null) originalPlan.setStartDate(request.getStartDate()); + if (request.getEndDate() != null) originalPlan.setEndDate(request.getEndDate()); + if (request.getColor() != null) originalPlan.setColor(request.getColor()); + + // 반복 규칙 수정 + if (request.getRepeatRule() != null && originalPlan.getRepeatRule() != null) { + updateRepeatRule(originalPlan.getRepeatRule(), request.getRepeatRule()); + } + + StudyPlan savedPlan = studyPlanRepository.save(originalPlan); + return new StudyPlanResponse(savedPlan); + } + + // 새로운 예외 추가 + private StudyPlanResponse createRepeatException(StudyPlan originalPlan, StudyPlanRequest request, StudyPlanException.ApplyScope applyScope) { + LocalDate exceptionDate = request.getStartDate().toLocalDate(); + + // 해당 날짜에 실제로 반복 계획이 있는지 확인 + if (!shouldRepeatOnDate(originalPlan, exceptionDate)) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + + StudyPlanException exception = new StudyPlanException(); + exception.setStudyPlan(originalPlan); + exception.setExceptionDate(exceptionDate.atStartOfDay()); + exception.setExceptionType(StudyPlanException.ExceptionType.MODIFIED); + exception.setApplyScope(applyScope); // 파라미터로 받은 applyScope + + // 수정된 내용 설정 + if (request.getSubject() != null) exception.setModifiedSubject(request.getSubject()); + if (request.getStartDate() != null) exception.setModifiedStartDate(request.getStartDate()); + if (request.getEndDate() != null) exception.setModifiedEndDate(request.getEndDate()); + if (request.getColor() != null) exception.setModifiedColor(request.getColor()); + + // 반복 규칙 수정. 요청에 반복 규칙이 있으면 설정 + if (request.getRepeatRule() != null) { + RepeatRuleEmbeddable embeddable = new RepeatRuleEmbeddable(); + embeddable.setFrequency(request.getRepeatRule().getFrequency()); + embeddable.setIntervalValue(request.getRepeatRule().getIntervalValue()); + embeddable.setByDay(request.getRepeatRule().getByDay()); + + if (request.getRepeatRule().getUntilDate() != null && !request.getRepeatRule().getUntilDate().isEmpty()) { try { - LocalDateTime untilDateTime = LocalDateTime.parse(repeatRuleRequest.getUntilDate() + "T23:59:59"); - repeatRule.setUntilDate(untilDateTime); + LocalDate untilDate = LocalDate.parse(request.getRepeatRule().getUntilDate()); + embeddable.setUntilDate(untilDate); } catch (Exception e) { - // 날짜 파싱 실패 시 무시하거나 예외 처리 + throw new CustomException(ErrorCode.BAD_REQUEST); } } - studyPlan.setRepeatRule(repeatRule); + exception.setModifiedRepeatRule(embeddable); + } + + + studyPlanExceptionRepository.save(exception); + return createVirtualPlanForDate(originalPlan, exceptionDate); + } + + // 기존 예외 수정 + private StudyPlanResponse updateExistingException(StudyPlan originalPlan, StudyPlanRequest request, StudyPlanException.ApplyScope applyScope) { + LocalDate exceptionDate = request.getStartDate().toLocalDate(); + + StudyPlanException existingException = studyPlanExceptionRepository + .findByPlanIdAndDate(originalPlan.getId(), exceptionDate.atStartOfDay()) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND)); + + // 기존 예외 정보 업데이트 + if (request.getSubject() != null) existingException.setModifiedSubject(request.getSubject()); + if (request.getStartDate() != null) existingException.setModifiedStartDate(request.getStartDate()); + if (request.getEndDate() != null) existingException.setModifiedEndDate(request.getEndDate()); + if (request.getColor() != null) existingException.setModifiedColor(request.getColor()); + + // ApplyScope도 업데이트 (사용자가 범위를 변경할 수 있음) + existingException.setApplyScope(applyScope); + + // 반복 규칙 수정사항 있으면 예외 안에 추가 (embeddable) + if (request.getRepeatRule() != null) { + RepeatRuleEmbeddable embeddable = new RepeatRuleEmbeddable(); + embeddable.setFrequency(request.getRepeatRule().getFrequency()); + embeddable.setIntervalValue(request.getRepeatRule().getIntervalValue()); + embeddable.setByDay(request.getRepeatRule().getByDay()); + + if (request.getRepeatRule().getUntilDate() != null && !request.getRepeatRule().getUntilDate().isEmpty()) { + try { + LocalDate untilDate = LocalDate.parse(request.getRepeatRule().getUntilDate()); + embeddable.setUntilDate(untilDate); + } catch (Exception e) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + } + + existingException.setModifiedRepeatRule(embeddable); + } + + studyPlanExceptionRepository.save(existingException); + return createVirtualPlanForDate(originalPlan, exceptionDate); + } + + + // 원본의 반복 룰 수정 (엔티티) + private void updateRepeatRule(RepeatRule repeatRule, StudyPlanRequest.RepeatRuleRequest request) { + if (request.getFrequency() != null) repeatRule.setFrequency(request.getFrequency()); + if (request.getIntervalValue() != null) repeatRule.setRepeatInterval(request.getIntervalValue()); + if (request.getByDay() != null) repeatRule.setByDay(request.getByDay()); + + if (request.getUntilDate() != null && !request.getUntilDate().isEmpty()) { + try { + LocalDate untilDate = LocalDate.parse(request.getUntilDate()); + repeatRule.setUntilDate(untilDate); + } catch (Exception e) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } } + } - //추후 변수명이나 리턴 형식은 수정 예정 - StudyPlanResponse rs =new StudyPlanResponse(studyPlanRepository.save(studyPlan)); - return rs; + // ==================== 삭제 =================== + + + // ==================== 유틸 =================== + // 인가 (작성자 일치 확인) + private void validateUserAccess(StudyPlan studyPlan, Long userId) { + if (!studyPlan.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.FORBIDDEN); + } } + }