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 638d9e56..f8e7543f 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 @@ -3,10 +3,12 @@ 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.entity.ApplyScope; import com.back.domain.study.plan.service.StudyPlanService; import com.back.global.common.dto.RsData; import com.back.global.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; @@ -19,10 +21,15 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/plans") +@Tag(name = "StudyPlan", description = "학습 계획 관련 API") public class StudyPlanController { private final StudyPlanService studyPlanService; // ==================== 생성 =================== @PostMapping + @Operation( summary = "학습 계획 생성", + description = "새로운 학습 계획을 생성합니다. 반복 계획 생성 시 최초 계획이 원본 계획으로서 db에 저장되고" + + " 이후 반복되는 계획들은 가상 계획으로서 db에는 없지만 조회 시 가상으로 생성됩니다") + public ResponseEntity> createStudyPlan( // 로그인 유저 정보 받기 @AuthenticationPrincipal CustomUserDetails user, @RequestBody StudyPlanRequest request) { @@ -35,6 +42,10 @@ public ResponseEntity> createStudyPlan( // ==================== 조회 =================== // 특정 날짜의 계획들 조회. date 형식: YYYY-MM-DD @GetMapping("/date/{date}") + @Operation( + summary = "특정 날짜의 학습 계획 조회", + description = "지정 날짜에 해당하는 모든 학습 계획을 조회합니다." + ) public ResponseEntity> getStudyPlansForDate( @AuthenticationPrincipal CustomUserDetails user, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { @@ -50,6 +61,10 @@ public ResponseEntity> getStudyPlansForDate( // 기간별 계획 조회. start, end 형식: YYYY-MM-DD @GetMapping + @Operation( + summary = "기간별 학습 계획 조회", + description = "기간에 해당하는 모든 학습 계획을 조회합니다." + ) public ResponseEntity>> getStudyPlansForPeriod( // @AuthenticationPrincipal CustomUserDetails user, @RequestParam("start") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, @@ -69,11 +84,16 @@ public ResponseEntity>> getStudyPlansForPeriod( // 가상인지 원본인지는 서비스에서 원본과 날짜를 대조해 판단 // 수정 적용 범위를 쿼리 파라미터로 받음 (THIS_ONLY, FROM_THIS_DATE) @PutMapping("/{planId}") + @Operation( + summary = "학습 계획 수정", + description = "기존 학습 계획을 수정합니다. 반복 계획의 경우 적용 범위를 applyScope로 설정 할 수 있으며" + + "클라이언트에서는 paln에 repeat_rule이 있으면 반복 계획으로 간주하고 반드시 apply_scope를 쿼리 파라미터로 넘겨야 합니다." + + "repeat_rule이 없으면 단발성 계획으로 간주하여 수정 범위를 설정 할 필요가 없으므로 apply_scope를 넘기지 않아도 됩니다.") public ResponseEntity> updateStudyPlan( // @AuthenticationPrincipal CustomUserDetails user, @PathVariable Long planId, @RequestBody StudyPlanRequest request, - @RequestParam(required = false, defaultValue = "THIS_ONLY") StudyPlanException.ApplyScope applyScope) { + @RequestParam(required = false, defaultValue = "THIS_ONLY") ApplyScope applyScope) { // Long userId = user.getId(); Long userId = 1L; // 임시 @@ -82,14 +102,25 @@ public ResponseEntity> updateStudyPlan( } - // ==================== 삭제 =================== + // 플랜 아이디는 원본의 아이디를 받음 + // 가상인지 원본인지는 서비스에서 원본과 날짜를 대조해 판단 + // 삭제 적용 범위를 쿼리 파라미터로 받음 (THIS_ONLY, FROM_THIS_DATE) @DeleteMapping("/{planId}") - public ResponseEntity> deleteStudyPlan(@PathVariable Long planId) { - //studyPlanService.deleteStudyPlan(planId); - return ResponseEntity.ok(RsData.success("학습 계획이 성공적으로 삭제되었습니다.", null)); - } - + @Operation( + summary = "학습 계획 삭제", + description = "기존 학습 계획을 삭제합니다. 반복 계획의 경우 적용 범위를 applyScope로 설정 할 수 있으며" + + "클라이언트에서는 paln에 repeat_rule이 있으면 반복 계획으로 간주하고 반드시 apply_scope를 쿼리 파라미터로 넘겨야 합니다." + + "repeat_rule이 없으면 단발성 계획으로 간주하여 삭제 범위를 설정 할 필요가 없으므로 apply_scope를 넘기지 않아도 됩니다.") + public ResponseEntity> deleteStudyPlan( + // @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long planId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate selectedDate, + @RequestParam(required = false) ApplyScope applyScope) { + Long userId = 1L; // 임시 + studyPlanService.deleteStudyPlan(userId, planId, selectedDate, applyScope); + return ResponseEntity.ok(RsData.success("학습 계획이 성공적으로 삭제되었습니다.")); + } } diff --git a/src/main/java/com/back/domain/study/plan/dto/StudyPlanDeleteRequest.java b/src/main/java/com/back/domain/study/plan/dto/StudyPlanDeleteRequest.java new file mode 100644 index 00000000..6b9b8624 --- /dev/null +++ b/src/main/java/com/back/domain/study/plan/dto/StudyPlanDeleteRequest.java @@ -0,0 +1,19 @@ +package com.back.domain.study.plan.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class StudyPlanDeleteRequest { + private DeleteScope deleteScope; + + public enum DeleteScope { + THIS_ONLY, // 이 날짜만 + FROM_THIS_DATE // 이 날짜부터 이후 모든 날짜 + } +} \ No newline at end of file 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 8f4e1cf2..5438b6a7 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 @@ -44,7 +44,7 @@ public static class RepeatRuleResponse { private Integer repeatInterval; private String byDay; // "MON" 형태의 문자열 - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") private LocalDate untilDate; public RepeatRuleResponse(com.back.domain.study.plan.entity.RepeatRule repeatRule) { diff --git a/src/main/java/com/back/domain/study/plan/entity/ApplyScope.java b/src/main/java/com/back/domain/study/plan/entity/ApplyScope.java new file mode 100644 index 00000000..9b1a90aa --- /dev/null +++ b/src/main/java/com/back/domain/study/plan/entity/ApplyScope.java @@ -0,0 +1,6 @@ +package com.back.domain.study.plan.entity; + +public enum ApplyScope { + THIS_ONLY, // 이 날짜만 + FROM_THIS_DATE // 이 날짜부터 이후 모든 날짜 +} 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 fe1d7268..2acca212 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 @@ -23,7 +23,7 @@ public class RepeatRule extends BaseEntity { private Frequency frequency; @Column(name = "interval_value", nullable = false) - private int RepeatInterval; + private int repeatInterval; //요일은 계획 날짜에 따라 자동 설정 @Column(name = "by_day") 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 d150cd5c..2137a6ae 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 @@ -26,7 +26,7 @@ public class StudyPlanException extends BaseEntity { // 예외가 발생한 날짜 @Column(name = "exception_date", nullable = false) - private LocalDateTime exceptionDate; + private LocalDate exceptionDate; //예외 유형 (수정 / 삭제) @Enumerated(EnumType.STRING) @@ -57,11 +57,6 @@ public enum ExceptionType { MODIFIED // 해당 날짜 수정 } - public enum ApplyScope { - THIS_ONLY, // 이 날짜만 - FROM_THIS_DATE // 이 날짜부터 이후 모든 날짜 - } - @Embedded @AttributeOverrides({ @AttributeOverride(name = "frequency", column = @Column(name = "modified_frequency")), 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 index 1a634b79..71fba8d7 100644 --- a/src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java +++ b/src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java @@ -1,11 +1,13 @@ package com.back.domain.study.plan.repository; +import com.back.domain.study.plan.entity.ApplyScope; 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.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -19,7 +21,7 @@ public interface StudyPlanExceptionRepository extends JpaRepository findByStudyPlanIdAndApplyScopeAndExceptionDateBefore( @Param("planId") Long planId, - @Param("applyScope") StudyPlanException.ApplyScope applyScope, + @Param("applyScope") ApplyScope applyScope, @Param("targetDate") LocalDateTime targetDate); // 특정 계획의 특정 기간 동안(start~end)의 예외를 조회 @Query("SELECT spe FROM StudyPlanException spe WHERE spe.studyPlan.id = :planId " + @@ -32,5 +34,5 @@ List findByStudyPlanIdAndExceptionDateBetween(@Param("planId @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); + @Param("targetDate") LocalDate targetDate); } 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 418df4a3..084cb33b 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,11 +1,9 @@ package com.back.domain.study.plan.service; +import com.back.domain.study.plan.dto.StudyPlanDeleteRequest; 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.entity.*; import com.back.domain.study.plan.repository.StudyPlanExceptionRepository; import com.back.domain.study.plan.repository.StudyPlanRepository; import com.back.global.exception.CustomException; @@ -201,7 +199,7 @@ private boolean shouldRepeatOnDate(StudyPlan originalPlan, LocalDate targetDate) private StudyPlanException getEffectiveException(Long planId, LocalDate targetDate) { // 해당 날짜에 직접적인 예외가 있는지 확인 Optional directException = studyPlanExceptionRepository - .findByPlanIdAndDate(planId, targetDate.atStartOfDay()); + .findByPlanIdAndDate(planId, targetDate); if (directException.isPresent()) { return directException.get(); } @@ -210,7 +208,7 @@ private StudyPlanException getEffectiveException(Long planId, LocalDate targetDa List scopeExceptions = studyPlanExceptionRepository .findByStudyPlanIdAndApplyScopeAndExceptionDateBefore( planId, - StudyPlanException.ApplyScope.FROM_THIS_DATE, + ApplyScope.FROM_THIS_DATE, targetDate.atStartOfDay() ); @@ -279,9 +277,9 @@ private enum UpdateType { REPEAT_INSTANCE_UPDATE // 기존 예외 수정 } @Transactional - public StudyPlanResponse updateStudyPlan(Long userId, Long planId, StudyPlanRequest request, StudyPlanException.ApplyScope applyScope) { + public StudyPlanResponse updateStudyPlan(Long userId, Long planId, StudyPlanRequest request, ApplyScope applyScope) { StudyPlan originalPlan = studyPlanRepository.findById(planId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND)); + .orElseThrow(() -> new CustomException(ErrorCode.PLAN_NOT_FOUND)); validateUserAccess(originalPlan, userId); @@ -318,9 +316,9 @@ private UpdateType determineUpdateType(StudyPlan originalPlan, StudyPlanRequest return UpdateType.ORIGINAL_PLAN_UPDATE; } - // 1-2. 반복 계획에서 다른 날짜인 경우 -> 기존 예외 확인 + // 1-2. 반복 계획에서 다른 날짜인 경우 -> 기존 예외 존재 유무 확인 Optional existingException = studyPlanExceptionRepository - .findByPlanIdAndDate(originalPlan.getId(), requestDate.atStartOfDay()); + .findByPlanIdAndDate(originalPlan.getId(), requestDate); if (existingException.isPresent()) { return UpdateType.REPEAT_INSTANCE_UPDATE; // 기존 예외 수정 @@ -347,17 +345,17 @@ private StudyPlanResponse updateOriginalPlan(StudyPlan originalPlan, StudyPlanRe } // 새로운 예외 추가 - private StudyPlanResponse createRepeatException(StudyPlan originalPlan, StudyPlanRequest request, StudyPlanException.ApplyScope applyScope) { + private StudyPlanResponse createRepeatException(StudyPlan originalPlan, StudyPlanRequest request, ApplyScope applyScope) { LocalDate exceptionDate = request.getStartDate().toLocalDate(); // 해당 날짜에 실제로 반복 계획이 있는지 확인 if (!shouldRepeatOnDate(originalPlan, exceptionDate)) { - throw new CustomException(ErrorCode.BAD_REQUEST); + throw new CustomException(ErrorCode.PLAN_ORIGINAL_REPEAT_NOT_FOUND); } StudyPlanException exception = new StudyPlanException(); exception.setStudyPlan(originalPlan); - exception.setExceptionDate(exceptionDate.atStartOfDay()); + exception.setExceptionDate(exceptionDate); exception.setExceptionType(StudyPlanException.ExceptionType.MODIFIED); exception.setApplyScope(applyScope); // 파라미터로 받은 applyScope @@ -379,7 +377,7 @@ private StudyPlanResponse createRepeatException(StudyPlan originalPlan, StudyPla LocalDate untilDate = LocalDate.parse(request.getRepeatRule().getUntilDate()); embeddable.setUntilDate(untilDate); } catch (Exception e) { - throw new CustomException(ErrorCode.BAD_REQUEST); + throw new CustomException(ErrorCode.PLAN_INVALID_DATE_FORMAT); } } @@ -392,12 +390,12 @@ private StudyPlanResponse createRepeatException(StudyPlan originalPlan, StudyPla } // 기존 예외 수정 - private StudyPlanResponse updateExistingException(StudyPlan originalPlan, StudyPlanRequest request, StudyPlanException.ApplyScope applyScope) { + private StudyPlanResponse updateExistingException(StudyPlan originalPlan, StudyPlanRequest request, ApplyScope applyScope) { LocalDate exceptionDate = request.getStartDate().toLocalDate(); StudyPlanException existingException = studyPlanExceptionRepository - .findByPlanIdAndDate(originalPlan.getId(), exceptionDate.atStartOfDay()) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND)); + .findByPlanIdAndDate(originalPlan.getId(), exceptionDate) + .orElse(null); // 기존 예외 정보 업데이트 if (request.getSubject() != null) existingException.setModifiedSubject(request.getSubject()); @@ -420,7 +418,7 @@ private StudyPlanResponse updateExistingException(StudyPlan originalPlan, StudyP LocalDate untilDate = LocalDate.parse(request.getRepeatRule().getUntilDate()); embeddable.setUntilDate(untilDate); } catch (Exception e) { - throw new CustomException(ErrorCode.BAD_REQUEST); + throw new CustomException(ErrorCode.PLAN_INVALID_DATE_FORMAT); } } @@ -443,13 +441,55 @@ private void updateRepeatRule(RepeatRule repeatRule, StudyPlanRequest.RepeatRule LocalDate untilDate = LocalDate.parse(request.getUntilDate()); repeatRule.setUntilDate(untilDate); } catch (Exception e) { - throw new CustomException(ErrorCode.BAD_REQUEST); + throw new CustomException(ErrorCode.PLAN_INVALID_DATE_FORMAT); } } } // ==================== 삭제 =================== + @Transactional + public void deleteStudyPlan(Long userId, Long planId, LocalDate selectedDate, ApplyScope applyScope) { + StudyPlan studyPlan = studyPlanRepository.findById(planId) + .orElseThrow(() -> new CustomException(ErrorCode.PLAN_NOT_FOUND)); + + validateUserAccess(studyPlan, userId); + + // 단발성 계획 삭제 (반복 룰이 null이거나 applyScope가 null인 경우) + if (studyPlan.getRepeatRule() == null || applyScope == null ) { + studyPlanRepository.delete(studyPlan); + return; + } + + // 반복성 계획 삭제 - applyScope에 따른 처리 + deleteRepeatPlan(studyPlan, selectedDate, applyScope); + } + private void deleteRepeatPlan(StudyPlan studyPlan, LocalDate selectedDate, ApplyScope applyScope) { + switch (applyScope) { + case FROM_THIS_DATE: + // 원본 날짜부터 삭제하는 경우 = 전체 계획 삭제 + if (selectedDate.equals(studyPlan.getStartDate().toLocalDate())) { + studyPlanRepository.delete(studyPlan); // CASCADE로 RepeatRule, Exception 모두 삭제 + } else { + // 중간 날짜부터 삭제하는 경우 = untilDate 수정 + RepeatRule repeatRule = studyPlan.getRepeatRule(); + LocalDate newUntilDate = selectedDate.minusDays(1); + repeatRule.setUntilDate(newUntilDate); + studyPlanRepository.save(studyPlan); + } + break; + + case THIS_ONLY: + // 선택한 날짜만 삭제 - 예외 생성 + StudyPlanException exception = new StudyPlanException(); + exception.setStudyPlan(studyPlan); + exception.setExceptionDate(selectedDate); + exception.setExceptionType(StudyPlanException.ExceptionType.DELETED); + exception.setApplyScope(ApplyScope.THIS_ONLY); + studyPlanExceptionRepository.save(exception); + break; + } + } // ==================== 유틸 =================== // 인가 (작성자 일치 확인) @@ -460,6 +500,4 @@ private void validateUserAccess(StudyPlan studyPlan, Long userId) { } - - } diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 466b22c8..64d70f37 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -32,6 +32,13 @@ public enum ErrorCode { CANNOT_KICK_HOST(HttpStatus.BAD_REQUEST, "ROOM_010", "방장은 추방할 수 없습니다."), CANNOT_CHANGE_HOST_ROLE(HttpStatus.BAD_REQUEST, "ROOM_011", "방장의 권한은 변경할 수 없습니다."), + // ======================== 스터디 플래너 관련 ======================== + PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_001", "존재하지 않는 학습 계획입니다."), + PLAN_FORBIDDEN(HttpStatus.FORBIDDEN, "PLAN_002", "학습 계획에 대한 접근 권한이 없습니다."), + PLAN_EXCEPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_003", "학습 계획의 예외가 존재하지 않습니다."), + PLAN_ORIGINAL_REPEAT_NOT_FOUND(HttpStatus.BAD_REQUEST, "PLAN_004", "해당 날짜에 원본 반복 계획을 찾을 수 없습니다."), + PLAN_INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "PLAN_005", "날짜 형식이 올바르지 않습니다. (YYYY-MM-DD 형식을 사용해주세요)"), + // ======================== 메시지 관련 ======================== MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "MESSAGE_001", "존재하지 않는 메시지입니다."), MESSAGE_FORBIDDEN(HttpStatus.FORBIDDEN, "MESSAGE_002", "자신의 메시지만 삭제할 수 있습니다."),