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 9c04caa9..00979e7a 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 @@ -2,11 +2,32 @@ import com.back.domain.study.plan.entity.StudyPlan; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.List; @Repository public interface StudyPlanRepository extends JpaRepository { List findByUserId(Long userId); + /* 시간 겹침 조건: + 새 계획 시작 시간보다 기존 계획 종료 시간이 늦고 (p.endDate > :newStart), + 새 계획 종료 시간보다 기존 계획 시작 시간이 빨라야 한다 (p.startDate < :newEnd). + (종료 시간 == 새 시작 시간)은 허용 + */ + @Query(""" + SELECT p + FROM StudyPlan p + WHERE p.user.id = :userId + AND (:planIdToExclude IS NULL OR p.id != :planIdToExclude) + AND p.endDate > :newStart + AND p.startDate < :newEnd +""") + List findByUserIdAndNotIdAndOverlapsTime( + Long userId, + Long planIdToExclude, + LocalDateTime newStart, + LocalDateTime newEnd + ); } 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 b53966b2..3493c4d4 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 @@ -16,6 +16,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.ArrayList; @@ -42,6 +43,8 @@ public StudyPlanResponse createStudyPlan(Long userId, StudyPlanRequest request) // 날짜/시간 검증 validateDateTime(request.getStartDate(), request.getEndDate()); + // 시간 겹침 검증 + validateTimeConflict(userId, null, request.getStartDate(), request.getEndDate()); StudyPlan studyPlan = new StudyPlan(); @@ -311,6 +314,10 @@ public StudyPlanResponse updateStudyPlan(Long userId, Long planId, StudyPlanRequ .orElseThrow(() -> new CustomException(ErrorCode.PLAN_NOT_FOUND)); validateUserAccess(originalPlan, userId); + // 날짜/시간 검증 + validateDateTime(request.getStartDate(), request.getEndDate()); + // 시간 겹침 검증 (원본 계획 ID 제외) + validateTimeConflict(userId, originalPlan.getId(), request.getStartDate(), request.getEndDate()); // 1. 단발성 계획인 경우 if (originalPlan.getRepeatRule() == null) { @@ -548,7 +555,7 @@ private void validateUserAccess(StudyPlan studyPlan, Long userId) { throw new CustomException(ErrorCode.FORBIDDEN); } } - + // 시작, 종료 날짜 검증 private void validateDateTime(LocalDateTime startDate, LocalDateTime endDate) { if (startDate == null || endDate == null) { throw new CustomException(ErrorCode.BAD_REQUEST); @@ -558,6 +565,49 @@ private void validateDateTime(LocalDateTime startDate, LocalDateTime endDate) { throw new CustomException(ErrorCode.PLAN_INVALID_TIME_RANGE); } } + //시간 겹침 검증 메서드 (최적화된 DB 쿼리 + 가상 인스턴스 검증 조합) + private void validateTimeConflict(Long userId, Long planIdToExclude, LocalDateTime newStart, LocalDateTime newEnd) { + LocalDate newPlanDate = newStart.toLocalDate(); + + // 1. DB 쿼리를 통해 요청 시간과 원본 시간대가 겹칠 가능성이 있는 계획들만 로드 (최적화) + // 기존 조회 코드를 이용하려 했으나 성능 문제로 인해 쿼리 작성. + // 조회기능도 리펙토링 예정 + List conflictingOriginalPlans = studyPlanRepository.findByUserIdAndNotIdAndOverlapsTime( + userId, planIdToExclude, newStart, newEnd + ); + + if (conflictingOriginalPlans.isEmpty()) { + return; + } + + for (StudyPlan plan : conflictingOriginalPlans) { + if (plan.getRepeatRule() == null) { + // 2-1. 단발성 계획 -> 쿼리에서 이미 시간 범위가 겹친다고 걸러졌지만 재확인 + if (isOverlapping(plan.getStartDate(), plan.getEndDate(), newStart, newEnd)) { + throw new CustomException(ErrorCode.PLAN_TIME_CONFLICT); + } + } else { + // 2-2. 반복 계획 -> 기존 메서드를 사용해 요청 날짜의 가상 인스턴스를 생성하고 검사 + StudyPlanResponse virtualPlan = createVirtualPlanForDate(plan, newPlanDate); + + if (virtualPlan != null) { + // 가상 인스턴스가 존재하고 + // 해당 인스턴스의 확정된 시간이 새 계획과 겹치는지 최종 확인 + if (isOverlapping(virtualPlan.getStartDate(), virtualPlan.getEndDate(), newStart, newEnd)) { + throw new CustomException(ErrorCode.PLAN_TIME_CONFLICT); + } + } + } + } + } + /* + * 두 시간 범위의 겹침을 확인하는 메서드 + * 겹치는 조건: (새로운 시작 시각 < 기존 종료 시각) && (새로운 종료 시각 > 기존 시작 시각) + * (기존 종료 시각 == 새로운 시작 시각)은 겹치지 않는 것으로 간주 + */ + private boolean isOverlapping(LocalDateTime existingStart, LocalDateTime existingEnd, LocalDateTime newStart, LocalDateTime newEnd) { + return newStart.isBefore(existingEnd) && newEnd.isAfter(existingStart); + } private void validateRepeatRuleDate(StudyPlan studyPlan, LocalDate untilDate) { LocalDate planStartDate = studyPlan.getStartDate().toLocalDate(); diff --git a/src/main/java/com/back/domain/study/todo/controller/TodoController.java b/src/main/java/com/back/domain/study/todo/controller/TodoController.java new file mode 100644 index 00000000..e0eea722 --- /dev/null +++ b/src/main/java/com/back/domain/study/todo/controller/TodoController.java @@ -0,0 +1,98 @@ +package com.back.domain.study.todo.controller; + +import com.back.domain.study.todo.dto.TodoRequestDto; +import com.back.domain.study.todo.dto.TodoResponseDto; +import com.back.domain.study.todo.service.TodoService; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CustomUserDetails; +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.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/todos") +@Tag(name = "Todo", description = "할 일 관련 API") +public class TodoController { + private final TodoService todoService; + + // ==================== 생성 =================== + @PostMapping + @Operation(summary = "할 일 생성", description = "새로운 할 일을 생성합니다.") + public ResponseEntity> createTodo( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody TodoRequestDto requestDto + ) { + TodoResponseDto response = todoService.createTodo(userDetails.getUserId(), requestDto); + return ResponseEntity.ok(RsData.success("할 일이 생성되었습니다.", response)); + } + + // ==================== 조회 =================== + // 특정 날짜 조회 + @GetMapping + @Operation(summary = "할 일 목록 조회", description = "조건에 따라 할 일 목록을 조회합니다. " + + "date만 제공시 해당 날짜, startDate와 endDate 제공시 기간별, 아무것도 없으면 전체 조회") + public ResponseEntity>> getTodos( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date + ) { + List response = todoService.getTodosByDate(userDetails.getUserId(), date); + + return ResponseEntity.ok(RsData.success("할 일 목록을 조회했습니다.", response)); + } + + // 사용자의 모든 할 일 조회 + @GetMapping("/all") + @Operation(summary = "모든 할 일 조회", description = "사용자의 모든 할 일을 조회합니다.") + public ResponseEntity>> getAllTodos( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + List response = todoService.getAllTodos(userDetails.getUserId()); + return ResponseEntity.ok(RsData.success("모든 할 일을 조회했습니다.", response)); + } + + // ==================== 수정 =================== + // 할 일 내용 수정 + @PutMapping("/{todoId}") + @Operation(summary = "할 일 수정", description = "할 일의 내용과 날짜를 수정합니다.") + public ResponseEntity> updateTodo( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId, + @Valid @RequestBody TodoRequestDto requestDto + ) { + TodoResponseDto response = todoService.updateTodo(userDetails.getUserId(), todoId, requestDto); + return ResponseEntity.ok(RsData.success("할 일이 수정되었습니다.", response)); + } + + // 할 일 완료/미완료 토글 + @PutMapping("/{todoId}/complete") + @Operation(summary = "할 일 완료 상태 토글", description = "할 일의 완료 상태를 변경합니다.") + public ResponseEntity> toggleTodoComplete( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId + ) { + TodoResponseDto response = todoService.toggleTodoComplete(userDetails.getUserId(), todoId); + return ResponseEntity.ok(RsData.success("할 일 상태가 변경되었습니다.", response)); + } + + // ==================== 삭제 =================== + // 할 일 삭제 + @DeleteMapping("/{todoId}") + @Operation(summary = "할 일 삭제", description = "할 일을 삭제합니다.") + public ResponseEntity> deleteTodo( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId + ) { + todoService.deleteTodo(userDetails.getUserId(), todoId); + return ResponseEntity.ok(RsData.success("할 일이 삭제되었습니다.")); + } + +} diff --git a/src/main/java/com/back/domain/study/todo/dto/TodoRequestDto.java b/src/main/java/com/back/domain/study/todo/dto/TodoRequestDto.java new file mode 100644 index 00000000..1995084b --- /dev/null +++ b/src/main/java/com/back/domain/study/todo/dto/TodoRequestDto.java @@ -0,0 +1,15 @@ +package com.back.domain.study.todo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record TodoRequestDto( + @NotBlank(message = "할 일 설명은 필수입니다.") + String description, + @NotNull(message = "날짜는 필수입니다.") + LocalDate date +) { +} diff --git a/src/main/java/com/back/domain/study/todo/dto/TodoResponseDto.java b/src/main/java/com/back/domain/study/todo/dto/TodoResponseDto.java new file mode 100644 index 00000000..53a4c655 --- /dev/null +++ b/src/main/java/com/back/domain/study/todo/dto/TodoResponseDto.java @@ -0,0 +1,23 @@ +package com.back.domain.study.todo.dto; + +import com.back.domain.study.todo.entity.Todo; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; + +public record TodoResponseDto( + Long id, + String description, + boolean isComplete, + LocalDate date +) { + // entity -> DTO + public static TodoResponseDto from(Todo todo) { + return new TodoResponseDto( + todo.getId(), + todo.getDescription(), + todo.isComplete(), + todo.getDate() + ); + } +} diff --git a/src/main/java/com/back/domain/study/todo/entity/Todo.java b/src/main/java/com/back/domain/study/todo/entity/Todo.java index 73c606a2..23d22e35 100644 --- a/src/main/java/com/back/domain/study/todo/entity/Todo.java +++ b/src/main/java/com/back/domain/study/todo/entity/Todo.java @@ -7,6 +7,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -14,7 +15,7 @@ @Getter @NoArgsConstructor public class Todo extends BaseEntity { - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) @JoinColumn(name = "user_id") private User user; @@ -22,6 +23,20 @@ public class Todo extends BaseEntity { private String description; - private LocalDateTime date; + private LocalDate date; + public Todo(User user, String description, LocalDate date) { + this.user = user; + this.description = description; + this.date = date; + this.isComplete = false; + } + + public void updateDescription(String description) { + this.description = description; + } + + public void toggleComplete() { + this.isComplete = !this.isComplete; + } } diff --git a/src/main/java/com/back/domain/study/todo/repository/TodoRepository.java b/src/main/java/com/back/domain/study/todo/repository/TodoRepository.java new file mode 100644 index 00000000..aede5ce4 --- /dev/null +++ b/src/main/java/com/back/domain/study/todo/repository/TodoRepository.java @@ -0,0 +1,17 @@ +package com.back.domain.study.todo.repository; + +import com.back.domain.study.todo.entity.Todo; +import com.back.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface TodoRepository extends JpaRepository { + List findByUserIdAndDate(Long userId, LocalDate date); + List findByUserId(Long userId); + Todo findByIdAndUser(Long id, User user); +} diff --git a/src/main/java/com/back/domain/study/todo/service/TodoService.java b/src/main/java/com/back/domain/study/todo/service/TodoService.java new file mode 100644 index 00000000..d2783757 --- /dev/null +++ b/src/main/java/com/back/domain/study/todo/service/TodoService.java @@ -0,0 +1,106 @@ +package com.back.domain.study.todo.service; + +import com.back.domain.study.todo.dto.TodoRequestDto; +import com.back.domain.study.todo.dto.TodoResponseDto; +import com.back.domain.study.todo.entity.Todo; +import com.back.domain.study.todo.repository.TodoRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +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.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TodoService { + private final TodoRepository todoRepository; + private final UserRepository userRepository; + + // ==================== 생성 =================== + @Transactional + public TodoResponseDto createTodo(Long userId, TodoRequestDto request) { + User user = findUserById(userId); + + Todo todo = new Todo( + user, + request.description(), + request.date() + ); + + Todo savedTodo = todoRepository.save(todo); + return TodoResponseDto.from(savedTodo); + } + + // ==================== 조회 =================== + //유저의 특정 날짜의 모든 할 일 조회 + public List getTodosByDate(Long userId, LocalDate date) { + List todos = todoRepository.findByUserIdAndDate(userId, date); + return todos.stream() + .map(TodoResponseDto::from) + .collect(Collectors.toList()); + } + + //유저의 전체 할 일 조회 + public List getAllTodos(Long userId) { + List todos = todoRepository.findByUserId(userId); + return todos.stream() + .map(TodoResponseDto::from) + .collect(Collectors.toList()); + } + + // ==================== 수정 =================== + // 할 일 내용 수정 + @Transactional + public TodoResponseDto updateTodo(Long userId, Long todoId, TodoRequestDto requestDto) { + User user = findUserById(userId); + Todo todo = findTodoByIdAndUser(todoId, user); + + todo.updateDescription(requestDto.description()); + + return TodoResponseDto.from(todo); + } + + // 할 일 완료 상태 토글 + @Transactional + public TodoResponseDto toggleTodoComplete(Long userId, Long todoId) { + User user = findUserById(userId); + Todo todo = findTodoByIdAndUser(todoId, user); + todo.toggleComplete(); + return TodoResponseDto.from(todo); + } + + // ==================== 삭제 =================== + // 할 일 삭제 + @Transactional + public void deleteTodo(Long userId, Long todoId) { + User user = findUserById(userId); + Todo todo = findTodoByIdAndUser(todoId, user); + todoRepository.delete(todo); + } + + // ==================== 유틸 =================== + // 유저 조회 + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + } + + // 할 일 조회 및 사용자 소유 검증 + private Todo findTodoByIdAndUser(Long todoId, User user) { + Todo todo = todoRepository.findById(todoId) + .orElseThrow(() -> new CustomException(ErrorCode.TODO_NOT_FOUND)); + + if (!todo.getUser().getId().equals(user.getId())) { + throw new CustomException(ErrorCode.TODO_FORBIDDEN); + } + + return todo; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 612466bb..3b25db35 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -43,11 +43,15 @@ public enum ErrorCode { PLAN_ORIGINAL_REPEAT_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_004", "해당 날짜에 원본 반복 계획을 찾을 수 없습니다."), INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "PLAN_005", "날짜 형식이 올바르지 않습니다. (YYYY-MM-DD 형식을 사용해주세요)"), PLAN_INVALID_TIME_RANGE(HttpStatus.BAD_REQUEST, "PLAN_006", "시작 시간은 종료 시간보다 빨라야 합니다."), - PLAN_CONFLICT(HttpStatus.CONFLICT, "PLAN_007", "이미 존재하는 학습 계획과 시간이 겹칩니다."), + PLAN_TIME_CONFLICT(HttpStatus.CONFLICT, "PLAN_007", "이미 존재하는 학습 계획과 시간이 겹칩니다. 기존 종료 시간과 겹치는 경우는 제외됩니다."), PLAN_CANNOT_UPDATE(HttpStatus.BAD_REQUEST, "PLAN_008", "수정 스위치 로직 탈출. 어떤 경우인지 파악이 필요합니다."), REPEAT_INVALID_UNTIL_DATE(HttpStatus.BAD_REQUEST, "REPEAT_001", "반복 계획의 종료 날짜는 시작 날짜 이전일 수 없습니다."), REPEAT_BYDAY_REQUIRED(HttpStatus.BAD_REQUEST, "REPEAT_002", "주간 반복 계획의 경우 요일(byDay) 정보가 필요합니다."), + // ======================== 투두 관련 ======================== + TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_001", "존재하지 않는 할 일입니다."), + TODO_FORBIDDEN(HttpStatus.FORBIDDEN, "TODO_002", "할 일에 대한 접근 권한이 없습니다."), + // ======================== 메시지 관련 ======================== MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "MESSAGE_001", "존재하지 않는 메시지입니다."),