diff --git a/build.gradle b/build.gradle index a397ccc..3f86d69 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.0' id 'io.spring.dependency-management' version '1.1.7' + //id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10' } group = 'com' @@ -26,6 +27,13 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + + // queryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' @@ -55,6 +63,36 @@ dependencies { implementation 'at.favre.lib:bcrypt:0.10.2' } +/* + * queryDSL 설정 추가 + */ +// querydsl에서 사용할 경로 설정 +//def querydslDir = "$buildDir/generated/querydsl" +// +// +//// JPA 사용 여부와 사용할 경로를 설정 +////querydsl { +//// jpa = true +//// querydslSourcesDir = querydslDir +////} +// +//// build 시 사용할 sourceSet 추가 +//sourceSets { +// main.java.srcDir querydslDir +//} +// +//tasks.withType(JavaCompile).configureEach { +// options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir) +//} +// +//// querydsl 이 compileClassPath 를 상속하도록 설정 +//configurations { +// compileOnly { +// extendsFrom annotationProcessor +// } +// querydsl.extendsFrom compileClasspath +//} + tasks.named('test') { useJUnitPlatform() } diff --git a/src/main/java/com/taskflow/domain/dashboard/controller/DashboardController.java b/src/main/java/com/taskflow/domain/dashboard/controller/DashboardController.java new file mode 100644 index 0000000..2fb9f37 --- /dev/null +++ b/src/main/java/com/taskflow/domain/dashboard/controller/DashboardController.java @@ -0,0 +1,38 @@ +package com.taskflow.domain.dashboard.controller; + +import com.taskflow.domain.dashboard.dto.TaskStatisticsResponse; +import com.taskflow.domain.dashboard.dto.TodayTaskSummaryResponse; +import com.taskflow.domain.dashboard.service.DashboardService; +import com.taskflow.global.common.ApiResponse; +import com.taskflow.global.response.success.StatisticSuccess; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class DashboardController { + + private final DashboardService dashboardService; + + + public DashboardController(DashboardService dashboardService) { + this.dashboardService = dashboardService; + } + + //통계값 조회 API + @GetMapping("/dashboard/statistics") + public ResponseEntity> getStatistics() { + return ResponseEntity.status(HttpStatus.OK). + body(ApiResponse.success(StatisticSuccess.STATISTIC_SUCCESS.getMessage(),dashboardService.getTaskStatistics())); + } + + //오늘의 태스크 요약 + @GetMapping("/dashboard/my-tasks/today") + public ResponseEntity> getMyTodayTasks(@RequestParam Long assigneeId) { + return ResponseEntity.status(HttpStatus.OK). +// body(ApiResponse.success(TaskSuccess.TASK_READ_SUCCESS.getMessage(), response)); + body(ApiResponse.success("태스크요약 성공", dashboardService.getTodayTaskSummary(assigneeId))); + } +} diff --git a/src/main/java/com/taskflow/domain/dashboard/dto/TaskSimpleResponse.java b/src/main/java/com/taskflow/domain/dashboard/dto/TaskSimpleResponse.java new file mode 100644 index 0000000..a6f8ff0 --- /dev/null +++ b/src/main/java/com/taskflow/domain/dashboard/dto/TaskSimpleResponse.java @@ -0,0 +1,18 @@ +package com.taskflow.domain.dashboard.dto; + +import com.taskflow.domain.task.entity.Task; + +import java.time.LocalDateTime; + +public record TaskSimpleResponse(Long id, String title, String priority, LocalDateTime dueDate) { + + public static TaskSimpleResponse from(Task task) { + return new TaskSimpleResponse( + task.getId(), + task.getTitle(), + task.getPriority().name(), + task.getDueDate() + ); + } + +} diff --git a/src/main/java/com/taskflow/domain/dashboard/dto/TaskStatisticsResponse.java b/src/main/java/com/taskflow/domain/dashboard/dto/TaskStatisticsResponse.java new file mode 100644 index 0000000..49933b1 --- /dev/null +++ b/src/main/java/com/taskflow/domain/dashboard/dto/TaskStatisticsResponse.java @@ -0,0 +1,10 @@ +package com.taskflow.domain.dashboard.dto; + +public record TaskStatisticsResponse( + long totalCount, + long todoCount, + long inProgressCount, + long doneCount, + double completionRate, + long overdueCount +) {} diff --git a/src/main/java/com/taskflow/domain/dashboard/dto/TodayTaskSummaryResponse.java b/src/main/java/com/taskflow/domain/dashboard/dto/TodayTaskSummaryResponse.java new file mode 100644 index 0000000..37a1ab3 --- /dev/null +++ b/src/main/java/com/taskflow/domain/dashboard/dto/TodayTaskSummaryResponse.java @@ -0,0 +1,8 @@ +package com.taskflow.domain.dashboard.dto; + +import java.util.List; + +public record TodayTaskSummaryResponse( + List todoTasks, + List inProgressTasks +) {} diff --git a/src/main/java/com/taskflow/domain/dashboard/repository/TaskRepositoryCustom.java b/src/main/java/com/taskflow/domain/dashboard/repository/TaskRepositoryCustom.java new file mode 100644 index 0000000..2d822c2 --- /dev/null +++ b/src/main/java/com/taskflow/domain/dashboard/repository/TaskRepositoryCustom.java @@ -0,0 +1,14 @@ +package com.taskflow.domain.dashboard.repository; + + +import com.taskflow.domain.dashboard.dto.TaskStatisticsResponse; +import com.taskflow.domain.task.entity.Task; +import com.taskflow.domain.task.enums.TaskStatus; + +import java.util.List; + +public interface TaskRepositoryCustom { + TaskStatisticsResponse fetchTaskStatistics(); + List findTodayTasksByStatusAndAssignee(TaskStatus status, Long assigneeId); + +} diff --git a/src/main/java/com/taskflow/domain/dashboard/repository/TaskRepositoryImpl.java b/src/main/java/com/taskflow/domain/dashboard/repository/TaskRepositoryImpl.java new file mode 100644 index 0000000..ee8934d --- /dev/null +++ b/src/main/java/com/taskflow/domain/dashboard/repository/TaskRepositoryImpl.java @@ -0,0 +1,78 @@ +package com.taskflow.domain.dashboard.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.taskflow.domain.dashboard.dto.TaskStatisticsResponse; + +import com.taskflow.domain.task.entity.QTask; +import com.taskflow.domain.task.entity.Task; +import com.taskflow.domain.task.enums.TaskStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Repository +public class TaskRepositoryImpl implements TaskRepositoryCustom { + private final JPAQueryFactory queryFactory; + + + @Override + public TaskStatisticsResponse fetchTaskStatistics() { + QTask task = QTask.task; + LocalDateTime now = LocalDateTime.now(); + + long total = queryFactory.select(task.count()) + .from(task) + .where(task.isDeleted.isFalse()) + .fetchOne(); + + long todo = queryFactory.select(task.count()) + .from(task) + .where(task.isDeleted.isFalse() + .and(task.status.eq(TaskStatus.valueOf("TODO")))) + .fetchOne(); + + long inProgress = queryFactory.select(task.count()) + .from(task) + .where(task.isDeleted.isFalse() + .and(task.status.eq(TaskStatus.valueOf("IN_PROGRESS")))) + .fetchOne(); + + long done = queryFactory.select(task.count()) + .from(task) + .where(task.isDeleted.isFalse() + .and(task.status.eq(TaskStatus.valueOf("DONE")))) + .fetchOne(); + + long overdue = queryFactory.select(task.count()) + .from(task) + .where(task.isDeleted.isFalse() + .and(task.status.in(TaskStatus.TODO, TaskStatus.IN_PROGRESS)) + .and(task.dueDate.before(now))) + .fetchOne(); + + double completionRate = total == 0 ? 0.0 : Math.round((done * 10000.0 / total)) / 100.0; + + return new TaskStatisticsResponse(total, todo, inProgress, done, completionRate, overdue); + } + + @Override + public List findTodayTasksByStatusAndAssignee(TaskStatus status, Long assigneeId) { + QTask task = QTask.task; + + return queryFactory + .selectFrom(task) + .where( + task.assignee.id.eq(assigneeId), + task.status.eq(status), + task.isDeleted.isFalse(), + task.createdAt.between(LocalDate.now().atStartOfDay(), LocalDateTime.now()) + ) + .orderBy(task.priority.desc()) // HIGH > MEDIUM > LOW + .fetch(); + } +} + diff --git a/src/main/java/com/taskflow/domain/dashboard/service/DashboardService.java b/src/main/java/com/taskflow/domain/dashboard/service/DashboardService.java new file mode 100644 index 0000000..473dc5e --- /dev/null +++ b/src/main/java/com/taskflow/domain/dashboard/service/DashboardService.java @@ -0,0 +1,45 @@ +package com.taskflow.domain.dashboard.service; + +import com.taskflow.domain.dashboard.dto.TaskSimpleResponse; +import com.taskflow.domain.dashboard.dto.TaskStatisticsResponse; +import com.taskflow.domain.dashboard.dto.TodayTaskSummaryResponse; +import com.taskflow.domain.task.entity.Task; +import com.taskflow.domain.task.enums.TaskStatus; +import com.taskflow.domain.task.repository.TaskRepository; +import com.taskflow.global.exception.dashboard.StastisticException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class DashboardService { + + private final TaskRepository taskRepository; + + public DashboardService(TaskRepository taskRepository) { + this.taskRepository = taskRepository; + } + + public TaskStatisticsResponse getTaskStatistics() { + + TaskStatisticsResponse dto = taskRepository.fetchTaskStatistics(); + + if (dto.totalCount() == 0) { + throw new StastisticException("태스크 통계를 불러오는 중 오류 발생", HttpStatus.INTERNAL_SERVER_ERROR); + } + + return dto; + } + + public TodayTaskSummaryResponse getTodayTaskSummary(Long assigneeId) { + List todos = taskRepository.findTodayTasksByStatusAndAssignee(TaskStatus.TODO, assigneeId); + List inProgress = taskRepository.findTodayTasksByStatusAndAssignee(TaskStatus.IN_PROGRESS, assigneeId); + + return new TodayTaskSummaryResponse( + todos.stream().map(TaskSimpleResponse::from).toList(), + inProgress.stream().map(TaskSimpleResponse::from).toList() + ); + } + +} diff --git a/src/main/java/com/taskflow/domain/member/entity/Member.java b/src/main/java/com/taskflow/domain/member/entity/Member.java index 53e1ebb..63934ba 100644 --- a/src/main/java/com/taskflow/domain/member/entity/Member.java +++ b/src/main/java/com/taskflow/domain/member/entity/Member.java @@ -33,6 +33,7 @@ public class Member extends BaseEntity { private UserRole userRole; @Column(nullable = false, columnDefinition = "BOOLEAN DEFAULT false") + @Builder.Default private Boolean is_deleted = false; public void softDelete() { diff --git a/src/main/java/com/taskflow/domain/task/entity/Task.java b/src/main/java/com/taskflow/domain/task/entity/Task.java index c5b60b3..02439b9 100644 --- a/src/main/java/com/taskflow/domain/task/entity/Task.java +++ b/src/main/java/com/taskflow/domain/task/entity/Task.java @@ -149,4 +149,8 @@ public void delete() { this.isDeleted = true; this.deletedAt = LocalDateTime.now(); } + +} + } + diff --git a/src/main/java/com/taskflow/domain/task/enums/TaskPriority.java b/src/main/java/com/taskflow/domain/task/enums/TaskPriority.java index c23c1cd..526fa03 100644 --- a/src/main/java/com/taskflow/domain/task/enums/TaskPriority.java +++ b/src/main/java/com/taskflow/domain/task/enums/TaskPriority.java @@ -1,5 +1,11 @@ package com.taskflow.domain.task.enums; + + +public enum TaskPriority { + LOW, MEDIUM, HIGH +} + /** * 일정 우선순위를 정의하는 열거형 * 업무 중요도에 따라 LOW, MEDIUM, HIGH 세 가지 값으로 분류 @@ -7,3 +13,4 @@ public enum TaskPriority { LOW, MEDIUM, HIGH } + diff --git a/src/main/java/com/taskflow/domain/task/enums/TaskStatus.java b/src/main/java/com/taskflow/domain/task/enums/TaskStatus.java index 604cf1d..69ad5fb 100644 --- a/src/main/java/com/taskflow/domain/task/enums/TaskStatus.java +++ b/src/main/java/com/taskflow/domain/task/enums/TaskStatus.java @@ -1,9 +1,13 @@ package com.taskflow.domain.task.enums; + +// 일정 상태 정의 + /** * 일정 상태를 정의하는 열거형 * 각 일정의 진행 상태를 나타내는 값으로 구성 */ + public enum TaskStatus { TODO, IN_PROGRESS, DONE } \ No newline at end of file diff --git a/src/main/java/com/taskflow/domain/task/repository/TaskRepository.java b/src/main/java/com/taskflow/domain/task/repository/TaskRepository.java index fe02149..7ae5016 100644 --- a/src/main/java/com/taskflow/domain/task/repository/TaskRepository.java +++ b/src/main/java/com/taskflow/domain/task/repository/TaskRepository.java @@ -1,5 +1,8 @@ package com.taskflow.domain.task.repository; + +import com.taskflow.domain.dashboard.repository.TaskRepositoryCustom; + import com.taskflow.domain.member.entity.Member; import com.taskflow.domain.task.entity.Task; import com.taskflow.domain.task.enums.TaskStatus; @@ -13,8 +16,12 @@ /** * Task 엔티티에 대한 데이터베이스 접근을 담당하는 JPA 레포지토리 인터페이스 */ + +public interface TaskRepository extends JpaRepository, TaskRepositoryCustom { + public interface TaskRepository extends JpaRepository { + /** * 삭제되지 않은 전체 일정 조회 */ @@ -59,4 +66,8 @@ public interface TaskRepository extends JpaRepository { * 상태, 담당자, 제목 키워드 기준으로 삭제되지 않은 일정 조회 */ List findAllByStatusAndAssigneeAndTitleContainingAndIsDeletedFalse(TaskStatus status, Member assignee, String title); + +} + } + diff --git a/src/main/java/com/taskflow/global/config/QuerydslConfig.java b/src/main/java/com/taskflow/global/config/QuerydslConfig.java new file mode 100644 index 0000000..d4ab956 --- /dev/null +++ b/src/main/java/com/taskflow/global/config/QuerydslConfig.java @@ -0,0 +1,20 @@ +package com.taskflow.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + //영속성을 트랜잭션 범위에서 생성하여 관리하는 어노테이션 + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/taskflow/global/exception/GlobalExceptionHandler.java b/src/main/java/com/taskflow/global/exception/GlobalExceptionHandler.java index 1cde585..4152ca8 100644 --- a/src/main/java/com/taskflow/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/taskflow/global/exception/GlobalExceptionHandler.java @@ -1,11 +1,17 @@ package com.taskflow.global.exception; import com.taskflow.global.common.ApiResponse; + +import com.taskflow.global.exception.dashboard.StastisticException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.ErrorResponse; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; + import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -24,6 +30,13 @@ public ResponseEntity> handleCustomException(CustomException .body(ApiResponse.fail(e.getErrorMessage())); } + //통계 예외처리 + @ExceptionHandler(StastisticException.class) + public ResponseEntity> handleStastisticException(StastisticException e) { + return ResponseEntity + .status(e.getStatus().value()) + .body(ApiResponse.fail(e.getMessage())); + // @Valid에서 검증값을 잘못 입력하였을 경우 @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { @@ -44,5 +57,6 @@ public ResponseEntity> handleJsonParseError(HttpMessageNotRe return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body(ApiResponse.fail("입력값이 누락되었습니다. 확인해주세요.")); + } } diff --git a/src/main/java/com/taskflow/global/exception/dashboard/StastisticException.java b/src/main/java/com/taskflow/global/exception/dashboard/StastisticException.java new file mode 100644 index 0000000..0583b00 --- /dev/null +++ b/src/main/java/com/taskflow/global/exception/dashboard/StastisticException.java @@ -0,0 +1,14 @@ +package com.taskflow.global.exception.dashboard; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class StastisticException extends RuntimeException { + private final HttpStatus status; + + public StastisticException(String message, HttpStatus status) { + super(message); + this.status = status; + } +} diff --git a/src/main/java/com/taskflow/global/response/success/StatisticSuccess.java b/src/main/java/com/taskflow/global/response/success/StatisticSuccess.java new file mode 100644 index 0000000..312892e --- /dev/null +++ b/src/main/java/com/taskflow/global/response/success/StatisticSuccess.java @@ -0,0 +1,23 @@ +package com.taskflow.global.response.success; + +import org.springframework.http.HttpStatus; + +public enum StatisticSuccess { + STATISTIC_SUCCESS(HttpStatus.OK, "통계정보 전달 성공"); + + private final HttpStatus status; + private final String message; + + StatisticSuccess(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + public HttpStatus getStatus() { + return status; + } + + public String getMessage() { + return message; + } +} diff --git a/src/test/java/com/taskflow/TimeComparisonTest.java b/src/test/java/com/taskflow/TimeComparisonTest.java new file mode 100644 index 0000000..ddeced5 --- /dev/null +++ b/src/test/java/com/taskflow/TimeComparisonTest.java @@ -0,0 +1,23 @@ +package com.taskflow; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +class TimeComparisonTest { + + @Test + void 기한_초과_비교_테스트() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime dueDatePast = now.minusDays(1); + LocalDateTime dueDateFuture = now.plusDays(1); + + assertTrue(dueDatePast.isBefore(now)); + assertFalse(dueDateFuture.isBefore(now)); + } +}