From f22a50fb5ca18ae6349f52f1c3ad5da69f71bc4d Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 01:51:00 +0900 Subject: [PATCH 01/24] =?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 02/24] =?UTF-8?q?feat(task):=20TaskRepository=EC=97=90=20f?= =?UTF-8?q?indOpenTasks,=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 03/24] =?UTF-8?q?feat(dashboard):=20DashboardTaskSummaryRe?= =?UTF-8?q?sponse=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 04/24] =?UTF-8?q?feat(dashboard):=20DashboardQueryService?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80,=20due-today=EB=A5=BC=20=EC=97=B4?= =?UTF-8?q?=EB=A6=B0=20Task=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=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 05/24] =?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 06/24] =?UTF-8?q?test(dashboard):=20DashboardSecurityInteg?= =?UTF-8?q?rationTest=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 07/24] =?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); } From a3cce845b0406aec66f9cf7fc05b050a9ce38bf9 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 06:14:25 +0900 Subject: [PATCH 08/24] =?UTF-8?q?feat(dashboard):=20UpcomingExpiryCategory?= =?UTF-8?q?,=20UpcomingExpiryItemResponse=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/api/UpcomingExpiryCategory.java | 9 +++ .../api/UpcomingExpiryItemResponse.java | 65 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryCategory.java create mode 100644 src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryItemResponse.java diff --git a/src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryCategory.java b/src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryCategory.java new file mode 100644 index 00000000..9e4d2c51 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryCategory.java @@ -0,0 +1,9 @@ +package com.fowoco.server.dashboard.api; + +public enum UpcomingExpiryCategory { + STAY_EXPIRY, + CONTRACT_END, + EMPLOYMENT_PERMIT_END, + EMPLOYMENT_ACTIVITY_END, + DOCUMENT_EXPIRY +} diff --git a/src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryItemResponse.java b/src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryItemResponse.java new file mode 100644 index 00000000..f137ccb1 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryItemResponse.java @@ -0,0 +1,65 @@ +package com.fowoco.server.dashboard.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.worker.domain.DocumentType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.UUID; + +@Schema(name = "UpcomingExpiryItemResponse", description = "7일 이내 체류·계약·서류 만료 항목") +public final class UpcomingExpiryItemResponse { + + @JsonProperty("worker_id") + @Schema(name = "worker_id", format = "uuid") + private final UUID workerId; + + @JsonProperty("display_name") + @Schema(name = "display_name") + private final String displayName; + + @JsonProperty("category") + @Schema(name = "category", description = "만료 종류") + private final UpcomingExpiryCategory category; + + @JsonProperty("expiry_date") + @Schema(name = "expiry_date", format = "date") + private final LocalDate expiryDate; + + @JsonProperty("document_type") + @Schema(name = "document_type", description = "category가 DOCUMENT_EXPIRY일 때만 값이 있음") + private final DocumentType documentType; + + public UpcomingExpiryItemResponse( + UUID workerId, + String displayName, + UpcomingExpiryCategory category, + LocalDate expiryDate, + DocumentType documentType + ) { + this.workerId = workerId; + this.displayName = displayName; + this.category = category; + this.expiryDate = expiryDate; + this.documentType = documentType; + } + + public UUID getWorkerId() { + return workerId; + } + + public String getDisplayName() { + return displayName; + } + + public UpcomingExpiryCategory getCategory() { + return category; + } + + public LocalDate getExpiryDate() { + return expiryDate; + } + + public DocumentType getDocumentType() { + return documentType; + } +} From 3bf037ffe77046d63bddb444236972a300c2ba62 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 07:58:09 +0900 Subject: [PATCH 09/24] =?UTF-8?q?feat(dashboard):=20upcoming=5F7=5Fdays=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(Worker=204=EA=B0=9C=20=EB=82=A0=EC=A7=9C=20+=20WorkerDocument?= =?UTF-8?q?=20=EB=A7=8C=EB=A3=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/api/DashboardTodayResponse.java | 10 +++ .../application/DashboardQueryService.java | 89 ++++++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java index ce2e08df..1efca4b9 100644 --- a/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java @@ -15,6 +15,10 @@ public final class DashboardTodayResponse { @Schema(name = "priority_tasks", description = "오늘의 우선 업무 (열린 업무, 마감일 순 최대 5건)") private final List priorityTasks; + @JsonProperty("upcoming_7_days") + @Schema(name = "upcoming_7_days", description = "향후 7일 체류·계약·서류 만료 요약") + private final List upcoming7Days; + @JsonProperty("approval_count") @Schema(name = "approval_count", description = "승인 대기 개수 (summary_counts.pending_approval과 동일)") private final long approvalCount; @@ -26,11 +30,13 @@ public final class DashboardTodayResponse { public DashboardTodayResponse( DashboardSummaryCountsResponse summaryCounts, List priorityTasks, + List upcoming7Days, long approvalCount, long workerResponseCount ) { this.summaryCounts = summaryCounts; this.priorityTasks = priorityTasks; + this.upcoming7Days = upcoming7Days; this.approvalCount = approvalCount; this.workerResponseCount = workerResponseCount; } @@ -43,6 +49,10 @@ public List getPriorityTasks() { return priorityTasks; } + public List getUpcoming7Days() { + return upcoming7Days; + } + public long getApprovalCount() { return approvalCount; } 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 98d78d0f..7b63bbf8 100644 --- a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java +++ b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java @@ -6,16 +6,27 @@ 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.api.UpcomingExpiryCategory; +import com.fowoco.server.dashboard.api.UpcomingExpiryItemResponse; 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 com.fowoco.server.worker.application.WorkerDocumentSearchQuery; +import com.fowoco.server.worker.application.WorkerSearchQuery; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; +import com.fowoco.server.worker.domain.Worker; +import com.fowoco.server.worker.domain.WorkerDocument; import java.time.Clock; import java.time.DateTimeException; import java.time.LocalDate; import java.time.ZoneId; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,17 +34,24 @@ public class DashboardQueryService { private static final int PRIORITY_TASK_LIMIT = 5; + private static final int UPCOMING_DAYS = 7; private final TaskRepository taskRepository; + private final WorkerRepository workerRepository; + private final WorkerDocumentRepository workerDocumentRepository; private final TenantDatabaseContext tenantDatabaseContext; private final Clock clock; public DashboardQueryService( TaskRepository taskRepository, + WorkerRepository workerRepository, + WorkerDocumentRepository workerDocumentRepository, TenantDatabaseContext tenantDatabaseContext, Clock clock ) { this.taskRepository = taskRepository; + this.workerRepository = workerRepository; + this.workerDocumentRepository = workerDocumentRepository; this.tenantDatabaseContext = tenantDatabaseContext; this.clock = clock; } @@ -44,6 +62,7 @@ public DashboardTodayResponse today(ActorContext actor, LocalDate date, String t UUID companyId = actor.companyId(); Clock effectiveClock = timezone != null ? clock.withZone(parseTimezone(timezone)) : clock; LocalDate targetDate = date != null ? date : LocalDate.now(effectiveClock); + LocalDate windowEnd = targetDate.plusDays(UPCOMING_DAYS); long pendingApproval = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.READY_FOR_REVIEW); long needsInfo = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.NEEDS_INFO); @@ -59,7 +78,75 @@ public DashboardTodayResponse today(ActorContext actor, LocalDate date, String t .map(DashboardTaskSummaryResponse::from) .toList(); - return new DashboardTodayResponse(summaryCounts, priorityTasks, pendingApproval, workerResponse); + List upcoming7Days = collectUpcomingExpiry(companyId, windowEnd); + + return new DashboardTodayResponse(summaryCounts, priorityTasks, upcoming7Days, pendingApproval, workerResponse); + } + + private List collectUpcomingExpiry(UUID companyId, LocalDate windowEnd) { + List result = new ArrayList<>(); + + List stayExpiringWorkers = workerRepository.findPage( + companyId, + new WorkerSearchQuery(null, null, windowEnd, null, null, null, 0, 100) + ); + List contractEndingWorkers = workerRepository.findPage( + companyId, + new WorkerSearchQuery(null, null, null, windowEnd, null, null, 0, 100) + ); + List permitEndingWorkers = workerRepository.findPage( + companyId, + new WorkerSearchQuery(null, null, null, null, windowEnd, null, 0, 100) + ); + List activityEndingWorkers = workerRepository.findPage( + companyId, + new WorkerSearchQuery(null, null, null, null, null, windowEnd, 0, 100) + ); + + addWorkerExpiry(result, stayExpiringWorkers, UpcomingExpiryCategory.STAY_EXPIRY, Worker::stayExpiryDate); + addWorkerExpiry(result, contractEndingWorkers, UpcomingExpiryCategory.CONTRACT_END, Worker::contractEndDate); + addWorkerExpiry(result, permitEndingWorkers, UpcomingExpiryCategory.EMPLOYMENT_PERMIT_END, Worker::employmentPermitEndDate); + addWorkerExpiry(result, activityEndingWorkers, UpcomingExpiryCategory.EMPLOYMENT_ACTIVITY_END, Worker::employmentActivityEndDate); + + List expiringDocuments = workerDocumentRepository.findPage( + companyId, + new WorkerDocumentSearchQuery(null, null, null, null, windowEnd, 0, 100) + ); + Map workerNames = expiringDocuments.isEmpty() + ? Map.of() + : workerRepository.findAllByWorkerIdsAndCompanyId( + expiringDocuments.stream().map(WorkerDocument::workerId).collect(Collectors.toSet()), + companyId + ).stream().collect(Collectors.toMap(Worker::workerId, Worker::displayName)); + + for (WorkerDocument document : expiringDocuments) { + result.add(new UpcomingExpiryItemResponse( + document.workerId(), + workerNames.get(document.workerId()), + UpcomingExpiryCategory.DOCUMENT_EXPIRY, + document.expiryDate(), + document.documentType() + )); + } + + return result; + } + + private void addWorkerExpiry( + List result, + List workers, + UpcomingExpiryCategory category, + java.util.function.Function dateExtractor + ) { + for (Worker worker : workers) { + result.add(new UpcomingExpiryItemResponse( + worker.workerId(), + worker.displayName(), + category, + dateExtractor.apply(worker), + null + )); + } } private ZoneId parseTimezone(String timezone) { From 6eeb6aa02156601635e36749647879caddc72f0f Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 08:24:24 +0900 Subject: [PATCH 10/24] =?UTF-8?q?test(dashboard):=20upcoming=5F7=5Fdays=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=202=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DashboardSecurityIntegrationTest.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java index 8b10e107..f68cb745 100644 --- a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java @@ -157,6 +157,46 @@ void timezoneParameterIsAccepted() throws Exception { assertThat(response.statusCode()).isEqualTo(200); } + @Test + void upcoming7DaysIncludesWorkerWithNearExpiry() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + registerWorkerWithStayExpiry(accessToken, "체류만료임박근로자", "2026-08-12"); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?date=2026-08-08", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + java.util.List upcoming = JsonPath.read(response.body(), "$.upcoming_7_days"); + assertThat(upcoming).isNotEmpty(); + List categories = JsonPath.read(response.body(), "$.upcoming_7_days[*].category"); + assertThat(categories).contains("STAY_EXPIRY"); + } + + @Test + void upcoming7DaysExcludesWorkerWithFarExpiry() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + registerWorkerWithStayExpiry(accessToken, "체류만료여유근로자", "2026-12-31"); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?date=2026-08-08", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + List names = JsonPath.read(response.body(), "$.upcoming_7_days[*].display_name"); + assertThat(names).doesNotContain("체류만료여유근로자"); + } + + private String registerWorkerWithStayExpiry(String accessToken, String displayName, String stayExpiryDate) + throws Exception { + String body = """ + {"display_name": "%s", "stay_expiry_date": "%s"} + """.formatted(displayName, stayExpiryDate); + HttpResponse response = postJson("/api/v1/workers", body, accessToken); + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_id"); + } + @Test void invalidTimezoneReturnsClientError() throws Exception { String accessToken = accessToken(login(HR_A_EMAIL)); From dec7a42e5f90c940ea0a8e9d4b4c54cd4716f050 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 09:12:25 +0900 Subject: [PATCH 11/24] =?UTF-8?q?fix(dashboard):=20upcoming=5F7=5Fdays?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=B4=EB=AF=B8=20=EC=A7=80=EB=82=9C=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=EC=9D=BC=20=EC=A0=9C=EC=99=B8,=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=B3=84/=EA=B3=BC=EA=B1=B0?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/DashboardQueryService.java | 10 +-- .../DashboardSecurityIntegrationTest.java | 70 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) 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 7b63bbf8..cccaaa9a 100644 --- a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java +++ b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java @@ -62,7 +62,7 @@ public DashboardTodayResponse today(ActorContext actor, LocalDate date, String t UUID companyId = actor.companyId(); Clock effectiveClock = timezone != null ? clock.withZone(parseTimezone(timezone)) : clock; LocalDate targetDate = date != null ? date : LocalDate.now(effectiveClock); - LocalDate windowEnd = targetDate.plusDays(UPCOMING_DAYS); + LocalDate windowEnd = targetDate.plusDays(UPCOMING_DAYS + 1); long pendingApproval = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.READY_FOR_REVIEW); long needsInfo = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.NEEDS_INFO); @@ -78,12 +78,12 @@ public DashboardTodayResponse today(ActorContext actor, LocalDate date, String t .map(DashboardTaskSummaryResponse::from) .toList(); - List upcoming7Days = collectUpcomingExpiry(companyId, windowEnd); + List upcoming7Days = collectUpcomingExpiry(companyId, targetDate, windowEnd); return new DashboardTodayResponse(summaryCounts, priorityTasks, upcoming7Days, pendingApproval, workerResponse); } - private List collectUpcomingExpiry(UUID companyId, LocalDate windowEnd) { + private List collectUpcomingExpiry(UUID companyId, LocalDate windowStart, LocalDate windowEnd) { List result = new ArrayList<>(); List stayExpiringWorkers = workerRepository.findPage( @@ -129,7 +129,9 @@ private List collectUpcomingExpiry(UUID companyId, L )); } - return result; + return result.stream() + .filter(item -> !item.getExpiryDate().isBefore(windowStart)) + .toList(); } private void addWorkerExpiry( diff --git a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java index f68cb745..c4943833 100644 --- a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java @@ -74,6 +74,7 @@ void seedCompaniesAndUsers() { @BeforeEach void resetTaskState() { + jdbcTemplate.update("DELETE FROM worker_document"); jdbcTemplate.update("DELETE FROM task_checklist_item"); jdbcTemplate.update("DELETE FROM task_transition_history"); jdbcTemplate.update("DELETE FROM approval_request"); @@ -187,6 +188,75 @@ void upcoming7DaysExcludesWorkerWithFarExpiry() throws Exception { assertThat(names).doesNotContain("체류만료여유근로자"); } + @Test + void upcoming7DaysIncludesAllExpiryCategories() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + registerWorkerWithAllExpiryFields( + accessToken, "전체만료임박근로자", + "2026-08-12", "2026-08-13", "2026-08-14", "2026-08-11" + ); + String workerId = registerWorker(accessToken, "서류만료임박근로자"); + registerDocumentWithExpiry(accessToken, workerId, "2026-08-10"); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?date=2026-08-08", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + List categories = JsonPath.read(response.body(), "$.upcoming_7_days[*].category"); + assertThat(categories) + .contains("STAY_EXPIRY", "CONTRACT_END", "EMPLOYMENT_PERMIT_END", + "EMPLOYMENT_ACTIVITY_END", "DOCUMENT_EXPIRY"); + } + + @Test + void upcoming7DaysExcludesAlreadyPastExpiry() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + registerWorkerWithStayExpiry(accessToken, "이미지난만료근로자", "2026-07-01"); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?date=2026-08-08", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + List names = JsonPath.read(response.body(), "$.upcoming_7_days[*].display_name"); + assertThat(names).doesNotContain("이미지난만료근로자"); + } + + private String registerWorkerWithAllExpiryFields( + String accessToken, + String displayName, + String stayExpiryDate, + String contractEndDate, + String employmentPermitEndDate, + String employmentActivityEndDate + ) throws Exception { + String body = """ + { + "display_name": "%s", + "stay_expiry_date": "%s", + "contract_end_date": "%s", + "employment_permit_end_date": "%s", + "employment_activity_end_date": "%s" + } + """.formatted(displayName, stayExpiryDate, contractEndDate, + employmentPermitEndDate, employmentActivityEndDate); + HttpResponse response = postJson("/api/v1/workers", body, accessToken); + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_id"); + } + + private void registerDocumentWithExpiry(String accessToken, String workerId, String expiryDate) + throws Exception { + String body = """ + {"document_type": "PASSPORT_COPY", "submission_status": "SUBMITTED", "expiry_date": "%s"} + """.formatted(expiryDate); + HttpResponse response = postJson( + "/api/v1/workers/" + workerId + "/documents", body, accessToken + ); + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + } + private String registerWorkerWithStayExpiry(String accessToken, String displayName, String stayExpiryDate) throws Exception { String body = """ From fbf6d6f59728bef0e426ae31fe9bbd1f9ac24b7a Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 19:02:19 +0900 Subject: [PATCH 12/24] =?UTF-8?q?feat(dashboard):=20recommendations=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=20=EC=A0=84=EC=9A=A9=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DashboardRecommendationItemResponse.java | 43 +++++++++++++++ .../api/DashboardRecommendationsResponse.java | 53 +++++++++++++++++++ .../dashboard/api/DashboardTodayResponse.java | 10 ++++ .../application/DashboardQueryService.java | 43 ++++++++++++++- .../task/application/port/TaskRepository.java | 1 + .../persistence/JpaTaskRepository.java | 5 ++ .../SpringDataTaskJpaRepository.java | 9 ++++ 7 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/fowoco/server/dashboard/api/DashboardRecommendationItemResponse.java create mode 100644 src/main/java/com/fowoco/server/dashboard/api/DashboardRecommendationsResponse.java diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardRecommendationItemResponse.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardRecommendationItemResponse.java new file mode 100644 index 00000000..e7b593f6 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardRecommendationItemResponse.java @@ -0,0 +1,43 @@ +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.util.UUID; + +@Schema(name = "DashboardRecommendationItemResponse", description = "Agent가 준비한 업무 항목") +public final class DashboardRecommendationItemResponse { + + @JsonProperty("task_id") + @Schema(name = "task_id", format = "uuid") + private final UUID taskId; + + @JsonProperty("title") + private final String title; + + @JsonProperty("status") + private final TaskStatus status; + + private DashboardRecommendationItemResponse(UUID taskId, String title, TaskStatus status) { + this.taskId = taskId; + this.title = title; + this.status = status; + } + + public static DashboardRecommendationItemResponse from(Task task) { + return new DashboardRecommendationItemResponse(task.taskId(), task.title(), task.status()); + } + + public UUID getTaskId() { + return taskId; + } + + public String getTitle() { + return title; + } + + public TaskStatus getStatus() { + return status; + } +} diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardRecommendationsResponse.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardRecommendationsResponse.java new file mode 100644 index 00000000..0b2cc1f5 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardRecommendationsResponse.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 = "DashboardRecommendationsResponse", description = "Agent가 준비한 내용 묶음") +public final class DashboardRecommendationsResponse { + + @JsonProperty("connected_count") + @Schema(name = "connected_count", description = "연결된 전체 열린 업무 개수") + private final long connectedCount; + + @JsonProperty("prepared") + @Schema(name = "prepared", description = "Agent가 생성한 초안 (AI_CANDIDATE 소스, DRAFT 상태)") + private final List prepared; + + @JsonProperty("review") + @Schema(name = "review", description = "담당자 확인 필요 (NEEDS_INFO 또는 READY_FOR_REVIEW)") + private final List review; + + @JsonProperty("after_approval") + @Schema(name = "after_approval", description = "응답·기관 대기 (WAITING_WORKER 또는 WAITING_EXTERNAL)") + private final List afterApproval; + + public DashboardRecommendationsResponse( + long connectedCount, + List prepared, + List review, + List afterApproval + ) { + this.connectedCount = connectedCount; + this.prepared = prepared; + this.review = review; + this.afterApproval = afterApproval; + } + + public long getConnectedCount() { + return connectedCount; + } + + public List getPrepared() { + return prepared; + } + + public List getReview() { + return review; + } + + public List getAfterApproval() { + return afterApproval; + } +} diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java index 1efca4b9..30ed4aef 100644 --- a/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java @@ -19,6 +19,10 @@ public final class DashboardTodayResponse { @Schema(name = "upcoming_7_days", description = "향후 7일 체류·계약·서류 만료 요약") private final List upcoming7Days; + @JsonProperty("recommendations") + @Schema(name = "recommendations", description = "Agent가 준비한 내용 (실행 전 초안)") + private final DashboardRecommendationsResponse recommendations; + @JsonProperty("approval_count") @Schema(name = "approval_count", description = "승인 대기 개수 (summary_counts.pending_approval과 동일)") private final long approvalCount; @@ -31,12 +35,14 @@ public DashboardTodayResponse( DashboardSummaryCountsResponse summaryCounts, List priorityTasks, List upcoming7Days, + DashboardRecommendationsResponse recommendations, long approvalCount, long workerResponseCount ) { this.summaryCounts = summaryCounts; this.priorityTasks = priorityTasks; this.upcoming7Days = upcoming7Days; + this.recommendations = recommendations; this.approvalCount = approvalCount; this.workerResponseCount = workerResponseCount; } @@ -53,6 +59,10 @@ public List getUpcoming7Days() { return upcoming7Days; } + public DashboardRecommendationsResponse getRecommendations() { + return recommendations; + } + public long getApprovalCount() { return approvalCount; } 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 cccaaa9a..9b6d01e8 100644 --- a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java +++ b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java @@ -6,6 +6,8 @@ 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.api.DashboardRecommendationItemResponse; +import com.fowoco.server.dashboard.api.DashboardRecommendationsResponse; import com.fowoco.server.dashboard.api.UpcomingExpiryCategory; import com.fowoco.server.dashboard.api.UpcomingExpiryItemResponse; import com.fowoco.server.dashboard.application.error.DashboardErrorCode; @@ -79,8 +81,47 @@ public DashboardTodayResponse today(ActorContext actor, LocalDate date, String t .toList(); List upcoming7Days = collectUpcomingExpiry(companyId, targetDate, windowEnd); + DashboardRecommendationsResponse recommendations = collectRecommendations(companyId); - return new DashboardTodayResponse(summaryCounts, priorityTasks, upcoming7Days, pendingApproval, workerResponse); + return new DashboardTodayResponse( + summaryCounts, priorityTasks, upcoming7Days, recommendations, pendingApproval, workerResponse + ); + } + + private DashboardRecommendationsResponse collectRecommendations(UUID companyId) { + List preparedTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria( + companyId, TaskStatus.DRAFT, null, null, com.fowoco.server.task.domain.TaskSource.AI_CANDIDATE, + null, null, null, null, null, 0, 100 + )).items(); + + List needsInfoTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria( + companyId, TaskStatus.NEEDS_INFO, null, null, null, null, null, null, null, null, 0, 100 + )).items(); + List readyTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria( + companyId, TaskStatus.READY_FOR_REVIEW, null, null, null, null, null, null, null, null, 0, 100 + )).items(); + List reviewTasks = new ArrayList<>(); + reviewTasks.addAll(needsInfoTasks); + reviewTasks.addAll(readyTasks); + + List waitingWorkerTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria( + companyId, TaskStatus.WAITING_WORKER, null, null, null, null, null, null, null, null, 0, 100 + )).items(); + List waitingExternalTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria( + companyId, TaskStatus.WAITING_EXTERNAL, null, null, null, null, null, null, null, null, 0, 100 + )).items(); + List afterApprovalTasks = new ArrayList<>(); + afterApprovalTasks.addAll(waitingWorkerTasks); + afterApprovalTasks.addAll(waitingExternalTasks); + + long connectedCount = taskRepository.countOpenTasksByCompanyId(companyId); + + return new DashboardRecommendationsResponse( + connectedCount, + preparedTasks.stream().map(DashboardRecommendationItemResponse::from).toList(), + reviewTasks.stream().map(DashboardRecommendationItemResponse::from).toList(), + afterApprovalTasks.stream().map(DashboardRecommendationItemResponse::from).toList() + ); } private List collectUpcomingExpiry(UUID companyId, LocalDate windowStart, LocalDate windowEnd) { 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 515b34ff..18bb6033 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 @@ -16,6 +16,7 @@ public interface TaskRepository { List findOpenTasks(UUID companyId, int limit); long countByCompanyIdAndStatus(UUID companyId, TaskStatus status); long countOpenTasksDueOn(UUID companyId, LocalDate dueDate); + long countOpenTasksByCompanyId(UUID companyId); 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 602a8e57..3c78bec1 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 @@ -73,6 +73,11 @@ public long countOpenTasksDueOn(UUID companyId, LocalDate dueDate) { return repository.countOpenTasksDueOn(companyId, dueDate); } + @Override + public long countOpenTasksByCompanyId(UUID companyId) { + return repository.countOpenTasksByCompanyId(companyId); + } + @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 b973a22d..e4851abd 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 @@ -73,4 +73,13 @@ SELECT COUNT(task) AND task.status <> com.fowoco.server.task.domain.TaskStatus.CANCELLED """) long countOpenTasksDueOn(@Param("companyId") UUID companyId, @Param("dueDate") LocalDate dueDate); + + @Query(""" + SELECT COUNT(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 + """) + long countOpenTasksByCompanyId(@Param("companyId") UUID companyId); } From 2bbe5cd85df724e60307c3bdfd275ddf2807cfb2 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 19:49:39 +0900 Subject: [PATCH 13/24] =?UTF-8?q?test(dashboard):=20recommendations=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=203=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DashboardSecurityIntegrationTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java index c4943833..67b5c8f1 100644 --- a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java @@ -223,6 +223,45 @@ void upcoming7DaysExcludesAlreadyPastExpiry() throws Exception { assertThat(names).doesNotContain("이미지난만료근로자"); } + @Test + void recommendationsConnectedCountMatchesOpenTasks() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(accessToken, "추천테스트근로자"); + createTask(accessToken, workerId, "추천테스트업무1"); + createTask(accessToken, workerId, "추천테스트업무2"); + + HttpResponse response = authorizedGet("/api/v1/dashboard/today", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(response.body(), "$.recommendations.connected_count").longValue()) + .isEqualTo(2); + } + + @Test + void recommendationsReviewIncludesReadyForReviewTask() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(accessToken, "승인대기추천근로자"); + String taskId = createTask(accessToken, workerId, "승인대기업무"); + requestReview(accessToken, taskId, workerId); + + HttpResponse response = authorizedGet("/api/v1/dashboard/today", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + List reviewTitles = JsonPath.read(response.body(), "$.recommendations.review[*].title"); + assertThat(reviewTitles).contains("승인대기업무"); + } + + @Test + void recommendationsPreparedAndAfterApprovalAreEmptyByDefault() 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(), "$.recommendations.prepared")).isEmpty(); + assertThat(JsonPath.>read(response.body(), "$.recommendations.after_approval")).isEmpty(); + } + private String registerWorkerWithAllExpiryFields( String accessToken, String displayName, From 5d3750b916af4b5e2e879152166ef4c002c48b25 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 20:30:21 +0900 Subject: [PATCH 14/24] =?UTF-8?q?fix(dashboard):=20timezone=20=EC=83=9D?= =?UTF-8?q?=EB=9E=B5=20=EC=8B=9C=20UTC=20=EB=8C=80=EC=8B=A0=20Asia/Seoul?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=82=AC=EC=9A=A9=20(?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20=EA=B2=80=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/DashboardQueryService.java | 3 +- .../DashboardSecurityIntegrationTest.java | 16 ++ .../DashboardQueryServiceTest.java | 167 ++++++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/fowoco/server/dashboard/application/DashboardQueryServiceTest.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 9b6d01e8..f258979f 100644 --- a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java +++ b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java @@ -62,7 +62,8 @@ 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(parseTimezone(timezone)) : clock; + ZoneId effectiveZone = timezone != null ? parseTimezone(timezone) : ZoneId.of("Asia/Seoul"); + Clock effectiveClock = clock.withZone(effectiveZone); LocalDate targetDate = date != null ? date : LocalDate.now(effectiveClock); LocalDate windowEnd = targetDate.plusDays(UPCOMING_DAYS + 1); diff --git a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java index 67b5c8f1..a279d545 100644 --- a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java @@ -158,6 +158,22 @@ void timezoneParameterIsAccepted() throws Exception { assertThat(response.statusCode()).isEqualTo(200); } + @Test + void dueTodayUsesKoreaTimeWhenTimezoneOmitted() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(accessToken, "타임존테스트근로자"); + createTask(accessToken, workerId, "한국시간마감업무"); + + // date를 명시적으로 지정하지 않고, timezone도 생략 + // 서버가 한국 시간 기준으로 "오늘"을 계산해야 정상 + HttpResponse response = authorizedGet("/api/v1/dashboard/today", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + // 이 테스트는 실제 UTC vs KST 자정~9시 사이 시간대 버그를 + // 완벽히 재현하진 못하지만(테스트 실행 시각에 의존), + // 최소한 API가 정상 응답하는지 확인 + } + @Test void upcoming7DaysIncludesWorkerWithNearExpiry() throws Exception { String accessToken = accessToken(login(HR_A_EMAIL)); diff --git a/src/test/java/com/fowoco/server/dashboard/application/DashboardQueryServiceTest.java b/src/test/java/com/fowoco/server/dashboard/application/DashboardQueryServiceTest.java new file mode 100644 index 00000000..c6fa5d39 --- /dev/null +++ b/src/test/java/com/fowoco/server/dashboard/application/DashboardQueryServiceTest.java @@ -0,0 +1,167 @@ +package com.fowoco.server.dashboard.application; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class DashboardQueryServiceTest { + + private static final UUID COMPANY_ID = UUID.fromString("50000000-0000-0000-0000-000000000001"); + + // UTC 기준 2026-08-07T18:00:00Z = 한국 시간(KST, UTC+9) 2026-08-08T03:00:00 + // 즉 한국은 이미 8월 8일이지만, UTC는 아직 8월 7일인 시점 + private static final Clock FIXED_UTC_CLOCK = + Clock.fixed(Instant.parse("2026-08-07T18:00:00Z"), ZoneOffset.UTC); + + @Test + void dueTodayUsesKoreaDateWhenTimezoneOmitted() { + FakeTaskRepository taskRepository = new FakeTaskRepository(); + DashboardQueryService service = new DashboardQueryService( + taskRepository, + new NoopWorkerRepository(), + new NoopWorkerDocumentRepository(), + new NoopTenantDatabaseContext(), + FIXED_UTC_CLOCK + ); + + service.today(actorContext(), null, null); + + // timezone을 생략했으니, "오늘"은 한국 날짜(2026-08-08)로 계산되어야 한다. + // UTC 그대로였다면 2026-08-07로 잘못 계산되었을 것이다. + assertThat(taskRepository.lastDueTodayDate).isEqualTo(LocalDate.of(2026, 8, 8)); + } + + private ActorContext actorContext() { + return new ActorContext(UUID.randomUUID(), COMPANY_ID, Set.of(UserRole.HR)); + } + + private static final class FakeTaskRepository implements TaskRepository { + private LocalDate lastDueTodayDate; + + @Override + public java.util.Optional findByIdAndCompanyId(UUID taskId, UUID companyId) { + throw new UnsupportedOperationException(); + } + + @Override + public TaskPage findAll(TaskSearchCriteria criteria) { + return new TaskPage(List.of(), 0, 0, 0, 0); + } + + @Override + public Task save(Task task) { + throw new UnsupportedOperationException(); + } + + @Override + public List findOpenTasks(UUID companyId, int limit) { + return List.of(); + } + + @Override + public long countByCompanyIdAndStatus(UUID companyId, TaskStatus status) { + return 0; + } + + @Override + public long countOpenTasksDueOn(UUID companyId, LocalDate dueDate) { + this.lastDueTodayDate = dueDate; + return 0; + } + + @Override + public long countOpenTasksByCompanyId(UUID companyId) { + return 0; + } + } + + private static final class NoopWorkerRepository implements WorkerRepository { + @Override + public void insert(com.fowoco.server.worker.domain.Worker worker) { + } + + @Override + public java.util.Optional findByWorkerIdAndCompanyId( + UUID workerId, UUID companyId) { + return java.util.Optional.empty(); + } + + @Override + public com.fowoco.server.worker.domain.Worker update(com.fowoco.server.worker.domain.Worker worker) { + throw new UnsupportedOperationException(); + } + + @Override + public List findPage( + UUID companyId, com.fowoco.server.worker.application.WorkerSearchQuery query) { + return List.of(); + } + + @Override + public long countPage(UUID companyId, com.fowoco.server.worker.application.WorkerSearchQuery query) { + return 0; + } + + @Override + public List findAllByWorkerIdsAndCompanyId( + Set workerIds, UUID companyId) { + return List.of(); + } + } + + private static final class NoopWorkerDocumentRepository implements WorkerDocumentRepository { + @Override + public void insert(com.fowoco.server.worker.domain.WorkerDocument document) { + } + + @Override + public java.util.Optional findByIdAndWorkerIdAndCompanyId( + UUID workerDocumentId, UUID workerId, UUID companyId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional findByIdAndCompanyId( + UUID workerDocumentId, UUID companyId) { + return java.util.Optional.empty(); + } + + @Override + public com.fowoco.server.worker.domain.WorkerDocument update( + com.fowoco.server.worker.domain.WorkerDocument document) { + throw new UnsupportedOperationException(); + } + + @Override + public List findPage( + UUID companyId, com.fowoco.server.worker.application.WorkerDocumentSearchQuery query) { + return List.of(); + } + + @Override + public long countPage(UUID companyId, com.fowoco.server.worker.application.WorkerDocumentSearchQuery query) { + return 0; + } + } + + private static final class NoopTenantDatabaseContext implements TenantDatabaseContext { + @Override + public void setCompanyIdForCurrentTransaction(UUID companyId) { + } + } +} From eeebe7a166c7f5f474ed4b8952ed9be03c627634 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 21:12:47 +0900 Subject: [PATCH 15/24] =?UTF-8?q?feat(notification):=20Notification=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8,=20V37,=20NotificationRepository=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(cursor=EA=B8=B0=EB=B0=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/NotificationRepository.java | 20 ++++ .../notification/domain/Notification.java | 107 ++++++++++++++++++ .../domain/NotificationTargetType.java | 7 ++ .../db/migration/V37__create_notification.sql | 14 +++ 4 files changed, 148 insertions(+) create mode 100644 src/main/java/com/fowoco/server/notification/application/port/NotificationRepository.java create mode 100644 src/main/java/com/fowoco/server/notification/domain/Notification.java create mode 100644 src/main/java/com/fowoco/server/notification/domain/NotificationTargetType.java create mode 100644 src/main/resources/db/migration/V37__create_notification.sql diff --git a/src/main/java/com/fowoco/server/notification/application/port/NotificationRepository.java b/src/main/java/com/fowoco/server/notification/application/port/NotificationRepository.java new file mode 100644 index 00000000..e628733c --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/port/NotificationRepository.java @@ -0,0 +1,20 @@ +package com.fowoco.server.notification.application.port; + +import com.fowoco.server.notification.domain.Notification; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface NotificationRepository { + + void insert(Notification notification); + + Notification update(Notification notification); + + Optional findByIdAndCompanyId(UUID notificationId, UUID companyId); + + List findPage(UUID companyId, boolean unreadOnly, Instant cursor, int size); + + long countUnread(UUID companyId); +} diff --git a/src/main/java/com/fowoco/server/notification/domain/Notification.java b/src/main/java/com/fowoco/server/notification/domain/Notification.java new file mode 100644 index 00000000..ffa84b22 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/domain/Notification.java @@ -0,0 +1,107 @@ +package com.fowoco.server.notification.domain; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public final class Notification { + + private final UUID notificationId; + private final UUID companyId; + private final NotificationTargetType targetType; + private final UUID targetId; + private final String route; + private final String title; + private final boolean read; + private final Instant occurredAt; + private final Instant createdAt; + + public Notification( + UUID notificationId, + UUID companyId, + NotificationTargetType targetType, + UUID targetId, + String route, + String title, + boolean read, + Instant occurredAt, + Instant createdAt + ) { + this.notificationId = Objects.requireNonNull(notificationId, "notificationId must not be null"); + this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); + this.targetType = Objects.requireNonNull(targetType, "targetType must not be null"); + this.targetId = Objects.requireNonNull(targetId, "targetId must not be null"); + this.route = requireNonBlank(route, "route"); + this.title = requireNonBlank(title, "title"); + this.read = read; + this.occurredAt = Objects.requireNonNull(occurredAt, "occurredAt must not be null"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null"); + } + + public static Notification create( + UUID notificationId, + UUID companyId, + NotificationTargetType targetType, + UUID targetId, + String route, + String title, + Instant occurredAt, + Instant now + ) { + return new Notification( + notificationId, companyId, targetType, targetId, route, title, false, occurredAt, now + ); + } + + public Notification markAsRead() { + if (read) { + return this; + } + return new Notification( + notificationId, companyId, targetType, targetId, route, title, true, occurredAt, createdAt + ); + } + + public UUID notificationId() { + return notificationId; + } + + public UUID companyId() { + return companyId; + } + + public NotificationTargetType targetType() { + return targetType; + } + + public UUID targetId() { + return targetId; + } + + public String route() { + return route; + } + + public String title() { + return title; + } + + public boolean read() { + return read; + } + + public Instant occurredAt() { + return occurredAt; + } + + public Instant createdAt() { + return createdAt; + } + + private static String requireNonBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value; + } +} diff --git a/src/main/java/com/fowoco/server/notification/domain/NotificationTargetType.java b/src/main/java/com/fowoco/server/notification/domain/NotificationTargetType.java new file mode 100644 index 00000000..ec501319 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/domain/NotificationTargetType.java @@ -0,0 +1,7 @@ +package com.fowoco.server.notification.domain; + +public enum NotificationTargetType { + TASK, + WORKER, + DOCUMENT +} diff --git a/src/main/resources/db/migration/V37__create_notification.sql b/src/main/resources/db/migration/V37__create_notification.sql new file mode 100644 index 00000000..0b5cdcb0 --- /dev/null +++ b/src/main/resources/db/migration/V37__create_notification.sql @@ -0,0 +1,14 @@ +CREATE TABLE notification ( + notification_id UUID PRIMARY KEY, + company_id UUID NOT NULL, + target_type VARCHAR(30) NOT NULL, + target_id UUID NOT NULL, + route VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + occurred_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE INDEX idx_notification_company_read_occurred + ON notification (company_id, is_read, occurred_at DESC); From 33437220988e849cdb62eeaa549d1b2a74a7df9c Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 21:24:29 +0900 Subject: [PATCH 16/24] =?UTF-8?q?feat(notification):=20NotificationJpaEnti?= =?UTF-8?q?ty=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/NotificationJpaEntity.java | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/main/java/com/fowoco/server/notification/infrastructure/persistence/NotificationJpaEntity.java diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/NotificationJpaEntity.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/NotificationJpaEntity.java new file mode 100644 index 00000000..20eaf62b --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/NotificationJpaEntity.java @@ -0,0 +1,102 @@ +package com.fowoco.server.notification.infrastructure.persistence; + +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.notification.domain.NotificationTargetType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "notification") +public class NotificationJpaEntity { + + @Id + @Column(name = "notification_id", nullable = false, updatable = false) + private UUID notificationId; + + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 30, updatable = false) + private NotificationTargetType targetType; + + @Column(name = "target_id", nullable = false, updatable = false) + private UUID targetId; + + @Column(name = "route", nullable = false, updatable = false) + private String route; + + @Column(name = "title", nullable = false, updatable = false) + private String title; + + @Column(name = "is_read", nullable = false) + private boolean read; + + @Column(name = "occurred_at", nullable = false, updatable = false) + private Instant occurredAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected NotificationJpaEntity() { + } + + private NotificationJpaEntity( + UUID notificationId, + UUID companyId, + NotificationTargetType targetType, + UUID targetId, + String route, + String title, + boolean read, + Instant occurredAt, + Instant createdAt + ) { + this.notificationId = notificationId; + this.companyId = companyId; + this.targetType = targetType; + this.targetId = targetId; + this.route = route; + this.title = title; + this.read = read; + this.occurredAt = occurredAt; + this.createdAt = createdAt; + } + + public static NotificationJpaEntity fromDomain(Notification notification) { + Objects.requireNonNull(notification, "notification must not be null"); + return new NotificationJpaEntity( + notification.notificationId(), + notification.companyId(), + notification.targetType(), + notification.targetId(), + notification.route(), + notification.title(), + notification.read(), + notification.occurredAt(), + notification.createdAt() + ); + } + + public Notification toDomain() { + return new Notification( + notificationId, companyId, targetType, targetId, route, title, read, occurredAt, createdAt + ); + } + + public void applyState(Notification notification) { + Objects.requireNonNull(notification, "notification must not be null"); + if (!notificationId.equals(notification.notificationId()) + || !companyId.equals(notification.companyId())) { + throw new IllegalArgumentException("immutable notification fields must not change"); + } + this.read = notification.read(); + } +} From d5608e72b022f993f0c72c830400c99ef2a9f3ca Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 21:25:34 +0900 Subject: [PATCH 17/24] =?UTF-8?q?feat(notification):=20JpaRepository=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20JpaNotificationRepository=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JpaNotificationRepository.java | 51 +++++++++++++++++++ .../SpringDataNotificationJpaRepository.java | 39 ++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java create mode 100644 src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java new file mode 100644 index 00000000..ace904b6 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java @@ -0,0 +1,51 @@ +package com.fowoco.server.notification.infrastructure.persistence; + +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaNotificationRepository implements NotificationRepository { + + private final SpringDataNotificationJpaRepository repository; + + public JpaNotificationRepository(SpringDataNotificationJpaRepository repository) { + this.repository = repository; + } + + @Override + public void insert(Notification notification) { + repository.save(NotificationJpaEntity.fromDomain(notification)); + } + + @Override + public Notification update(Notification notification) { + NotificationJpaEntity entity = repository + .findByIdAndCompanyId(notification.notificationId(), notification.companyId()) + .orElseThrow(() -> new IllegalStateException("notification not found for update")); + entity.applyState(notification); + return repository.saveAndFlush(entity).toDomain(); + } + + @Override + public Optional findByIdAndCompanyId(UUID notificationId, UUID companyId) { + return repository.findByIdAndCompanyId(notificationId, companyId).map(NotificationJpaEntity::toDomain); + } + + @Override + public List findPage(UUID companyId, boolean unreadOnly, Instant cursor, int size) { + return repository.findPage(companyId, unreadOnly, cursor, PageRequest.of(0, size)).stream() + .map(NotificationJpaEntity::toDomain) + .toList(); + } + + @Override + public long countUnread(UUID companyId) { + return repository.countByCompanyIdAndReadFalse(companyId); + } +} diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java new file mode 100644 index 00000000..bffe2f85 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java @@ -0,0 +1,39 @@ +package com.fowoco.server.notification.infrastructure.persistence; + +import java.time.Instant; +import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +interface SpringDataNotificationJpaRepository extends JpaRepository { + + @Query(""" + SELECT n + FROM NotificationJpaEntity n + WHERE n.notificationId = :notificationId + AND n.companyId = :companyId + """) + java.util.Optional findByIdAndCompanyId( + @Param("notificationId") UUID notificationId, + @Param("companyId") UUID companyId + ); + + @Query(""" + SELECT n + FROM NotificationJpaEntity n + WHERE n.companyId = :companyId + AND (:unreadOnly = false OR n.read = false) + AND (:cursor IS NULL OR n.occurredAt < :cursor) + ORDER BY n.occurredAt DESC + """) + java.util.List findPage( + @Param("companyId") UUID companyId, + @Param("unreadOnly") boolean unreadOnly, + @Param("cursor") Instant cursor, + Pageable pageable + ); + + long countByCompanyIdAndReadFalse(UUID companyId); +} From b236b1b4271c950f7cb571859bb5b30b64f7d9c1 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 22:37:43 +0900 Subject: [PATCH 18/24] =?UTF-8?q?feat(notification):=20error,=20service,?= =?UTF-8?q?=20Controller=20(GET=20/notifications,=20POST=20/notifications/?= =?UTF-8?q?{id}/read)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/NotificationController.java | 104 ++++++++ .../api/NotificationItemResponse.java | 91 +++++++ .../api/NotificationPageResponse.java | 38 +++ .../application/NotificationPageResult.java | 11 + .../application/NotificationService.java | 67 ++++++ .../error/NotificationErrorCode.java | 34 +++ .../NotificationSecurityIntegrationTest.java | 224 ++++++++++++++++++ 7 files changed, 569 insertions(+) create mode 100644 src/main/java/com/fowoco/server/notification/api/NotificationController.java create mode 100644 src/main/java/com/fowoco/server/notification/api/NotificationItemResponse.java create mode 100644 src/main/java/com/fowoco/server/notification/api/NotificationPageResponse.java create mode 100644 src/main/java/com/fowoco/server/notification/application/NotificationPageResult.java create mode 100644 src/main/java/com/fowoco/server/notification/application/NotificationService.java create mode 100644 src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java create mode 100644 src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java diff --git a/src/main/java/com/fowoco/server/notification/api/NotificationController.java b/src/main/java/com/fowoco/server/notification/api/NotificationController.java new file mode 100644 index 00000000..3518a808 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/api/NotificationController.java @@ -0,0 +1,104 @@ +package com.fowoco.server.notification.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.notification.application.NotificationPageResult; +import com.fowoco.server.notification.application.NotificationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.time.Instant; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Notification", description = "알림 조회·읽음 처리") +@RestController +@RequestMapping("/api/v1/notifications") +@SecurityRequirement(name = "bearerAuth") +@Validated +public class NotificationController { + + private final NotificationService notificationService; + private final ActorContextProvider actorContextProvider; + + public NotificationController( + NotificationService notificationService, + ActorContextProvider actorContextProvider + ) { + this.notificationService = notificationService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "listNotifications", + summary = "알림 목록 조회", + description = "상단 알림 패널에 승인·응답·기한·서류 알림과 이동 대상을 제공합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = NotificationPageResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden") + }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public NotificationPageResponse list( + @Parameter(description = "읽지 않은 알림만 조회") @RequestParam(required = false) Boolean unreadOnly, + @Parameter(description = "이전 페이지 마지막 항목의 occurred_at (다음 페이지 조회용)") + @RequestParam(required = false) Instant cursor, + @Parameter(description = "페이지당 항목 수 (1~100)") + @RequestParam(required = false) @Min(1) @Max(100) Integer size + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + NotificationPageResult result = notificationService.findPage(actor, unreadOnly, cursor, size); + return new NotificationPageResponse( + result.items().stream().map(NotificationItemResponse::from).toList(), + result.unreadCount(), + result.nextCursor() + ); + } + + @Operation( + operationId = "readNotification", + summary = "알림 읽음 처리", + description = "사용자가 확인한 알림을 읽음으로 기록합니다. 같은 요청을 반복해도 결과는 동일합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "처리 성공"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound") + }) + @PostMapping(path = "/{notificationId}/read") + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public ResponseEntity read( + @Parameter(description = "알림 ID") @PathVariable UUID notificationId + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + notificationService.markAsRead(notificationId, actor); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/fowoco/server/notification/api/NotificationItemResponse.java b/src/main/java/com/fowoco/server/notification/api/NotificationItemResponse.java new file mode 100644 index 00000000..66040af8 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/api/NotificationItemResponse.java @@ -0,0 +1,91 @@ +package com.fowoco.server.notification.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.notification.domain.NotificationTargetType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.UUID; + +@Schema(name = "NotificationItemResponse", description = "알림 항목") +public final class NotificationItemResponse { + + @JsonProperty("id") + @Schema(name = "id", format = "uuid") + private final UUID id; + + @JsonProperty("target_type") + @Schema(name = "target_type", description = "알림 대상 종류") + private final NotificationTargetType targetType; + + @JsonProperty("target_id") + @Schema(name = "target_id", format = "uuid") + private final UUID targetId; + + @JsonProperty("route") + @Schema(name = "route", description = "허용된 화면으로 이동할 안전한 내부 경로") + private final String route; + + @JsonProperty("title") + private final String title; + + @JsonProperty("read") + private final boolean read; + + @JsonProperty("occurred_at") + @Schema(name = "occurred_at", format = "date-time") + private final Instant occurredAt; + + private NotificationItemResponse( + UUID id, NotificationTargetType targetType, UUID targetId, + String route, String title, boolean read, Instant occurredAt + ) { + this.id = id; + this.targetType = targetType; + this.targetId = targetId; + this.route = route; + this.title = title; + this.read = read; + this.occurredAt = occurredAt; + } + + public static NotificationItemResponse from(Notification notification) { + return new NotificationItemResponse( + notification.notificationId(), + notification.targetType(), + notification.targetId(), + notification.route(), + notification.title(), + notification.read(), + notification.occurredAt() + ); + } + + public UUID getId() { + return id; + } + + public NotificationTargetType getTargetType() { + return targetType; + } + + public UUID getTargetId() { + return targetId; + } + + public String getRoute() { + return route; + } + + public String getTitle() { + return title; + } + + public boolean isRead() { + return read; + } + + public Instant getOccurredAt() { + return occurredAt; + } +} diff --git a/src/main/java/com/fowoco/server/notification/api/NotificationPageResponse.java b/src/main/java/com/fowoco/server/notification/api/NotificationPageResponse.java new file mode 100644 index 00000000..8800295e --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/api/NotificationPageResponse.java @@ -0,0 +1,38 @@ +package com.fowoco.server.notification.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(name = "NotificationPageResponse", description = "알림 목록 응답") +public final class NotificationPageResponse { + + @JsonProperty("items") + private final List items; + + @JsonProperty("unread_count") + @Schema(name = "unread_count", description = "읽지 않은 알림 개수") + private final long unreadCount; + + @JsonProperty("next_cursor") + @Schema(name = "next_cursor", description = "다음 페이지 조회용 커서 (없으면 마지막 페이지)") + private final String nextCursor; + + public NotificationPageResponse(List items, long unreadCount, String nextCursor) { + this.items = items; + this.unreadCount = unreadCount; + this.nextCursor = nextCursor; + } + + public List getItems() { + return items; + } + + public long getUnreadCount() { + return unreadCount; + } + + public String getNextCursor() { + return nextCursor; + } +} diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationPageResult.java b/src/main/java/com/fowoco/server/notification/application/NotificationPageResult.java new file mode 100644 index 00000000..218e7a49 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/NotificationPageResult.java @@ -0,0 +1,11 @@ +package com.fowoco.server.notification.application; + +import com.fowoco.server.notification.domain.Notification; +import java.util.List; + +public record NotificationPageResult( + List items, + long unreadCount, + String nextCursor +) { +} diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationService.java b/src/main/java/com/fowoco/server/notification/application/NotificationService.java new file mode 100644 index 00000000..e21d1f31 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/NotificationService.java @@ -0,0 +1,67 @@ +package com.fowoco.server.notification.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.notification.application.error.NotificationErrorCode; +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class NotificationService { + + private static final int MAX_PAGE_SIZE = 100; + private static final int DEFAULT_PAGE_SIZE = 20; + + private final NotificationRepository notificationRepository; + private final TenantDatabaseContext tenantDatabaseContext; + + public NotificationService( + NotificationRepository notificationRepository, + TenantDatabaseContext tenantDatabaseContext + ) { + this.notificationRepository = notificationRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + } + + @Transactional(readOnly = true) + public NotificationPageResult findPage(ActorContext actor, Boolean unreadOnly, Instant cursor, Integer size) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + UUID companyId = actor.companyId(); + int effectiveSize = normalizeSize(size); + + List items = notificationRepository.findPage( + companyId, unreadOnly != null && unreadOnly, cursor, effectiveSize + ); + long unreadCount = notificationRepository.countUnread(companyId); + String nextCursor = items.size() == effectiveSize && !items.isEmpty() + ? items.get(items.size() - 1).occurredAt().toString() + : null; + + return new NotificationPageResult(items, unreadCount, nextCursor); + } + + @Transactional + public void markAsRead(UUID notificationId, ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + Notification notification = notificationRepository + .findByIdAndCompanyId(notificationId, actor.companyId()) + .orElseThrow(() -> new ApiException(NotificationErrorCode.NOTIFICATION_NOT_FOUND)); + Notification updated = notification.markAsRead(); + if (updated != notification) { + notificationRepository.update(updated); + } + } + + private int normalizeSize(Integer size) { + if (size == null) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(Math.max(size, 1), MAX_PAGE_SIZE); + } +} diff --git a/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java b/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java new file mode 100644 index 00000000..23ba7b16 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java @@ -0,0 +1,34 @@ +package com.fowoco.server.notification.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum NotificationErrorCode implements ApiErrorCode { + NOTIFICATION_NOT_FOUND( + HttpStatus.NOT_FOUND, + "알림을 찾을 수 없습니다." + ); + + private final HttpStatus status; + private final String defaultMessage; + + NotificationErrorCode(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/notification/NotificationSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java new file mode 100644 index 00000000..f82c4cf7 --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java @@ -0,0 +1,224 @@ +package com.fowoco.server.notification; + +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.time.Instant; +import java.time.temporal.ChronoUnit; +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 NotificationSecurityIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("52000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("62000000-0000-0000-0000-000000000002"); + private static final UUID HR_A = UUID.fromString("53000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("63000000-0000-0000-0000-000000000002"); + private static final String HR_A_EMAIL = "hr.notification.a@example.com"; + private static final String HR_B_EMAIL = "hr.notification.b@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 notification"); + jdbcTemplate.update("DELETE FROM refresh_token"); + 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 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); + insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash); + } + + @BeforeEach + void resetNotificationState() { + jdbcTemplate.update("DELETE FROM notification"); + } + + @Test + void listReturnsItemsAndUnreadCount() throws Exception { + insertNotification(COMPANY_A, "TASK", false, Instant.now().minus(1, ChronoUnit.HOURS)); + insertNotification(COMPANY_A, "TASK", true, Instant.now().minus(2, ChronoUnit.HOURS)); + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).hasSize(2); + assertThat(JsonPath.read(response.body(), "$.unread_count").longValue()).isEqualTo(1); + } + + @Test + void unreadOnlyFiltersReadNotifications() throws Exception { + insertNotification(COMPANY_A, "TASK", false, Instant.now()); + insertNotification(COMPANY_A, "TASK", true, Instant.now()); + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications?unreadOnly=true", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).hasSize(1); + } + + @Test + void otherCompanyNotificationsAreNotVisible() throws Exception { + insertNotification(COMPANY_B, "TASK", false, Instant.now()); + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).isEmpty(); + } + + @Test + void readMarksNotificationAsReadAndIsIdempotent() throws Exception { + UUID notificationId = insertNotification(COMPANY_A, "TASK", false, Instant.now()); + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse firstRead = authorizedPost( + "/api/v1/notifications/" + notificationId + "/read", accessToken + ); + HttpResponse secondRead = authorizedPost( + "/api/v1/notifications/" + notificationId + "/read", accessToken + ); + + assertThat(firstRead.statusCode()).isEqualTo(204); + assertThat(secondRead.statusCode()).isEqualTo(204); + + HttpResponse listResponse = authorizedGet("/api/v1/notifications", accessToken); + assertThat(JsonPath.read(listResponse.body(), "$.items[0].read")).isTrue(); + } + + @Test + void readOnOtherCompanyNotificationReturnsNotFound() throws Exception { + UUID notificationId = insertNotification(COMPANY_B, "TASK", false, Instant.now()); + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedPost( + "/api/v1/notifications/" + notificationId + "/read", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(404); + } + + private UUID insertNotification(UUID companyId, String targetType, boolean read, Instant occurredAt) { + UUID notificationId = UUID.randomUUID(); + jdbcTemplate.update( + """ + INSERT INTO notification ( + notification_id, company_id, target_type, target_id, route, + title, is_read, occurred_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + notificationId, companyId, targetType, UUID.randomUUID(), "/tasks/" + UUID.randomUUID(), + "테스트 알림", read, java.sql.Timestamp.from(occurredAt), java.sql.Timestamp.from(Instant.now()) + ); + return notificationId; + } + + 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) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + userId, companyId, email, email, passwordHash + ); + } + + 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 authorizedPost(String path, String accessToken) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .POST(HttpRequest.BodyPublishers.noBody()) + .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 b8cd511431e4ea974fdffd0ac5e70dbd0c30d631 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 22:38:15 +0900 Subject: [PATCH 19/24] =?UTF-8?q?fix(dashboard):=20recommendations?= =?UTF-8?q?=EC=9D=98=20review/after=5Fapproval=EC=9D=84=20dueDate=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/dashboard/application/DashboardQueryService.java | 5 +++++ 1 file changed, 5 insertions(+) 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 f258979f..caf3366d 100644 --- a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java +++ b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java @@ -37,6 +37,9 @@ public class DashboardQueryService { private static final int PRIORITY_TASK_LIMIT = 5; private static final int UPCOMING_DAYS = 7; + private static final java.util.Comparator PRIORITY_ORDER = java.util.Comparator + .comparing(Task::dueDate, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())) + .thenComparing(Task::createdAt, java.util.Comparator.reverseOrder()); private final TaskRepository taskRepository; private final WorkerRepository workerRepository; @@ -104,6 +107,7 @@ private DashboardRecommendationsResponse collectRecommendations(UUID companyId) List reviewTasks = new ArrayList<>(); reviewTasks.addAll(needsInfoTasks); reviewTasks.addAll(readyTasks); + reviewTasks.sort(PRIORITY_ORDER); List waitingWorkerTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria( companyId, TaskStatus.WAITING_WORKER, null, null, null, null, null, null, null, null, 0, 100 @@ -114,6 +118,7 @@ private DashboardRecommendationsResponse collectRecommendations(UUID companyId) List afterApprovalTasks = new ArrayList<>(); afterApprovalTasks.addAll(waitingWorkerTasks); afterApprovalTasks.addAll(waitingExternalTasks); + afterApprovalTasks.sort(PRIORITY_ORDER); long connectedCount = taskRepository.countOpenTasksByCompanyId(companyId); From 9c80f25bc9f1579a23a55284d9c942d70576f7b2 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 23:18:49 +0900 Subject: [PATCH 20/24] =?UTF-8?q?feat(notification):=20V38=20notification?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=20RLS=20=EC=A0=95=EC=B1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V38__prepare_notification_rls.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/resources/db/migration-postgresql/V38__prepare_notification_rls.sql diff --git a/src/main/resources/db/migration-postgresql/V38__prepare_notification_rls.sql b/src/main/resources/db/migration-postgresql/V38__prepare_notification_rls.sql new file mode 100644 index 00000000..499b7f4c --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V38__prepare_notification_rls.sql @@ -0,0 +1,12 @@ +CREATE POLICY pl_notification_tenant_isolation + ON public.notification + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); From f81371bc939fe2851823a5b8b885935339d1c4ea Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 9 Aug 2026 02:14:00 +0900 Subject: [PATCH 21/24] =?UTF-8?q?test:=20PostgreSqlMigrationTests=20?= =?UTF-8?q?=EC=98=88=EC=83=81=20RLS=20=EC=A0=95=EC=B1=85=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index fb6ad45b..82adaec4 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -504,7 +504,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_worker_import_job_tenant_isolation", "pl_worker_import_row_tenant_isolation", "pl_worker_import_commit_idempotency_tenant_isolation", - "pl_document_ocr_run_tenant_isolation" + "pl_document_ocr_run_tenant_isolation", + "pl_notification_tenant_isolation" ); assertThat(rlsEnabledTables(connection)).isEmpty(); assertThat(securityDefinerFunctionNames(connection)) From 0dc82c8ed549b6a7419ff047f6fd9a901d25687b Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 9 Aug 2026 15:29:46 +0900 Subject: [PATCH 22/24] =?UTF-8?q?fix(notification):=20user=5Fid=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EB=B3=84=20=EC=95=8C=EB=A6=BC=20=EA=B2=A9=EB=A6=AC,?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/NotificationController.java | 3 ++- .../api/NotificationPageResponse.java | 13 ++++++++++- .../application/NotificationPageResult.java | 1 + .../application/NotificationService.java | 22 +++++++++++++------ .../port/NotificationRepository.java | 4 ++-- .../notification/domain/Notification.java | 12 ++++++++-- .../JpaNotificationRepository.java | 8 +++---- .../persistence/NotificationJpaEntity.java | 8 ++++++- .../SpringDataNotificationJpaRepository.java | 4 +++- .../db/migration/V37__create_notification.sql | 5 +++-- .../NotificationSecurityIntegrationTest.java | 12 +++++++--- 11 files changed, 68 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/fowoco/server/notification/api/NotificationController.java b/src/main/java/com/fowoco/server/notification/api/NotificationController.java index 3518a808..0985a9b3 100644 --- a/src/main/java/com/fowoco/server/notification/api/NotificationController.java +++ b/src/main/java/com/fowoco/server/notification/api/NotificationController.java @@ -69,7 +69,7 @@ public NotificationPageResponse list( @Parameter(description = "읽지 않은 알림만 조회") @RequestParam(required = false) Boolean unreadOnly, @Parameter(description = "이전 페이지 마지막 항목의 occurred_at (다음 페이지 조회용)") @RequestParam(required = false) Instant cursor, - @Parameter(description = "페이지당 항목 수 (1~100)") + @Parameter(description = "페이지당 항목 수 (1~50)") @RequestParam(required = false) @Min(1) @Max(100) Integer size ) { ActorContext actor = actorContextProvider.requireCurrentActor(); @@ -77,6 +77,7 @@ public NotificationPageResponse list( return new NotificationPageResponse( result.items().stream().map(NotificationItemResponse::from).toList(), result.unreadCount(), + result.hasNext(), result.nextCursor() ); } diff --git a/src/main/java/com/fowoco/server/notification/api/NotificationPageResponse.java b/src/main/java/com/fowoco/server/notification/api/NotificationPageResponse.java index 8800295e..df43df2e 100644 --- a/src/main/java/com/fowoco/server/notification/api/NotificationPageResponse.java +++ b/src/main/java/com/fowoco/server/notification/api/NotificationPageResponse.java @@ -14,13 +14,20 @@ public final class NotificationPageResponse { @Schema(name = "unread_count", description = "읽지 않은 알림 개수") private final long unreadCount; + @JsonProperty("has_next") + @Schema(name = "has_next", description = "다음 페이지 존재 여부") + private final boolean hasNext; + @JsonProperty("next_cursor") @Schema(name = "next_cursor", description = "다음 페이지 조회용 커서 (없으면 마지막 페이지)") private final String nextCursor; - public NotificationPageResponse(List items, long unreadCount, String nextCursor) { + public NotificationPageResponse( + List items, long unreadCount, boolean hasNext, String nextCursor + ) { this.items = items; this.unreadCount = unreadCount; + this.hasNext = hasNext; this.nextCursor = nextCursor; } @@ -32,6 +39,10 @@ public long getUnreadCount() { return unreadCount; } + public boolean isHasNext() { + return hasNext; + } + public String getNextCursor() { return nextCursor; } diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationPageResult.java b/src/main/java/com/fowoco/server/notification/application/NotificationPageResult.java index 218e7a49..58477227 100644 --- a/src/main/java/com/fowoco/server/notification/application/NotificationPageResult.java +++ b/src/main/java/com/fowoco/server/notification/application/NotificationPageResult.java @@ -6,6 +6,7 @@ public record NotificationPageResult( List items, long unreadCount, + boolean hasNext, String nextCursor ) { } diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationService.java b/src/main/java/com/fowoco/server/notification/application/NotificationService.java index e21d1f31..7f1aba17 100644 --- a/src/main/java/com/fowoco/server/notification/application/NotificationService.java +++ b/src/main/java/com/fowoco/server/notification/application/NotificationService.java @@ -7,6 +7,7 @@ import com.fowoco.server.notification.application.port.NotificationRepository; import com.fowoco.server.notification.domain.Notification; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.UUID; import org.springframework.stereotype.Service; @@ -15,8 +16,8 @@ @Service public class NotificationService { - private static final int MAX_PAGE_SIZE = 100; - private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 35; + private static final int DEFAULT_PAGE_SIZE = 5; private final NotificationRepository notificationRepository; private final TenantDatabaseContext tenantDatabaseContext; @@ -33,17 +34,24 @@ public NotificationService( public NotificationPageResult findPage(ActorContext actor, Boolean unreadOnly, Instant cursor, Integer size) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); UUID companyId = actor.companyId(); + UUID userId = actor.actorId(); int effectiveSize = normalizeSize(size); - List items = notificationRepository.findPage( - companyId, unreadOnly != null && unreadOnly, cursor, effectiveSize + List fetched = notificationRepository.findPage( + companyId, userId, unreadOnly != null && unreadOnly, cursor, effectiveSize + 1 ); - long unreadCount = notificationRepository.countUnread(companyId); - String nextCursor = items.size() == effectiveSize && !items.isEmpty() + + boolean hasNext = fetched.size() > effectiveSize; + List items = hasNext + ? new ArrayList<>(fetched.subList(0, effectiveSize)) + : fetched; + + long unreadCount = notificationRepository.countUnread(companyId, userId); + String nextCursor = hasNext && !items.isEmpty() ? items.get(items.size() - 1).occurredAt().toString() : null; - return new NotificationPageResult(items, unreadCount, nextCursor); + return new NotificationPageResult(items, unreadCount, hasNext, nextCursor); } @Transactional diff --git a/src/main/java/com/fowoco/server/notification/application/port/NotificationRepository.java b/src/main/java/com/fowoco/server/notification/application/port/NotificationRepository.java index e628733c..f4fc06ef 100644 --- a/src/main/java/com/fowoco/server/notification/application/port/NotificationRepository.java +++ b/src/main/java/com/fowoco/server/notification/application/port/NotificationRepository.java @@ -14,7 +14,7 @@ public interface NotificationRepository { Optional findByIdAndCompanyId(UUID notificationId, UUID companyId); - List findPage(UUID companyId, boolean unreadOnly, Instant cursor, int size); + List findPage(UUID companyId, UUID userId, boolean unreadOnly, Instant cursor, int size); - long countUnread(UUID companyId); + long countUnread(UUID companyId, UUID userId); } diff --git a/src/main/java/com/fowoco/server/notification/domain/Notification.java b/src/main/java/com/fowoco/server/notification/domain/Notification.java index ffa84b22..edad6185 100644 --- a/src/main/java/com/fowoco/server/notification/domain/Notification.java +++ b/src/main/java/com/fowoco/server/notification/domain/Notification.java @@ -8,6 +8,7 @@ public final class Notification { private final UUID notificationId; private final UUID companyId; + private final UUID userId; private final NotificationTargetType targetType; private final UUID targetId; private final String route; @@ -19,6 +20,7 @@ public final class Notification { public Notification( UUID notificationId, UUID companyId, + UUID userId, NotificationTargetType targetType, UUID targetId, String route, @@ -29,6 +31,7 @@ public Notification( ) { this.notificationId = Objects.requireNonNull(notificationId, "notificationId must not be null"); this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); + this.userId = Objects.requireNonNull(userId, "userId must not be null"); this.targetType = Objects.requireNonNull(targetType, "targetType must not be null"); this.targetId = Objects.requireNonNull(targetId, "targetId must not be null"); this.route = requireNonBlank(route, "route"); @@ -41,6 +44,7 @@ public Notification( public static Notification create( UUID notificationId, UUID companyId, + UUID userId, NotificationTargetType targetType, UUID targetId, String route, @@ -49,7 +53,7 @@ public static Notification create( Instant now ) { return new Notification( - notificationId, companyId, targetType, targetId, route, title, false, occurredAt, now + notificationId, companyId, userId, targetType, targetId, route, title, false, occurredAt, now ); } @@ -58,7 +62,7 @@ public Notification markAsRead() { return this; } return new Notification( - notificationId, companyId, targetType, targetId, route, title, true, occurredAt, createdAt + notificationId, companyId, userId, targetType, targetId, route, title, true, occurredAt, createdAt ); } @@ -70,6 +74,10 @@ public UUID companyId() { return companyId; } + public UUID userId() { + return userId; + } + public NotificationTargetType targetType() { return targetType; } diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java index ace904b6..077224b6 100644 --- a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java @@ -38,14 +38,14 @@ public Optional findByIdAndCompanyId(UUID notificationId, UUID com } @Override - public List findPage(UUID companyId, boolean unreadOnly, Instant cursor, int size) { - return repository.findPage(companyId, unreadOnly, cursor, PageRequest.of(0, size)).stream() + public List findPage(UUID companyId, UUID userId, boolean unreadOnly, Instant cursor, int size) { + return repository.findPage(companyId, userId, unreadOnly, cursor, PageRequest.of(0, size)).stream() .map(NotificationJpaEntity::toDomain) .toList(); } @Override - public long countUnread(UUID companyId) { - return repository.countByCompanyIdAndReadFalse(companyId); + public long countUnread(UUID companyId, UUID userId) { + return repository.countByCompanyIdAndUserIdAndReadFalse(companyId, userId); } } diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/NotificationJpaEntity.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/NotificationJpaEntity.java index 20eaf62b..99a52578 100644 --- a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/NotificationJpaEntity.java +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/NotificationJpaEntity.java @@ -23,6 +23,9 @@ public class NotificationJpaEntity { @Column(name = "company_id", nullable = false, updatable = false) private UUID companyId; + @Column(name = "user_id", nullable = false, updatable = false) + private UUID userId; + @Enumerated(EnumType.STRING) @Column(name = "target_type", nullable = false, length = 30, updatable = false) private NotificationTargetType targetType; @@ -51,6 +54,7 @@ protected NotificationJpaEntity() { private NotificationJpaEntity( UUID notificationId, UUID companyId, + UUID userId, NotificationTargetType targetType, UUID targetId, String route, @@ -61,6 +65,7 @@ private NotificationJpaEntity( ) { this.notificationId = notificationId; this.companyId = companyId; + this.userId = userId; this.targetType = targetType; this.targetId = targetId; this.route = route; @@ -75,6 +80,7 @@ public static NotificationJpaEntity fromDomain(Notification notification) { return new NotificationJpaEntity( notification.notificationId(), notification.companyId(), + notification.userId(), notification.targetType(), notification.targetId(), notification.route(), @@ -87,7 +93,7 @@ public static NotificationJpaEntity fromDomain(Notification notification) { public Notification toDomain() { return new Notification( - notificationId, companyId, targetType, targetId, route, title, read, occurredAt, createdAt + notificationId, companyId, userId, targetType, targetId, route, title, read, occurredAt, createdAt ); } diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java index bffe2f85..369e02db 100644 --- a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java @@ -24,16 +24,18 @@ java.util.Optional findByIdAndCompanyId( SELECT n FROM NotificationJpaEntity n WHERE n.companyId = :companyId + AND n.userId = :userId AND (:unreadOnly = false OR n.read = false) AND (:cursor IS NULL OR n.occurredAt < :cursor) ORDER BY n.occurredAt DESC """) java.util.List findPage( @Param("companyId") UUID companyId, + @Param("userId") UUID userId, @Param("unreadOnly") boolean unreadOnly, @Param("cursor") Instant cursor, Pageable pageable ); - long countByCompanyIdAndReadFalse(UUID companyId); + long countByCompanyIdAndUserIdAndReadFalse(UUID companyId, UUID userId); } diff --git a/src/main/resources/db/migration/V37__create_notification.sql b/src/main/resources/db/migration/V37__create_notification.sql index 0b5cdcb0..37d40a0a 100644 --- a/src/main/resources/db/migration/V37__create_notification.sql +++ b/src/main/resources/db/migration/V37__create_notification.sql @@ -1,6 +1,7 @@ CREATE TABLE notification ( notification_id UUID PRIMARY KEY, company_id UUID NOT NULL, + user_id UUID NOT NULL, target_type VARCHAR(30) NOT NULL, target_id UUID NOT NULL, route VARCHAR(255) NOT NULL, @@ -10,5 +11,5 @@ CREATE TABLE notification ( created_at TIMESTAMP WITH TIME ZONE NOT NULL ); -CREATE INDEX idx_notification_company_read_occurred - ON notification (company_id, is_read, occurred_at DESC); +CREATE INDEX idx_notification_company_user_read_occurred + ON notification (company_id, user_id, is_read, occurred_at DESC); diff --git a/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java index f82c4cf7..5f190dff 100644 --- a/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java @@ -144,15 +144,21 @@ void readOnOtherCompanyNotificationReturnsNotFound() throws Exception { } private UUID insertNotification(UUID companyId, String targetType, boolean read, Instant occurredAt) { + return insertNotification(companyId, HR_A, targetType, read, occurredAt); + } + + private UUID insertNotification( + UUID companyId, UUID userId, String targetType, boolean read, Instant occurredAt + ) { UUID notificationId = UUID.randomUUID(); jdbcTemplate.update( """ INSERT INTO notification ( - notification_id, company_id, target_type, target_id, route, + notification_id, company_id, user_id, target_type, target_id, route, title, is_read, occurred_at, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, - notificationId, companyId, targetType, UUID.randomUUID(), "/tasks/" + UUID.randomUUID(), + notificationId, companyId, userId, targetType, UUID.randomUUID(), "/tasks/" + UUID.randomUUID(), "테스트 알림", read, java.sql.Timestamp.from(occurredAt), java.sql.Timestamp.from(Instant.now()) ); return notificationId; From 3636c1bb1ecf071c93073dbdd20210eb95ff0529 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 9 Aug 2026 16:10:34 +0900 Subject: [PATCH 23/24] =?UTF-8?q?feat(notification):=20route=EB=A5=BC=20ta?= =?UTF-8?q?rget=5Ftype/target=5Fid=EB=A1=9C=20=EB=B6=88=EC=9D=BC=EC=B9=98?= =?UTF-8?q?=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/notification/domain/Notification.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/fowoco/server/notification/domain/Notification.java b/src/main/java/com/fowoco/server/notification/domain/Notification.java index edad6185..07b12736 100644 --- a/src/main/java/com/fowoco/server/notification/domain/Notification.java +++ b/src/main/java/com/fowoco/server/notification/domain/Notification.java @@ -47,13 +47,13 @@ public static Notification create( UUID userId, NotificationTargetType targetType, UUID targetId, - String route, String title, Instant occurredAt, Instant now ) { return new Notification( - notificationId, companyId, userId, targetType, targetId, route, title, false, occurredAt, now + notificationId, companyId, userId, targetType, targetId, + buildRoute(targetType, targetId), title, false, occurredAt, now ); } @@ -106,6 +106,16 @@ public Instant createdAt() { return createdAt; } + private static String buildRoute(NotificationTargetType targetType, UUID targetId) { + Objects.requireNonNull(targetType, "targetType must not be null"); + Objects.requireNonNull(targetId, "targetId must not be null"); + return switch (targetType) { + case TASK -> "/tasks/" + targetId; + case WORKER -> "/workers/" + targetId + "/detail"; + case DOCUMENT -> "/documents/" + targetId; + }; + } + private static String requireNonBlank(String value, String fieldName) { if (value == null || value.isBlank()) { throw new IllegalArgumentException(fieldName + " must not be blank"); From 0c8392a7974c73af3045ad3a7fce242a9dd4c5f8 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 9 Aug 2026 20:51:48 +0900 Subject: [PATCH 24/24] =?UTF-8?q?test(notification):=20user=5Fid=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC,=20hasNext=20=EA=B2=BD=EA=B3=84=EA=B0=92=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationSecurityIntegrationTest.java | 43 ++++++++++++ .../notification/domain/NotificationTest.java | 69 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/test/java/com/fowoco/server/notification/domain/NotificationTest.java diff --git a/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java index 5f190dff..0e73d77e 100644 --- a/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java @@ -31,7 +31,9 @@ class NotificationSecurityIntegrationTest { private static final UUID COMPANY_B = UUID.fromString("62000000-0000-0000-0000-000000000002"); private static final UUID HR_A = UUID.fromString("53000000-0000-0000-0000-000000000001"); private static final UUID HR_B = UUID.fromString("63000000-0000-0000-0000-000000000002"); + private static final UUID HR_A2 = UUID.fromString("53000000-0000-0000-0000-000000000002"); private static final String HR_A_EMAIL = "hr.notification.a@example.com"; + private static final String HR_A2_EMAIL = "hr.notification.a2@example.com"; private static final String HR_B_EMAIL = "hr.notification.b@example.com"; private static final String PASSWORD = "Test-password-1!"; @@ -68,6 +70,7 @@ void seedCompaniesAndUsers() { insertCompany(COMPANY_B, "알림 사업장 B"); String passwordHash = passwordEncoder.encode(PASSWORD); insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash); + insertUser(HR_A2, COMPANY_A, HR_A2_EMAIL, passwordHash); insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash); } @@ -143,6 +146,46 @@ void readOnOtherCompanyNotificationReturnsNotFound() throws Exception { assertThat(response.statusCode()).isEqualTo(404); } + @Test + void notificationsAreIsolatedBetweenUsersInSameCompany() throws Exception { + insertNotification(COMPANY_A, HR_A, "TASK", false, Instant.now()); + String hrA2Token = accessToken(login(HR_A2_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications", hrA2Token); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).isEmpty(); + assertThat(JsonPath.read(response.body(), "$.unread_count").longValue()).isZero(); + } + + @Test + void hasNextIsFalseWhenExactlySizeItemsRemain() throws Exception { + for (int i = 0; i < 3; i++) { + insertNotification(COMPANY_A, HR_A, "TASK", false, Instant.now().minusSeconds(i)); + } + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications?size=3", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).hasSize(3); + assertThat(JsonPath.read(response.body(), "$.has_next")).isFalse(); + } + + @Test + void hasNextIsTrueWhenMoreItemsRemain() throws Exception { + for (int i = 0; i < 4; i++) { + insertNotification(COMPANY_A, HR_A, "TASK", false, Instant.now().minusSeconds(i)); + } + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications?size=3", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).hasSize(3); + assertThat(JsonPath.read(response.body(), "$.has_next")).isTrue(); + } + private UUID insertNotification(UUID companyId, String targetType, boolean read, Instant occurredAt) { return insertNotification(companyId, HR_A, targetType, read, occurredAt); } diff --git a/src/test/java/com/fowoco/server/notification/domain/NotificationTest.java b/src/test/java/com/fowoco/server/notification/domain/NotificationTest.java new file mode 100644 index 00000000..7dcc0845 --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/domain/NotificationTest.java @@ -0,0 +1,69 @@ +package com.fowoco.server.notification.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class NotificationTest { + + private static final UUID COMPANY_ID = UUID.randomUUID(); + private static final UUID USER_ID = UUID.randomUUID(); + private static final UUID TARGET_ID = UUID.randomUUID(); + private static final Instant NOW = Instant.parse("2026-08-09T00:00:00Z"); + + @Test + void buildsTaskRoute() { + Notification notification = Notification.create( + UUID.randomUUID(), COMPANY_ID, USER_ID, + NotificationTargetType.TASK, TARGET_ID, "제목", NOW, NOW + ); + + assertThat(notification.route()).isEqualTo("/tasks/" + TARGET_ID); + } + + @Test + void buildsWorkerRoute() { + Notification notification = Notification.create( + UUID.randomUUID(), COMPANY_ID, USER_ID, + NotificationTargetType.WORKER, TARGET_ID, "제목", NOW, NOW + ); + + assertThat(notification.route()).isEqualTo("/workers/" + TARGET_ID + "/detail"); + } + + @Test + void buildsDocumentRoute() { + Notification notification = Notification.create( + UUID.randomUUID(), COMPANY_ID, USER_ID, + NotificationTargetType.DOCUMENT, TARGET_ID, "제목", NOW, NOW + ); + + assertThat(notification.route()).isEqualTo("/documents/" + TARGET_ID); + } + + @Test + void routeAlwaysMatchesTargetId() { + Notification notification = Notification.create( + UUID.randomUUID(), COMPANY_ID, USER_ID, + NotificationTargetType.TASK, TARGET_ID, "제목", NOW, NOW + ); + + assertThat(notification.route()).contains(notification.targetId().toString()); + } + + @Test + void markAsReadIsIdempotent() { + Notification notification = Notification.create( + UUID.randomUUID(), COMPANY_ID, USER_ID, + NotificationTargetType.TASK, TARGET_ID, "제목", NOW, NOW + ); + + Notification firstRead = notification.markAsRead(); + Notification secondRead = firstRead.markAsRead(); + + assertThat(firstRead.read()).isTrue(); + assertThat(secondRead).isSameAs(firstRead); + } +}