From f22a50fb5ca18ae6349f52f1c3ad5da69f71bc4d Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 01:51:00 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(task):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=EC=9A=A9=20=EC=97=B4=EB=A6=B0=20Task=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C,=20=EC=83=81=ED=83=9C=EB=B3=84=20=EA=B0=9C=EC=88=98?= =?UTF-8?q?=20=EC=A7=91=EA=B3=84=20=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/SpringDataTaskJpaRepository.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java index f99a8255..c0675dfd 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java @@ -49,4 +49,18 @@ Page search( @Param("keyword") String keyword, Pageable pageable ); + + @Query(""" + SELECT task + FROM TaskJpaEntity task + WHERE task.companyId = :companyId + AND task.status <> com.fowoco.server.task.domain.TaskStatus.COMPLETED + AND task.status <> com.fowoco.server.task.domain.TaskStatus.CANCELLED + """) + Page findOpenTasksByCompanyId( + @Param("companyId") UUID companyId, + Pageable pageable + ); + + long countByCompanyIdAndStatus(UUID companyId, TaskStatus status); } From eb1f3a392f65e35fe7f9423e71eb76bb511b61e3 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 01:55:26 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(task):=20TaskRepository=EC=97=90=20fin?= =?UTF-8?q?dOpenTasks,=20countByCompanyIdAndStatus=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../task/application/port/TaskRepository.java | 5 ++--- .../persistence/JpaTaskRepository.java | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java b/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java index 20e4b644..2623177f 100644 --- a/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java +++ b/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java @@ -11,11 +11,10 @@ import java.util.UUID; public interface TaskRepository { - Optional findByIdAndCompanyId(UUID taskId, UUID companyId); - TaskPage findAll(TaskSearchCriteria criteria); - + List findOpenTasks(UUID companyId, int limit); + long countByCompanyIdAndStatus(UUID companyId, TaskStatus status); Task save(Task task); record TaskSearchCriteria( diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java index da7c0b09..9989563d 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java @@ -2,8 +2,10 @@ import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; import java.util.Optional; import java.util.UUID; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -51,6 +53,20 @@ public TaskPage findAll(TaskSearchCriteria criteria) { ); } + @Override + public List findOpenTasks(UUID companyId, int limit) { + Page page = repository.findOpenTasksByCompanyId( + companyId, + PageRequest.of(0, limit, Sort.by(Sort.Order.asc("dueDate"), Sort.Order.desc("createdAt"))) + ); + return page.getContent().stream().map(TaskJpaEntity::toDomain).toList(); + } + + @Override + public long countByCompanyIdAndStatus(UUID companyId, TaskStatus status) { + return repository.countByCompanyIdAndStatus(companyId, status); + } + @Override public Task save(Task task) { TaskJpaEntity entity = repository.findByTaskIdAndCompanyId(task.taskId(), task.companyId()) From 01fd01e27b421e0d2fe2c665af8021fa595fad8f Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 02:02:39 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(dashboard):=20DashboardTaskSummaryResp?= =?UTF-8?q?onse=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/DashboardTaskSummaryResponse.java | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/main/java/com/fowoco/server/dashboard/api/DashboardTaskSummaryResponse.java diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardTaskSummaryResponse.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardTaskSummaryResponse.java new file mode 100644 index 00000000..25b2542e --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardTaskSummaryResponse.java @@ -0,0 +1,68 @@ +package com.fowoco.server.dashboard.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.UUID; + +@Schema(name = "DashboardTaskSummaryResponse", description = "대시보드에 표시할 업무 요약") +public final class DashboardTaskSummaryResponse { + + @JsonProperty("task_id") + @Schema(name = "task_id", format = "uuid") + private final UUID taskId; + + @JsonProperty("worker_id") + @Schema(name = "worker_id", format = "uuid") + private final UUID workerId; + + @JsonProperty("title") + private final String title; + + @JsonProperty("status") + private final TaskStatus status; + + @JsonProperty("due_date") + @Schema(name = "due_date", format = "date") + private final LocalDate dueDate; + + private DashboardTaskSummaryResponse(UUID taskId, UUID workerId, String title, TaskStatus status, LocalDate dueDate) { + this.taskId = taskId; + this.workerId = workerId; + this.title = title; + this.status = status; + this.dueDate = dueDate; + } + + public static DashboardTaskSummaryResponse from(Task task) { + return new DashboardTaskSummaryResponse( + task.taskId(), + task.workerId(), + task.title(), + task.status(), + task.dueDate() + ); + } + + public UUID getTaskId() { + return taskId; + } + + public UUID getWorkerId() { + return workerId; + } + + public String getTitle() { + return title; + } + + public TaskStatus getStatus() { + return status; + } + + public LocalDate getDueDate() { + return dueDate; + } +} From b71ed07a9e9dc84e1a0d8cd1ce468302271edfa5 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 02:10:44 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(dashboard):=20DashboardQueryService=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20due-today=EB=A5=BC=20=EC=97=B4=EB=A6=B0?= =?UTF-8?q?=20Task=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=ED=99=95=ED=9E=88=20=EA=B3=84=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/DashboardSummaryCountsResponse.java | 47 ++++++++++++++ .../dashboard/api/DashboardTodayResponse.java | 53 ++++++++++++++++ .../application/DashboardQueryService.java | 63 +++++++++++++++++++ .../task/application/port/TaskRepository.java | 1 + .../persistence/JpaTaskRepository.java | 6 ++ .../SpringDataTaskJpaRepository.java | 10 +++ 6 files changed, 180 insertions(+) create mode 100644 src/main/java/com/fowoco/server/dashboard/api/DashboardSummaryCountsResponse.java create mode 100644 src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java create mode 100644 src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardSummaryCountsResponse.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardSummaryCountsResponse.java new file mode 100644 index 00000000..2256d011 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardSummaryCountsResponse.java @@ -0,0 +1,47 @@ +package com.fowoco.server.dashboard.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "DashboardSummaryCountsResponse", description = "오늘 대시보드 상태별 개수") +public final class DashboardSummaryCountsResponse { + + @JsonProperty("pending_approval") + @Schema(name = "pending_approval", description = "승인 대기(READY_FOR_REVIEW) 개수") + private final long pendingApproval; + + @JsonProperty("due_today") + @Schema(name = "due_today", description = "오늘 마감인 업무 개수") + private final long dueToday; + + @JsonProperty("needs_info") + @Schema(name = "needs_info", description = "정보 보완(NEEDS_INFO) 개수") + private final long needsInfo; + + @JsonProperty("worker_response") + @Schema(name = "worker_response", description = "근로자 응답 대기(WAITING_WORKER) 개수") + private final long workerResponse; + + public DashboardSummaryCountsResponse(long pendingApproval, long dueToday, long needsInfo, long workerResponse) { + this.pendingApproval = pendingApproval; + this.dueToday = dueToday; + this.needsInfo = needsInfo; + this.workerResponse = workerResponse; + } + + public long getPendingApproval() { + return pendingApproval; + } + + public long getDueToday() { + return dueToday; + } + + public long getNeedsInfo() { + return needsInfo; + } + + public long getWorkerResponse() { + return workerResponse; + } +} diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java new file mode 100644 index 00000000..ce2e08df --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java @@ -0,0 +1,53 @@ +package com.fowoco.server.dashboard.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(name = "DashboardTodayResponse", description = "오늘 대시보드 응답") +public final class DashboardTodayResponse { + + @JsonProperty("summary_counts") + @Schema(name = "summary_counts") + private final DashboardSummaryCountsResponse summaryCounts; + + @JsonProperty("priority_tasks") + @Schema(name = "priority_tasks", description = "오늘의 우선 업무 (열린 업무, 마감일 순 최대 5건)") + private final List priorityTasks; + + @JsonProperty("approval_count") + @Schema(name = "approval_count", description = "승인 대기 개수 (summary_counts.pending_approval과 동일)") + private final long approvalCount; + + @JsonProperty("worker_response_count") + @Schema(name = "worker_response_count", description = "근로자 응답 대기 개수") + private final long workerResponseCount; + + public DashboardTodayResponse( + DashboardSummaryCountsResponse summaryCounts, + List priorityTasks, + long approvalCount, + long workerResponseCount + ) { + this.summaryCounts = summaryCounts; + this.priorityTasks = priorityTasks; + this.approvalCount = approvalCount; + this.workerResponseCount = workerResponseCount; + } + + public DashboardSummaryCountsResponse getSummaryCounts() { + return summaryCounts; + } + + public List getPriorityTasks() { + return priorityTasks; + } + + public long getApprovalCount() { + return approvalCount; + } + + public long getWorkerResponseCount() { + return workerResponseCount; + } +} diff --git a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java new file mode 100644 index 00000000..3d304ba6 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java @@ -0,0 +1,63 @@ +package com.fowoco.server.dashboard.application; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.dashboard.api.DashboardSummaryCountsResponse; +import com.fowoco.server.dashboard.api.DashboardTaskSummaryResponse; +import com.fowoco.server.dashboard.api.DashboardTodayResponse; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DashboardQueryService { + + private static final int PRIORITY_TASK_LIMIT = 5; + + private final TaskRepository taskRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final Clock clock; + + public DashboardQueryService( + TaskRepository taskRepository, + TenantDatabaseContext tenantDatabaseContext, + Clock clock + ) { + this.taskRepository = taskRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.clock = clock; + } + + @Transactional(readOnly = true) + public DashboardTodayResponse today(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + UUID companyId = actor.companyId(); + + long pendingApproval = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.READY_FOR_REVIEW); + long needsInfo = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.NEEDS_INFO); + long workerResponse = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.WAITING_WORKER); + long dueToday = countDueToday(companyId); + + DashboardSummaryCountsResponse summaryCounts = new DashboardSummaryCountsResponse( + pendingApproval, dueToday, needsInfo, workerResponse + ); + + List openTasks = taskRepository.findOpenTasks(companyId, PRIORITY_TASK_LIMIT); + List priorityTasks = openTasks.stream() + .map(DashboardTaskSummaryResponse::from) + .toList(); + + return new DashboardTodayResponse(summaryCounts, priorityTasks, pendingApproval, workerResponse); + } + + private long countDueToday(UUID companyId) { + LocalDate today = LocalDate.now(clock); + return taskRepository.countOpenTasksDueOn(companyId, today); + } +} diff --git a/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java b/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java index 2623177f..515b34ff 100644 --- a/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java +++ b/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java @@ -15,6 +15,7 @@ public interface TaskRepository { TaskPage findAll(TaskSearchCriteria criteria); List findOpenTasks(UUID companyId, int limit); long countByCompanyIdAndStatus(UUID companyId, TaskStatus status); + long countOpenTasksDueOn(UUID companyId, LocalDate dueDate); Task save(Task task); record TaskSearchCriteria( diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java index 9989563d..602a8e57 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java @@ -3,6 +3,7 @@ import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.task.domain.Task; import com.fowoco.server.task.domain.TaskStatus; +import java.time.LocalDate; import java.util.Optional; import java.util.UUID; import java.util.List; @@ -67,6 +68,11 @@ public long countByCompanyIdAndStatus(UUID companyId, TaskStatus status) { return repository.countByCompanyIdAndStatus(companyId, status); } + @Override + public long countOpenTasksDueOn(UUID companyId, LocalDate dueDate) { + return repository.countOpenTasksDueOn(companyId, dueDate); + } + @Override public Task save(Task task) { TaskJpaEntity entity = repository.findByTaskIdAndCompanyId(task.taskId(), task.companyId()) diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java index c0675dfd..b973a22d 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java @@ -63,4 +63,14 @@ Page findOpenTasksByCompanyId( ); long countByCompanyIdAndStatus(UUID companyId, TaskStatus status); + + @Query(""" + SELECT COUNT(task) + FROM TaskJpaEntity task + WHERE task.companyId = :companyId + AND task.dueDate = :dueDate + AND task.status <> com.fowoco.server.task.domain.TaskStatus.COMPLETED + AND task.status <> com.fowoco.server.task.domain.TaskStatus.CANCELLED + """) + long countOpenTasksDueOn(@Param("companyId") UUID companyId, @Param("dueDate") LocalDate dueDate); } From b59614bc4bce4091b02722250b3cf3e0a47d2f9e Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 02:28:00 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat(dashboard):=20GET=20/dashboard/today?= =?UTF-8?q?=20API=20=EC=B6=94=EA=B0=80=20(date,=20timezone=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=A7=80=EC=9B=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/api/DashboardController.java | 66 +++++++++++++++++++ .../application/DashboardQueryService.java | 11 ++-- 2 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/fowoco/server/dashboard/api/DashboardController.java diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardController.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardController.java new file mode 100644 index 00000000..9e3cb324 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardController.java @@ -0,0 +1,66 @@ +package com.fowoco.server.dashboard.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.dashboard.application.DashboardQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter; +import java.time.LocalDate; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Dashboard", description = "오늘 업무 대시보드 요약") +@RestController +@RequestMapping("/api/v1/dashboard") +@SecurityRequirement(name = "bearerAuth") +public class DashboardController { + + private final DashboardQueryService dashboardQueryService; + private final ActorContextProvider actorContextProvider; + + public DashboardController( + DashboardQueryService dashboardQueryService, + ActorContextProvider actorContextProvider + ) { + this.dashboardQueryService = dashboardQueryService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "getDashboardToday", + summary = "오늘 대시보드 조회", + description = "오늘 우선 업무·승인 대기·응답 대기·기한 경고를 한 번에 조회합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DashboardTodayResponse.class) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden") + }) + @GetMapping(path = "/today", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public DashboardTodayResponse today( + @Parameter(description = "기준 날짜 (생략 시 서버 오늘 날짜)") @RequestParam(required = false) LocalDate date, + @Parameter(description = "IANA 타임존 ID (예: Asia/Seoul, 생략 시 서버 기본 타임존)") + @RequestParam(required = false) String timezone + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return dashboardQueryService.today(actor, date, timezone); + } +} diff --git a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java index 3d304ba6..dcd04966 100644 --- a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java +++ b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java @@ -35,14 +35,16 @@ public DashboardQueryService( } @Transactional(readOnly = true) - public DashboardTodayResponse today(ActorContext actor) { + public DashboardTodayResponse today(ActorContext actor, LocalDate date, String timezone) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); UUID companyId = actor.companyId(); + Clock effectiveClock = timezone != null ? clock.withZone(java.time.ZoneId.of(timezone)) : clock; + LocalDate targetDate = date != null ? date : LocalDate.now(effectiveClock); long pendingApproval = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.READY_FOR_REVIEW); long needsInfo = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.NEEDS_INFO); long workerResponse = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.WAITING_WORKER); - long dueToday = countDueToday(companyId); + long dueToday = taskRepository.countOpenTasksDueOn(companyId, targetDate); DashboardSummaryCountsResponse summaryCounts = new DashboardSummaryCountsResponse( pendingApproval, dueToday, needsInfo, workerResponse @@ -55,9 +57,4 @@ public DashboardTodayResponse today(ActorContext actor) { return new DashboardTodayResponse(summaryCounts, priorityTasks, pendingApproval, workerResponse); } - - private long countDueToday(UUID companyId) { - LocalDate today = LocalDate.now(clock); - return taskRepository.countOpenTasksDueOn(companyId, today); - } } From c2fbc64e9325306049e2a6ad4a9cca1f847689d8 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 02:44:58 +0900 Subject: [PATCH 6/7] =?UTF-8?q?test(dashboard):=20DashboardSecurityIntegra?= =?UTF-8?q?tionTest=205=EA=B0=9C=20(=EB=B9=88=EC=82=AC=EC=97=85=EC=9E=A5,?= =?UTF-8?q?=20=EA=B0=9C=EC=88=98=EC=9D=BC=EC=B9=98,=20=ED=83=80=EC=82=AC?= =?UTF-8?q?=EC=97=85=EC=9E=A5=EA=B2=A9=EB=A6=AC,=20VIEWER=EC=A0=91?= =?UTF-8?q?=EA=B7=BC,=20date=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DashboardSecurityIntegrationTest.java | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java diff --git a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java new file mode 100644 index 00000000..1af29cff --- /dev/null +++ b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java @@ -0,0 +1,230 @@ +package com.fowoco.server.dashboard; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DashboardSecurityIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("50000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("60000000-0000-0000-0000-000000000002"); + private static final UUID HR_A = UUID.fromString("51000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("61000000-0000-0000-0000-000000000002"); + private static final UUID VIEWER_A = UUID.fromString("52000000-0000-0000-0000-000000000001"); + private static final String HR_A_EMAIL = "hr.dashboard.a@example.com"; + private static final String HR_B_EMAIL = "hr.dashboard.b@example.com"; + private static final String VIEWER_A_EMAIL = "viewer.dashboard.a@example.com"; + private static final String PASSWORD = "Test-password-1!"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeAll + void seedCompaniesAndUsers() { + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM task_evidence"); + jdbcTemplate.update("DELETE FROM external_submission"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task_transition_history"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM worker_document"); + jdbcTemplate.update("DELETE FROM worker"); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + + insertCompany(COMPANY_A, "대시보드 사업장 A"); + insertCompany(COMPANY_B, "대시보드 사업장 B"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash, "HR"); + insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash, "HR"); + insertUser(VIEWER_A, COMPANY_A, VIEWER_A_EMAIL, passwordHash, "VIEWER"); + } + + @BeforeEach + void resetTaskState() { + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task_transition_history"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM worker"); + } + + @Test + void emptyCompanyReturnsZeroCountsNotError() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/dashboard/today", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(response.body(), "$.summary_counts.pending_approval").longValue()) + .isZero(); + assertThat(JsonPath.read(response.body(), "$.summary_counts.due_today").longValue()) + .isZero(); + assertThat(JsonPath.>read(response.body(), "$.priority_tasks")).isEmpty(); + } + + @Test + void countsMatchActualTaskStatuses() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(accessToken, "대시보드테스트근로자"); + createTask(accessToken, workerId, "READY_FOR_REVIEW_후보1"); + createTask(accessToken, workerId, "READY_FOR_REVIEW_후보2"); + + HttpResponse response = authorizedGet("/api/v1/dashboard/today", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.priority_tasks")).hasSize(2); + } + + @Test + void otherCompanyDataIsNotVisible() throws Exception { + String companyAToken = accessToken(login(HR_A_EMAIL)); + String companyBToken = accessToken(login(HR_B_EMAIL)); + String workerId = registerWorker(companyBToken, "타사업장근로자"); + createTask(companyBToken, workerId, "타사업장업무"); + + HttpResponse response = authorizedGet("/api/v1/dashboard/today", companyAToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.priority_tasks")).isEmpty(); + } + + @Test + void viewerCanAccessDashboard() throws Exception { + String viewerToken = accessToken(login(VIEWER_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/dashboard/today", viewerToken); + + assertThat(response.statusCode()).isEqualTo(200); + } + + @Test + void dateParameterOverridesServerToday() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?date=2020-01-01", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + } + + private String registerWorker(String accessToken, String displayName) throws Exception { + String body = """ + {"display_name": "%s"} + """.formatted(displayName); + HttpResponse response = postJson("/api/v1/workers", body, accessToken); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_id"); + } + + private String createTask(String accessToken, String workerId, String title) throws Exception { + String body = """ + { + "worker_id":"%s", + "task_type":"RECONTRACT", + "workflow_id":"WF-CON-001", + "title":"%s", + "description":"대시보드 테스트용", + "due_date":"2026-08-20", + "business_data":{"monthly_wage":2500000} + } + """.formatted(workerId, title); + HttpResponse response = postJson("/api/v1/tasks", body, accessToken); + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + return JsonPath.read(response.body(), "$.task_id"); + } + + private void insertCompany(UUID companyId, String name) { + jdbcTemplate.update( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + companyId, + name + ); + } + + private void insertUser(UUID userId, UUID companyId, String email, String passwordHash, String role) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + userId, + companyId, + email, + email, + passwordHash, + role + ); + } + + private HttpResponse login(String email) throws Exception { + String body = """ + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD); + return postJson("/api/v1/auth/login", body, null); + } + + private String accessToken(HttpResponse loginResponse) { + assertThat(loginResponse.statusCode()).isEqualTo(200); + return JsonPath.read(loginResponse.body(), "$.access_token"); + } + + private HttpResponse authorizedGet(String path, String accessToken) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .GET() + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse postJson(String path, String body, String accessToken) throws Exception { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (accessToken != null) { + requestBuilder.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); + } + return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } +} From 66dee42d6e7e0e5e1970e2b9d9736661bcb25257 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 03:33:11 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix(dashboard):=20timezone=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20500=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20400=20=EB=B0=98=ED=99=98,=20summary=5Fcounts=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EA=B0=92=20=EA=B2=80=EC=A6=9D=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/DashboardQueryService.java | 14 +++- .../application/error/DashboardErrorCode.java | 34 ++++++++++ .../DashboardSecurityIntegrationTest.java | 68 ++++++++++++++++++- 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/fowoco/server/dashboard/application/error/DashboardErrorCode.java diff --git a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java index dcd04966..98d78d0f 100644 --- a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java +++ b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java @@ -1,15 +1,19 @@ package com.fowoco.server.dashboard.application; import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.dashboard.api.DashboardSummaryCountsResponse; import com.fowoco.server.dashboard.api.DashboardTaskSummaryResponse; import com.fowoco.server.dashboard.api.DashboardTodayResponse; +import com.fowoco.server.dashboard.application.error.DashboardErrorCode; import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.task.domain.Task; import com.fowoco.server.task.domain.TaskStatus; import java.time.Clock; +import java.time.DateTimeException; import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.UUID; import org.springframework.stereotype.Service; @@ -38,7 +42,7 @@ public DashboardQueryService( public DashboardTodayResponse today(ActorContext actor, LocalDate date, String timezone) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); UUID companyId = actor.companyId(); - Clock effectiveClock = timezone != null ? clock.withZone(java.time.ZoneId.of(timezone)) : clock; + Clock effectiveClock = timezone != null ? clock.withZone(parseTimezone(timezone)) : clock; LocalDate targetDate = date != null ? date : LocalDate.now(effectiveClock); long pendingApproval = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.READY_FOR_REVIEW); @@ -57,4 +61,12 @@ public DashboardTodayResponse today(ActorContext actor, LocalDate date, String t return new DashboardTodayResponse(summaryCounts, priorityTasks, pendingApproval, workerResponse); } + + private ZoneId parseTimezone(String timezone) { + try { + return ZoneId.of(timezone); + } catch (DateTimeException exception) { + throw new ApiException(DashboardErrorCode.INVALID_TIMEZONE); + } + } } diff --git a/src/main/java/com/fowoco/server/dashboard/application/error/DashboardErrorCode.java b/src/main/java/com/fowoco/server/dashboard/application/error/DashboardErrorCode.java new file mode 100644 index 00000000..0ff3dcf3 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/application/error/DashboardErrorCode.java @@ -0,0 +1,34 @@ +package com.fowoco.server.dashboard.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum DashboardErrorCode implements ApiErrorCode { + INVALID_TIMEZONE( + HttpStatus.BAD_REQUEST, + "유효하지 않은 timezone 값입니다. IANA 타임존 ID(예: Asia/Seoul)를 사용해 주세요." + ); + + private final HttpStatus status; + private final String defaultMessage; + + DashboardErrorCode(HttpStatus status, String defaultMessage) { + this.status = status; + this.defaultMessage = defaultMessage; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String defaultMessage() { + return defaultMessage; + } +} diff --git a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java index 1af29cff..8b10e107 100644 --- a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java @@ -8,6 +8,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.UUID; +import java.util.List; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -98,13 +99,18 @@ void emptyCompanyReturnsZeroCountsNotError() throws Exception { void countsMatchActualTaskStatuses() throws Exception { String accessToken = accessToken(login(HR_A_EMAIL)); String workerId = registerWorker(accessToken, "대시보드테스트근로자"); - createTask(accessToken, workerId, "READY_FOR_REVIEW_후보1"); + String taskId1 = createTask(accessToken, workerId, "READY_FOR_REVIEW_후보1"); createTask(accessToken, workerId, "READY_FOR_REVIEW_후보2"); + requestReview(accessToken, taskId1, workerId); HttpResponse response = authorizedGet("/api/v1/dashboard/today", accessToken); assertThat(response.statusCode()).isEqualTo(200); assertThat(JsonPath.>read(response.body(), "$.priority_tasks")).hasSize(2); + assertThat(JsonPath.read(response.body(), "$.summary_counts.pending_approval").longValue()) + .isEqualTo(1); + assertThat(JsonPath.read(response.body(), "$.approval_count").longValue()) + .isEqualTo(1); } @Test @@ -140,6 +146,28 @@ void dateParameterOverridesServerToday() throws Exception { assertThat(response.statusCode()).isEqualTo(200); } + @Test + void timezoneParameterIsAccepted() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?timezone=Asia/Seoul", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + } + + @Test + void invalidTimezoneReturnsClientError() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?timezone=Not/AValidZone", accessToken + ); + + assertThat(response.statusCode()).isNotEqualTo(500); + } + private String registerWorker(String accessToken, String displayName) throws Exception { String body = """ {"display_name": "%s"} @@ -166,6 +194,37 @@ private String createTask(String accessToken, String workerId, String title) thr return JsonPath.read(response.body(), "$.task_id"); } + private void requestReview(String accessToken, String taskId, String workerId) throws Exception { + HttpResponse taskResponse = authorizedGet("/api/v1/tasks/" + taskId, accessToken); + List checklistIds = JsonPath.read(taskResponse.body(), "$.checklist_items[*].checklist_item_id"); + for (String checklistId : checklistIds) { + HttpResponse checked = sendJson( + "/api/v1/tasks/" + taskId + "/checklist-items/" + checklistId, + """ + {"completed":true,"expected_version":0,"expected_task_version":0} + """, + accessToken, + "PATCH" + ); + assertThat(checked.statusCode()).as("body: %s", checked.body()).isEqualTo(200); + } + HttpResponse approvalRequest = sendJson( + "/api/v1/tasks/" + taskId + "/approval-requests", + """ + { + "expected_version":0, + "ai_snapshot":null, + "hr_snapshot":{"worker_id":"%s"}, + "changed_fields":[], + "source_versions":{"workflow_catalog_version":"0.2.0"} + } + """.formatted(workerId), + accessToken, + "POST" + ); + assertThat(approvalRequest.statusCode()).as("body: %s", approvalRequest.body()).isEqualTo(201); + } + private void insertCompany(UUID companyId, String name) { jdbcTemplate.update( """ @@ -215,9 +274,14 @@ private HttpResponse authorizedGet(String path, String accessToken) thro } private HttpResponse postJson(String path, String body, String accessToken) throws Exception { + return sendJson(path, body, accessToken, "POST"); + } + + private HttpResponse sendJson(String path, String body, String accessToken, String method) + throws Exception { HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(uri(path)) .header(HttpHeaders.CONTENT_TYPE, "application/json") - .POST(HttpRequest.BodyPublishers.ofString(body)); + .method(method, HttpRequest.BodyPublishers.ofString(body)); if (accessToken != null) { requestBuilder.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); }