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 @@ -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<StudyPlan, Long> {
List<StudyPlan> 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<StudyPlan> findByUserIdAndNotIdAndOverlapsTime(
Long userId,
Long planIdToExclude,
LocalDateTime newStart,
LocalDateTime newEnd
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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<StudyPlan> 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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RsData<TodoResponseDto>> 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<RsData<List<TodoResponseDto>>> getTodos(
@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date
) {
List<TodoResponseDto> response = todoService.getTodosByDate(userDetails.getUserId(), date);

return ResponseEntity.ok(RsData.success("할 일 목록을 조회했습니다.", response));
}

// 사용자의 모든 할 일 조회
@GetMapping("/all")
@Operation(summary = "모든 할 일 조회", description = "사용자의 모든 할 일을 조회합니다.")
public ResponseEntity<RsData<List<TodoResponseDto>>> getAllTodos(
@AuthenticationPrincipal CustomUserDetails userDetails
) {
List<TodoResponseDto> response = todoService.getAllTodos(userDetails.getUserId());
return ResponseEntity.ok(RsData.success("모든 할 일을 조회했습니다.", response));
}

// ==================== 수정 ===================
// 할 일 내용 수정
@PutMapping("/{todoId}")
@Operation(summary = "할 일 수정", description = "할 일의 내용과 날짜를 수정합니다.")
public ResponseEntity<RsData<TodoResponseDto>> 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<RsData<TodoResponseDto>> 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<RsData<Void>> deleteTodo(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long todoId
) {
todoService.deleteTodo(userDetails.getUserId(), todoId);
return ResponseEntity.ok(RsData.success("할 일이 삭제되었습니다."));
}

}
15 changes: 15 additions & 0 deletions src/main/java/com/back/domain/study/todo/dto/TodoRequestDto.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
23 changes: 23 additions & 0 deletions src/main/java/com/back/domain/study/todo/dto/TodoResponseDto.java
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
19 changes: 17 additions & 2 deletions src/main/java/com/back/domain/study/todo/entity/Todo.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,36 @@
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

@Entity
@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;

private boolean isComplete;

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;
}
}
Original file line number Diff line number Diff line change
@@ -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<Todo, Long> {
List<Todo> findByUserIdAndDate(Long userId, LocalDate date);
List<Todo> findByUserId(Long userId);
Todo findByIdAndUser(Long id, User user);
}
Loading
Loading