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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<RsData<StudyPlanResponse>> createStudyPlan(
// 로그인 유저 정보 받기 @AuthenticationPrincipal CustomUserDetails user,
@RequestBody StudyPlanRequest request) {
Expand All @@ -35,6 +42,10 @@ public ResponseEntity<RsData<StudyPlanResponse>> createStudyPlan(
// ==================== 조회 ===================
// 특정 날짜의 계획들 조회. date 형식: YYYY-MM-DD
@GetMapping("/date/{date}")
@Operation(
summary = "특정 날짜의 학습 계획 조회",
description = "지정 날짜에 해당하는 모든 학습 계획을 조회합니다."
)
public ResponseEntity<RsData<StudyPlanListResponse>> getStudyPlansForDate(
@AuthenticationPrincipal CustomUserDetails user,
@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
Expand All @@ -50,6 +61,10 @@ public ResponseEntity<RsData<StudyPlanListResponse>> getStudyPlansForDate(

// 기간별 계획 조회. start, end 형식: YYYY-MM-DD
@GetMapping
@Operation(
summary = "기간별 학습 계획 조회",
description = "기간에 해당하는 모든 학습 계획을 조회합니다."
)
public ResponseEntity<RsData<List<StudyPlanResponse>>> getStudyPlansForPeriod(
// @AuthenticationPrincipal CustomUserDetails user,
@RequestParam("start") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
Expand All @@ -69,11 +84,16 @@ public ResponseEntity<RsData<List<StudyPlanResponse>>> getStudyPlansForPeriod(
// 가상인지 원본인지는 서비스에서 원본과 날짜를 대조해 판단
// 수정 적용 범위를 쿼리 파라미터로 받음 (THIS_ONLY, FROM_THIS_DATE)
@PutMapping("/{planId}")
@Operation(
summary = "학습 계획 수정",
description = "기존 학습 계획을 수정합니다. 반복 계획의 경우 적용 범위를 applyScope로 설정 할 수 있으며" +
"클라이언트에서는 paln에 repeat_rule이 있으면 반복 계획으로 간주하고 반드시 apply_scope를 쿼리 파라미터로 넘겨야 합니다." +
"repeat_rule이 없으면 단발성 계획으로 간주하여 수정 범위를 설정 할 필요가 없으므로 apply_scope를 넘기지 않아도 됩니다.")
public ResponseEntity<RsData<StudyPlanResponse>> 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; // 임시

Expand All @@ -82,14 +102,25 @@ public ResponseEntity<RsData<StudyPlanResponse>> updateStudyPlan(
}



// ==================== 삭제 ===================
// 플랜 아이디는 원본의 아이디를 받음
// 가상인지 원본인지는 서비스에서 원본과 날짜를 대조해 판단
// 삭제 적용 범위를 쿼리 파라미터로 받음 (THIS_ONLY, FROM_THIS_DATE)
@DeleteMapping("/{planId}")
public ResponseEntity<RsData<Void>> 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<RsData<Void>> 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("학습 계획이 성공적으로 삭제되었습니다."));
}

}
Original file line number Diff line number Diff line change
@@ -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 // 이 날짜부터 이후 모든 날짜
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.back.domain.study.plan.entity;

public enum ApplyScope {
THIS_ONLY, // 이 날짜만
FROM_THIS_DATE // 이 날짜부터 이후 모든 날짜
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public class StudyPlanException extends BaseEntity {

// 예외가 발생한 날짜
@Column(name = "exception_date", nullable = false)
private LocalDateTime exceptionDate;
private LocalDate exceptionDate;

//예외 유형 (수정 / 삭제)
@Enumerated(EnumType.STRING)
Expand Down Expand Up @@ -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")),
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -19,7 +21,7 @@ public interface StudyPlanExceptionRepository extends JpaRepository<StudyPlanExc
"ORDER BY spe.exceptionDate DESC")
List<StudyPlanException> 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 " +
Expand All @@ -32,5 +34,5 @@ List<StudyPlanException> findByStudyPlanIdAndExceptionDateBetween(@Param("planId
@Query("SELECT spe FROM StudyPlanException spe WHERE spe.studyPlan.id = :planId " +
"AND DATE(spe.exceptionDate) = DATE(:targetDate)")
Optional<StudyPlanException> findByPlanIdAndDate(@Param("planId") Long planId,
@Param("targetDate") LocalDateTime targetDate);
@Param("targetDate") LocalDate targetDate);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -201,7 +199,7 @@ private boolean shouldRepeatOnDate(StudyPlan originalPlan, LocalDate targetDate)
private StudyPlanException getEffectiveException(Long planId, LocalDate targetDate) {
// 해당 날짜에 직접적인 예외가 있는지 확인
Optional<StudyPlanException> directException = studyPlanExceptionRepository
.findByPlanIdAndDate(planId, targetDate.atStartOfDay());
.findByPlanIdAndDate(planId, targetDate);
if (directException.isPresent()) {
return directException.get();
}
Expand All @@ -210,7 +208,7 @@ private StudyPlanException getEffectiveException(Long planId, LocalDate targetDa
List<StudyPlanException> scopeExceptions = studyPlanExceptionRepository
.findByStudyPlanIdAndApplyScopeAndExceptionDateBefore(
planId,
StudyPlanException.ApplyScope.FROM_THIS_DATE,
ApplyScope.FROM_THIS_DATE,
targetDate.atStartOfDay()
);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -318,9 +316,9 @@ private UpdateType determineUpdateType(StudyPlan originalPlan, StudyPlanRequest
return UpdateType.ORIGINAL_PLAN_UPDATE;
}

// 1-2. 반복 계획에서 다른 날짜인 경우 -> 기존 예외 확인
// 1-2. 반복 계획에서 다른 날짜인 경우 -> 기존 예외 존재 유무 확인
Optional<StudyPlanException> existingException = studyPlanExceptionRepository
.findByPlanIdAndDate(originalPlan.getId(), requestDate.atStartOfDay());
.findByPlanIdAndDate(originalPlan.getId(), requestDate);

if (existingException.isPresent()) {
return UpdateType.REPEAT_INSTANCE_UPDATE; // 기존 예외 수정
Expand All @@ -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

Expand All @@ -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);
}
}

Expand All @@ -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());
Expand All @@ -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);
}
}

Expand All @@ -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;
}
}

// ==================== 유틸 ===================
// 인가 (작성자 일치 확인)
Expand All @@ -460,6 +500,4 @@ private void validateUserAccess(StudyPlan studyPlan, Long userId) {
}




}
7 changes: 7 additions & 0 deletions src/main/java/com/back/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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", "자신의 메시지만 삭제할 수 있습니다."),
Expand Down
Loading