From f22a50fb5ca18ae6349f52f1c3ad5da69f71bc4d Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 8 Aug 2026 01:51:00 +0900 Subject: [PATCH 01/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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/36] =?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 2306c1124c9e074807889897661bdc22e53d57b8 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 9 Aug 2026 10:07:51 +0900 Subject: [PATCH 22/36] =?UTF-8?q?feat(notification):=20NotificationEventHa?= =?UTF-8?q?ndler=20=EB=8B=A8=EC=9C=84/=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/NotificationEventHandler.java | 75 ++++++++ ...tificationEventHandlerIntegrationTest.java | 171 ++++++++++++++++++ .../NotificationEventHandlerTest.java | 111 ++++++++++++ 3 files changed, 357 insertions(+) create mode 100644 src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java create mode 100644 src/test/java/com/fowoco/server/notification/NotificationEventHandlerIntegrationTest.java create mode 100644 src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java b/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java new file mode 100644 index 00000000..fb8a4b3c --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java @@ -0,0 +1,75 @@ +package com.fowoco.server.notification.application; + +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.notification.domain.NotificationTargetType; +import com.fowoco.server.reliability.application.port.DomainEventHandler; +import com.fowoco.server.reliability.domain.DomainEventEnvelope; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskSource; +import java.time.Clock; +import java.util.UUID; +import org.springframework.stereotype.Component; + +@Component +public final class NotificationEventHandler implements DomainEventHandler { + + private static final String HANDLER_NAME = "notificationFromTaskCreated"; + private static final String TASK_CREATED = "TaskCreated"; + + private final TaskRepository taskRepository; + private final NotificationRepository notificationRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public NotificationEventHandler( + TaskRepository taskRepository, + NotificationRepository notificationRepository, + TenantDatabaseContext tenantDatabaseContext, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.taskRepository = taskRepository; + this.notificationRepository = notificationRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Override + public String handlerName() { + return HANDLER_NAME; + } + + @Override + public boolean supports(String eventType) { + return TASK_CREATED.equals(eventType); + } + + @Override + public void handle(DomainEventEnvelope event) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId()); + Task task = taskRepository.findByIdAndCompanyId(event.aggregateId(), event.companyId()) + .orElseThrow(() -> new IllegalStateException("task not found for TaskCreated event")); + + if (task.source() != TaskSource.AI_CANDIDATE) { + return; + } + + Notification notification = Notification.create( + uuidGenerator.generate(), + task.companyId(), + NotificationTargetType.TASK, + task.taskId(), + "/tasks/" + task.taskId(), + "Agent 분석이 완료됐습니다: " + task.title(), + event.occurredAt(), + clock.instant() + ); + notificationRepository.insert(notification); + } +} diff --git a/src/test/java/com/fowoco/server/notification/NotificationEventHandlerIntegrationTest.java b/src/test/java/com/fowoco/server/notification/NotificationEventHandlerIntegrationTest.java new file mode 100644 index 00000000..1ff57008 --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/NotificationEventHandlerIntegrationTest.java @@ -0,0 +1,171 @@ +package com.fowoco.server.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import com.fowoco.server.reliability.application.OutboxProcessor; +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 NotificationEventHandlerIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("54000000-0000-0000-0000-000000000001"); + private static final UUID HR_A = UUID.fromString("55000000-0000-0000-0000-000000000001"); + private static final String HR_A_EMAIL = "hr.notifgen.a@example.com"; + private static final String PASSWORD = "Test-password-1!"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private OutboxProcessor outboxProcessor; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeAll + void seedCompanyAndUser() { + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM notification"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM audit_event"); + 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 worker_document"); + jdbcTemplate.update("DELETE FROM worker"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + insertCompany(COMPANY_A, "알림생성 사업장 A"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash); + } + + @BeforeEach + void resetState() { + jdbcTemplate.update("DELETE FROM notification"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM worker"); + } + + @Test + void manualTaskCreationDoesNotCreateNotification() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(accessToken, "일반업무근로자"); + createManualTask(accessToken, workerId); + + assertThat(outboxProcessor.processAvailable()).isEqualTo(1); + + HttpResponse response = authorizedGet("/api/v1/notifications", accessToken); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).isEmpty(); + } + + 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()).as("body: %s", response.body()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_id"); + } + + private void createManualTask(String accessToken, String workerId) throws Exception { + String body = """ + { + "worker_id":"%s", + "task_type":"RECONTRACT", + "workflow_id":"WF-CON-001", + "title":"수동 재계약 업무", + "description":"테스트용", + "due_date":"2026-08-20", + "business_data":{"monthly_wage":2500000} + } + """.formatted(workerId); + HttpResponse response = postJson("/api/v1/tasks", body, accessToken); + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + } + + 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 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); + } +} diff --git a/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java b/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java new file mode 100644 index 00000000..f44e8c7d --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java @@ -0,0 +1,111 @@ +package com.fowoco.server.notification.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.reliability.domain.DomainEventEnvelope; +import com.fowoco.server.reliability.domain.EventActorType; +import com.fowoco.server.reliability.domain.SafeEventPayload; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskTargetType; +import com.fowoco.server.task.domain.TaskType; +import java.time.Clock; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class NotificationEventHandlerTest { + + private static final UUID COMPANY_ID = UUID.fromString("50000000-0000-0000-0000-000000000001"); + private static final UUID TASK_ID = UUID.fromString("94000000-0000-0000-0000-000000000001"); + private static final UUID NEW_ID = UUID.fromString("99000000-0000-0000-0000-000000000001"); + private static final Instant NOW = Instant.parse("2026-08-09T00:00:00Z"); + + private final TaskRepository taskRepository = mock(TaskRepository.class); + private final NotificationRepository notificationRepository = mock(NotificationRepository.class); + private final TenantDatabaseContext tenantDatabaseContext = mock(TenantDatabaseContext.class); + private final UuidGenerator uuidGenerator = mock(UuidGenerator.class); + private final Clock clock = Clock.fixed(NOW, java.time.ZoneOffset.UTC); + + private final NotificationEventHandler handler = new NotificationEventHandler( + taskRepository, notificationRepository, tenantDatabaseContext, uuidGenerator, clock + ); + + @Test + void supportsOnlyTaskCreated() { + assertThat(handler.supports("TaskCreated")).isTrue(); + assertThat(handler.supports("TaskCancelled")).isFalse(); + assertThat(handler.supports("SomethingElse")).isFalse(); + } + + @Test + void createsNotificationForAiCandidateTask() { + Task task = aiCandidateTask(); + when(taskRepository.findByIdAndCompanyId(TASK_ID, COMPANY_ID)).thenReturn(Optional.of(task)); + when(uuidGenerator.generate()).thenReturn(NEW_ID); + + handler.handle(taskCreatedEvent()); + + verify(notificationRepository).insert(org.mockito.ArgumentMatchers.any(Notification.class)); + } + + @Test + void doesNotCreateNotificationForManualTask() { + Task task = manualTask(); + when(taskRepository.findByIdAndCompanyId(TASK_ID, COMPANY_ID)).thenReturn(Optional.of(task)); + + handler.handle(taskCreatedEvent()); + + verify(notificationRepository, never()).insert(org.mockito.ArgumentMatchers.any(Notification.class)); + } + + private DomainEventEnvelope taskCreatedEvent() { + return new DomainEventEnvelope( + UUID.randomUUID(), + "TaskCreated", + "1", + "Task", + TASK_ID, + COMPANY_ID, + EventActorType.HR_USER, + UUID.randomUUID(), + "req-1", + "12345678901234567890123456789012", + NOW, + SafeEventPayload.of(Set.of("source"), Map.of("source", TaskSource.AI_CANDIDATE)) + ); + } + + private Task aiCandidateTask() { + return Task.create( + TASK_ID, COMPANY_ID, UUID.randomUUID(), UUID.randomUUID(), + TaskType.RECONTRACT, "WF-CON-001", "0.2.0", + "AI 추천: 재계약 준비", "설명", "{}", "a".repeat(64), + TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, null, + UUID.randomUUID(), NOW + ); + } + + private Task manualTask() { + return Task.create( + TASK_ID, COMPANY_ID, UUID.randomUUID(), UUID.randomUUID(), + TaskType.RECONTRACT, "WF-CON-001", "0.2.0", + "수동 재계약", "설명", "{}", "a".repeat(64), + TaskSource.MANUAL, TaskStatus.DRAFT, null, + UUID.randomUUID(), NOW + ); + } +} From 0dc82c8ed549b6a7419ff047f6fd9a901d25687b Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 9 Aug 2026 15:29:46 +0900 Subject: [PATCH 23/36] =?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 24/36] =?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 25/36] =?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); + } +} From b8e9594ba2f7ecb5efeab9104dd1a2ceac051bfd Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 9 Aug 2026 22:32:03 +0900 Subject: [PATCH 26/36] =?UTF-8?q?fix(notification):=20NotificationEventHan?= =?UTF-8?q?dler=EB=A5=BC=20Notification.create()=20=EC=83=88=20=EC=8B=9C?= =?UTF-8?q?=EA=B7=B8=EB=8B=88=EC=B2=98=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/application/NotificationEventHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java b/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java index fb8a4b3c..d78497da 100644 --- a/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java +++ b/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java @@ -63,9 +63,9 @@ public void handle(DomainEventEnvelope event) { Notification notification = Notification.create( uuidGenerator.generate(), task.companyId(), + event.actorId(), NotificationTargetType.TASK, task.taskId(), - "/tasks/" + task.taskId(), "Agent 분석이 완료됐습니다: " + task.title(), event.occurredAt(), clock.instant() From 4fc24dd267cfe0db2eb2658c5dc9e3203e7b8ffb Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 9 Aug 2026 22:50:46 +0900 Subject: [PATCH 27/36] =?UTF-8?q?feat(approval):=20ApprovalRequested=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=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/ApprovalDomainEvents.java | 54 +++++++++++++++++++ .../approval/application/ApprovalService.java | 13 ++++- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/fowoco/server/approval/application/ApprovalDomainEvents.java diff --git a/src/main/java/com/fowoco/server/approval/application/ApprovalDomainEvents.java b/src/main/java/com/fowoco/server/approval/application/ApprovalDomainEvents.java new file mode 100644 index 00000000..9e70efbe --- /dev/null +++ b/src/main/java/com/fowoco/server/approval/application/ApprovalDomainEvents.java @@ -0,0 +1,54 @@ +package com.fowoco.server.approval.application; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.reliability.domain.DomainEventEnvelope; +import com.fowoco.server.reliability.domain.EventActorType; +import com.fowoco.server.reliability.domain.SafeEventPayload; +import com.fowoco.server.task.domain.Task; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +final class ApprovalDomainEvents { + + private static final String PAYLOAD_VERSION = "1"; + private static final String AGGREGATE_TYPE = "Task"; + private static final Set APPROVAL_REQUESTED_FIELDS = Set.of( + "task_title", + "task_type" + ); + + private ApprovalDomainEvents() { + } + + static DomainEventEnvelope approvalRequested( + UUID eventId, + Task task, + ActorContext actor, + RequestMetadata metadata, + Instant occurredAt + ) { + return new DomainEventEnvelope( + eventId, + "ApprovalRequested", + PAYLOAD_VERSION, + AGGREGATE_TYPE, + task.taskId(), + task.companyId(), + EventActorType.HR_USER, + actor.actorId(), + metadata.requestId(), + metadata.traceId(), + occurredAt, + SafeEventPayload.of( + APPROVAL_REQUESTED_FIELDS, + Map.of( + "task_title", task.title(), + "task_type", task.taskType() + ) + ) + ); + } +} diff --git a/src/main/java/com/fowoco/server/approval/application/ApprovalService.java b/src/main/java/com/fowoco/server/approval/application/ApprovalService.java index ccf6920c..869685e3 100644 --- a/src/main/java/com/fowoco/server/approval/application/ApprovalService.java +++ b/src/main/java/com/fowoco/server/approval/application/ApprovalService.java @@ -17,6 +17,7 @@ import com.fowoco.server.auth.domain.UserRole; import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.reliability.application.port.DomainEventPublisher; import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.task.application.error.TaskErrorCode; @@ -52,6 +53,7 @@ public class ApprovalService implements ApprovalControlPort { private final SafeJsonService safeJsonService; private final UuidGenerator uuidGenerator; private final Clock clock; + private final DomainEventPublisher eventPublisher; public ApprovalService( ActorAuthorizer actorAuthorizer, @@ -65,7 +67,8 @@ public ApprovalService( AuditEventRepository auditRepository, SafeJsonService safeJsonService, UuidGenerator uuidGenerator, - Clock clock + Clock clock, + DomainEventPublisher eventPublisher ) { this.actorAuthorizer = actorAuthorizer; this.tenantDatabaseContext = tenantDatabaseContext; @@ -79,6 +82,7 @@ public ApprovalService( this.safeJsonService = safeJsonService; this.uuidGenerator = uuidGenerator; this.clock = clock; + this.eventPublisher = eventPublisher; } @Transactional @@ -136,6 +140,13 @@ public ApprovalResult requestApproval( metadata, now ); + eventPublisher.publish(ApprovalDomainEvents.approvalRequested( + uuidGenerator.generate(), + savedTask, + actor, + metadata, + now + )); return result(savedApproval, savedTask); } From f334f75e1b8a7aa633de39b3bf8df5fb8b949c10 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 9 Aug 2026 23:15:47 +0900 Subject: [PATCH 28/36] =?UTF-8?q?feat(notification):=20ApprovalRequested?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B5=AC=EB=8F=85=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=8A=B9=EC=9D=B8=20=EC=9A=94=EC=B2=AD=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/NotificationEventHandler.java | 33 ++++++++++++++-- .../NotificationEventHandlerTest.java | 39 ++++++++++++++++++- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java b/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java index d78497da..df1394ca 100644 --- a/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java +++ b/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java @@ -11,14 +11,16 @@ import com.fowoco.server.task.domain.Task; import com.fowoco.server.task.domain.TaskSource; import java.time.Clock; -import java.util.UUID; +import java.util.Set; import org.springframework.stereotype.Component; @Component public final class NotificationEventHandler implements DomainEventHandler { - private static final String HANDLER_NAME = "notificationFromTaskCreated"; + private static final String HANDLER_NAME = "notificationFromTaskEvents"; private static final String TASK_CREATED = "TaskCreated"; + private static final String APPROVAL_REQUESTED = "ApprovalRequested"; + private static final Set SUPPORTED_EVENTS = Set.of(TASK_CREATED, APPROVAL_REQUESTED); private final TaskRepository taskRepository; private final NotificationRepository notificationRepository; @@ -47,11 +49,19 @@ public String handlerName() { @Override public boolean supports(String eventType) { - return TASK_CREATED.equals(eventType); + return SUPPORTED_EVENTS.contains(eventType); } @Override public void handle(DomainEventEnvelope event) { + if (TASK_CREATED.equals(event.eventType())) { + handleTaskCreated(event); + } else if (APPROVAL_REQUESTED.equals(event.eventType())) { + handleApprovalRequested(event); + } + } + + private void handleTaskCreated(DomainEventEnvelope event) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId()); Task task = taskRepository.findByIdAndCompanyId(event.aggregateId(), event.companyId()) .orElseThrow(() -> new IllegalStateException("task not found for TaskCreated event")); @@ -72,4 +82,21 @@ public void handle(DomainEventEnvelope event) { ); notificationRepository.insert(notification); } + + private void handleApprovalRequested(DomainEventEnvelope event) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId()); + Object taskTitle = event.payload().values().get("task_title"); + + Notification notification = Notification.create( + uuidGenerator.generate(), + event.companyId(), + event.actorId(), + NotificationTargetType.TASK, + event.aggregateId(), + "승인 요청이 도착했습니다: " + taskTitle, + event.occurredAt(), + clock.instant() + ); + notificationRepository.insert(notification); + } } diff --git a/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java b/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java index f44e8c7d..b7ec1270 100644 --- a/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java +++ b/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java @@ -17,7 +17,6 @@ import com.fowoco.server.task.domain.Task; import com.fowoco.server.task.domain.TaskSource; import com.fowoco.server.task.domain.TaskStatus; -import com.fowoco.server.task.domain.TaskTargetType; import com.fowoco.server.task.domain.TaskType; import java.time.Clock; import java.time.Instant; @@ -26,11 +25,13 @@ import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; class NotificationEventHandlerTest { private static final UUID COMPANY_ID = UUID.fromString("50000000-0000-0000-0000-000000000001"); private static final UUID TASK_ID = UUID.fromString("94000000-0000-0000-0000-000000000001"); + private static final UUID ACTOR_ID = UUID.fromString("51000000-0000-0000-0000-000000000001"); private static final UUID NEW_ID = UUID.fromString("99000000-0000-0000-0000-000000000001"); private static final Instant NOW = Instant.parse("2026-08-09T00:00:00Z"); @@ -45,8 +46,9 @@ class NotificationEventHandlerTest { ); @Test - void supportsOnlyTaskCreated() { + void supportsTaskCreatedAndApprovalRequested() { assertThat(handler.supports("TaskCreated")).isTrue(); + assertThat(handler.supports("ApprovalRequested")).isTrue(); assertThat(handler.supports("TaskCancelled")).isFalse(); assertThat(handler.supports("SomethingElse")).isFalse(); } @@ -72,6 +74,19 @@ void doesNotCreateNotificationForManualTask() { verify(notificationRepository, never()).insert(org.mockito.ArgumentMatchers.any(Notification.class)); } + @Test + void createsNotificationForApprovalRequestedAndSendsToRequester() { + when(uuidGenerator.generate()).thenReturn(NEW_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + + handler.handle(approvalRequestedEvent()); + + verify(notificationRepository).insert(captor.capture()); + Notification notification = captor.getValue(); + assertThat(notification.userId()).isEqualTo(ACTOR_ID); + assertThat(notification.title()).contains("재계약 준비"); + } + private DomainEventEnvelope taskCreatedEvent() { return new DomainEventEnvelope( UUID.randomUUID(), @@ -89,6 +104,26 @@ private DomainEventEnvelope taskCreatedEvent() { ); } + private DomainEventEnvelope approvalRequestedEvent() { + return new DomainEventEnvelope( + UUID.randomUUID(), + "ApprovalRequested", + "1", + "Task", + TASK_ID, + COMPANY_ID, + EventActorType.HR_USER, + ACTOR_ID, + "req-2", + "12345678901234567890123456789012", + NOW, + SafeEventPayload.of( + Set.of("task_title", "task_type"), + Map.of("task_title", "재계약 준비", "task_type", TaskType.RECONTRACT) + ) + ); + } + private Task aiCandidateTask() { return Task.create( TASK_ID, COMPANY_ID, UUID.randomUUID(), UUID.randomUUID(), From 0cf327939fa4b7d2998b36a1aaf86d3a6041dfda Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 9 Aug 2026 23:32:32 +0900 Subject: [PATCH 29/36] =?UTF-8?q?feat(workerlink):=20WorkerResponseService?= =?UTF-8?q?.submit()=EC=97=90=EC=84=9C=20Submitted=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=9C=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkerResponseDomainEvents.java | 51 +++++++++++++++++++ .../application/WorkerResponseService.java | 20 +++++++- 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDomainEvents.java diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDomainEvents.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDomainEvents.java new file mode 100644 index 00000000..e32553f2 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDomainEvents.java @@ -0,0 +1,51 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.reliability.domain.DomainEventEnvelope; +import com.fowoco.server.reliability.domain.EventActorType; +import com.fowoco.server.reliability.domain.SafeEventPayload; +import com.fowoco.server.task.domain.Task; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +final class WorkerResponseDomainEvents { + + private static final String PAYLOAD_VERSION = "1"; + private static final String AGGREGATE_TYPE = "Task"; + private static final Set RESPONSE_SUBMITTED_FIELDS = Set.of( + "task_title", + "task_type" + ); + + private WorkerResponseDomainEvents() { + } + + static DomainEventEnvelope responseSubmitted( + UUID eventId, + Task task, + UUID companyId, + Instant occurredAt + ) { + return new DomainEventEnvelope( + eventId, + "WorkerResponseSubmitted", + PAYLOAD_VERSION, + AGGREGATE_TYPE, + task.taskId(), + companyId, + EventActorType.WORKER_LINK, + task.createdBy(), + null, + null, + occurredAt, + SafeEventPayload.of( + RESPONSE_SUBMITTED_FIELDS, + Map.of( + "task_title", task.title(), + "task_type", task.taskType() + ) + ) + ); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java index d73740b6..e1f6ae67 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java @@ -42,6 +42,8 @@ public class WorkerResponseService { private final AuditEventRepository auditRepository; private final UuidGenerator uuidGenerator; private final Clock clock; + private final com.fowoco.server.task.application.port.TaskRepository taskRepository; + private final com.fowoco.server.reliability.application.port.DomainEventPublisher eventPublisher; public WorkerResponseService( WorkerLinkTenantBootstrap workerLinkTenantBootstrap, @@ -52,7 +54,9 @@ public WorkerResponseService( StoredFileRepository storedFileRepository, AuditEventRepository auditRepository, UuidGenerator uuidGenerator, - Clock clock + Clock clock, + com.fowoco.server.task.application.port.TaskRepository taskRepository, + com.fowoco.server.reliability.application.port.DomainEventPublisher eventPublisher ) { this.workerLinkTenantBootstrap = workerLinkTenantBootstrap; this.tenantDatabaseContext = tenantDatabaseContext; @@ -63,6 +67,8 @@ public WorkerResponseService( this.auditRepository = auditRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; + this.taskRepository = taskRepository; + this.eventPublisher = eventPublisher; } @Transactional @@ -141,10 +147,20 @@ public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, Re "근로자 응답 제출: " + command.responseType(), now )); - + if (command.responseType() == WorkerResponseType.DOCUMENT_SUBMITTED) { + publishResponseSubmittedEvent(link, companyId, now); + } return new WorkerResponseSubmitResult(responseId, now); } + private void publishResponseSubmittedEvent(WorkerLink link, UUID companyId, Instant now) { + taskRepository.findByIdAndCompanyId(link.taskId(), companyId).ifPresent(task -> + eventPublisher.publish(WorkerResponseDomainEvents.responseSubmitted( + uuidGenerator.generate(), task, companyId, now + )) + ); + } + private boolean requiresHrReview(WorkerResponseType responseType) { return responseType != WorkerResponseType.ACKNOWLEDGED; } From 73a66b689ee4a648afec95a1c8421f48ee5a30aa Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 9 Aug 2026 23:48:31 +0900 Subject: [PATCH 30/36] =?UTF-8?q?test(notification):=20WorkerResponseSubmi?= =?UTF-8?q?tted=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/NotificationEventHandler.java | 23 +++++++++++- .../WorkerResponseDomainEvents.java | 3 +- .../application/WorkerResponseService.java | 8 +++-- .../NotificationEventHandlerTest.java | 35 +++++++++++++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java b/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java index df1394ca..847fe5ed 100644 --- a/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java +++ b/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java @@ -20,7 +20,9 @@ public final class NotificationEventHandler implements DomainEventHandler { private static final String HANDLER_NAME = "notificationFromTaskEvents"; private static final String TASK_CREATED = "TaskCreated"; private static final String APPROVAL_REQUESTED = "ApprovalRequested"; - private static final Set SUPPORTED_EVENTS = Set.of(TASK_CREATED, APPROVAL_REQUESTED); + private static final String WORKER_RESPONSE_SUBMITTED = "WorkerResponseSubmitted"; + private static final Set SUPPORTED_EVENTS = + Set.of(TASK_CREATED, APPROVAL_REQUESTED, WORKER_RESPONSE_SUBMITTED); private final TaskRepository taskRepository; private final NotificationRepository notificationRepository; @@ -58,6 +60,8 @@ public void handle(DomainEventEnvelope event) { handleTaskCreated(event); } else if (APPROVAL_REQUESTED.equals(event.eventType())) { handleApprovalRequested(event); + } else if (WORKER_RESPONSE_SUBMITTED.equals(event.eventType())) { + handleWorkerResponseSubmitted(event); } } @@ -99,4 +103,21 @@ private void handleApprovalRequested(DomainEventEnvelope event) { ); notificationRepository.insert(notification); } + + private void handleWorkerResponseSubmitted(DomainEventEnvelope event) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId()); + Object taskTitle = event.payload().values().get("task_title"); + + Notification notification = Notification.create( + uuidGenerator.generate(), + event.companyId(), + event.actorId(), + NotificationTargetType.TASK, + event.aggregateId(), + "문서 제출이 완료됐습니다: " + taskTitle, + event.occurredAt(), + clock.instant() + ); + notificationRepository.insert(notification); + } } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDomainEvents.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDomainEvents.java index e32553f2..762d67c1 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDomainEvents.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDomainEvents.java @@ -23,6 +23,7 @@ private WorkerResponseDomainEvents() { static DomainEventEnvelope responseSubmitted( UUID eventId, + UUID responseId, Task task, UUID companyId, Instant occurredAt @@ -36,7 +37,7 @@ static DomainEventEnvelope responseSubmitted( companyId, EventActorType.WORKER_LINK, task.createdBy(), - null, + responseId.toString(), null, occurredAt, SafeEventPayload.of( diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java index e1f6ae67..cc689318 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java @@ -148,15 +148,17 @@ public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, Re now )); if (command.responseType() == WorkerResponseType.DOCUMENT_SUBMITTED) { - publishResponseSubmittedEvent(link, companyId, now); + publishResponseSubmittedEvent(link, responseId, companyId, now); } return new WorkerResponseSubmitResult(responseId, now); } - private void publishResponseSubmittedEvent(WorkerLink link, UUID companyId, Instant now) { + private void publishResponseSubmittedEvent( + WorkerLink link, UUID responseId, UUID companyId, Instant now + ) { taskRepository.findByIdAndCompanyId(link.taskId(), companyId).ifPresent(task -> eventPublisher.publish(WorkerResponseDomainEvents.responseSubmitted( - uuidGenerator.generate(), task, companyId, now + uuidGenerator.generate(), responseId, task, companyId, now )) ); } diff --git a/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java b/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java index b7ec1270..3180bdd1 100644 --- a/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java +++ b/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java @@ -49,6 +49,7 @@ class NotificationEventHandlerTest { void supportsTaskCreatedAndApprovalRequested() { assertThat(handler.supports("TaskCreated")).isTrue(); assertThat(handler.supports("ApprovalRequested")).isTrue(); + assertThat(handler.supports("WorkerResponseSubmitted")).isTrue(); assertThat(handler.supports("TaskCancelled")).isFalse(); assertThat(handler.supports("SomethingElse")).isFalse(); } @@ -87,6 +88,20 @@ void createsNotificationForApprovalRequestedAndSendsToRequester() { assertThat(notification.title()).contains("재계약 준비"); } + @Test + void createsNotificationForWorkerResponseSubmitted() { + when(uuidGenerator.generate()).thenReturn(NEW_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + + handler.handle(workerResponseSubmittedEvent()); + + verify(notificationRepository).insert(captor.capture()); + Notification notification = captor.getValue(); + assertThat(notification.userId()).isEqualTo(ACTOR_ID); + assertThat(notification.title()).contains("문서 제출이 완료됐습니다"); + assertThat(notification.title()).contains("재계약 준비"); + } + private DomainEventEnvelope taskCreatedEvent() { return new DomainEventEnvelope( UUID.randomUUID(), @@ -124,6 +139,26 @@ private DomainEventEnvelope approvalRequestedEvent() { ); } + private DomainEventEnvelope workerResponseSubmittedEvent() { + return new DomainEventEnvelope( + UUID.randomUUID(), + "WorkerResponseSubmitted", + "1", + "Task", + TASK_ID, + COMPANY_ID, + EventActorType.WORKER_LINK, + ACTOR_ID, + "worker-response-submit", + "12345678901234567890123456789012", + NOW, + SafeEventPayload.of( + Set.of("task_title", "task_type"), + Map.of("task_title", "재계약 준비", "task_type", TaskType.RECONTRACT) + ) + ); + } + private Task aiCandidateTask() { return Task.create( TASK_ID, COMPANY_ID, UUID.randomUUID(), UUID.randomUUID(), From b7c29ad96a0bdeca50801f705a05b794d11cb8df Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 10 Aug 2026 00:21:59 +0900 Subject: [PATCH 31/36] =?UTF-8?q?feat(task):=20updateChecklistItem()?= =?UTF-8?q?=EC=97=90=EC=84=9C=20NEEDS=5FINFO=20=EC=A0=84=EC=9D=B4=20?= =?UTF-8?q?=EC=8B=9C=20TaskNeedsInfo=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../task/application/TaskDomainEvents.java | 33 +++++++++++++++++++ .../task/application/TaskWorkflowService.java | 3 ++ 2 files changed, 36 insertions(+) diff --git a/src/main/java/com/fowoco/server/task/application/TaskDomainEvents.java b/src/main/java/com/fowoco/server/task/application/TaskDomainEvents.java index aa5bf74a..0b2b5f0d 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskDomainEvents.java +++ b/src/main/java/com/fowoco/server/task/application/TaskDomainEvents.java @@ -28,6 +28,10 @@ final class TaskDomainEvents { "previous_status", "status" ); + private static final Set TASK_NEEDS_INFO_FIELDS = Set.of( + "task_title", + "task_type" + ); private TaskDomainEvents() { } @@ -85,6 +89,35 @@ static DomainEventEnvelope taskCancelled( ); } + /** + * 필수 체크리스트 항목이 미완료로 바뀌거나 필수 슬롯이 채워지지 않아 + * Task가 NEEDS_INFO로 전이될 때 발행한다. "문서 보완이 필요하다"는 + * 신호를, 이 재평가를 트리거한 HR 본인에게 알림으로 전달하기 위함이다. + */ + static DomainEventEnvelope taskNeedsInfo( + UUID eventId, + Task task, + ActorContext actor, + RequestMetadata metadata, + Instant occurredAt + ) { + return envelope( + eventId, + "TaskNeedsInfo", + task, + actor, + metadata, + occurredAt, + SafeEventPayload.of( + TASK_NEEDS_INFO_FIELDS, + Map.of( + "task_title", task.title(), + "task_type", task.taskType() + ) + ) + ); + } + private static DomainEventEnvelope envelope( UUID eventId, String eventType, diff --git a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java index 30824324..96e7bd49 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java +++ b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java @@ -386,6 +386,9 @@ public TaskResult updateChecklistItem( now, metadata ); + eventPublisher.publish(TaskDomainEvents.taskNeedsInfo( + uuidGenerator.generate(), savedTask, actor, metadata, now + )); } recordTransitionIfChanged( savedTask, From 167e3069eb8fad93229267bc1155d8b1b9d04a84 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 10 Aug 2026 00:26:17 +0900 Subject: [PATCH 32/36] =?UTF-8?q?test(notification):=20TaskNeedsInfo=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/NotificationEventHandler.java | 22 +++++++++++- .../NotificationEventHandlerTest.java | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java b/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java index 847fe5ed..2f8ce539 100644 --- a/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java +++ b/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java @@ -21,8 +21,9 @@ public final class NotificationEventHandler implements DomainEventHandler { private static final String TASK_CREATED = "TaskCreated"; private static final String APPROVAL_REQUESTED = "ApprovalRequested"; private static final String WORKER_RESPONSE_SUBMITTED = "WorkerResponseSubmitted"; + private static final String TASK_NEEDS_INFO = "TaskNeedsInfo"; private static final Set SUPPORTED_EVENTS = - Set.of(TASK_CREATED, APPROVAL_REQUESTED, WORKER_RESPONSE_SUBMITTED); + Set.of(TASK_CREATED, APPROVAL_REQUESTED, WORKER_RESPONSE_SUBMITTED, TASK_NEEDS_INFO); private final TaskRepository taskRepository; private final NotificationRepository notificationRepository; @@ -62,6 +63,8 @@ public void handle(DomainEventEnvelope event) { handleApprovalRequested(event); } else if (WORKER_RESPONSE_SUBMITTED.equals(event.eventType())) { handleWorkerResponseSubmitted(event); + } else if (TASK_NEEDS_INFO.equals(event.eventType())) { + handleTaskNeedsInfo(event); } } @@ -120,4 +123,21 @@ private void handleWorkerResponseSubmitted(DomainEventEnvelope event) { ); notificationRepository.insert(notification); } + + private void handleTaskNeedsInfo(DomainEventEnvelope event) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId()); + Object taskTitle = event.payload().values().get("task_title"); + + Notification notification = Notification.create( + uuidGenerator.generate(), + event.companyId(), + event.actorId(), + NotificationTargetType.TASK, + event.aggregateId(), + "문서 보완이 필요합니다: " + taskTitle, + event.occurredAt(), + clock.instant() + ); + notificationRepository.insert(notification); + } } diff --git a/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java b/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java index 3180bdd1..05e4e1d4 100644 --- a/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java +++ b/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java @@ -50,6 +50,7 @@ void supportsTaskCreatedAndApprovalRequested() { assertThat(handler.supports("TaskCreated")).isTrue(); assertThat(handler.supports("ApprovalRequested")).isTrue(); assertThat(handler.supports("WorkerResponseSubmitted")).isTrue(); + assertThat(handler.supports("TaskNeedsInfo")).isTrue(); assertThat(handler.supports("TaskCancelled")).isFalse(); assertThat(handler.supports("SomethingElse")).isFalse(); } @@ -102,6 +103,20 @@ void createsNotificationForWorkerResponseSubmitted() { assertThat(notification.title()).contains("재계약 준비"); } + @Test + void createsNotificationForTaskNeedsInfo() { + when(uuidGenerator.generate()).thenReturn(NEW_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + + handler.handle(taskNeedsInfoEvent()); + + verify(notificationRepository).insert(captor.capture()); + Notification notification = captor.getValue(); + assertThat(notification.userId()).isEqualTo(ACTOR_ID); + assertThat(notification.title()).contains("문서 보완이 필요합니다"); + assertThat(notification.title()).contains("재계약 준비"); + } + private DomainEventEnvelope taskCreatedEvent() { return new DomainEventEnvelope( UUID.randomUUID(), @@ -159,6 +174,26 @@ private DomainEventEnvelope workerResponseSubmittedEvent() { ); } + private DomainEventEnvelope taskNeedsInfoEvent() { + return new DomainEventEnvelope( + UUID.randomUUID(), + "TaskNeedsInfo", + "1", + "Task", + TASK_ID, + COMPANY_ID, + EventActorType.HR_USER, + ACTOR_ID, + "req-3", + "12345678901234567890123456789012", + NOW, + SafeEventPayload.of( + Set.of("task_title", "task_type"), + Map.of("task_title", "재계약 준비", "task_type", TaskType.RECONTRACT) + ) + ); + } + private Task aiCandidateTask() { return Task.create( TASK_ID, COMPANY_ID, UUID.randomUUID(), UUID.randomUUID(), From 4af2cdb3c0ad5a7e217add7a6a02b7a96d45dca1 Mon Sep 17 00:00:00 2001 From: chaelin Date: Tue, 11 Aug 2026 23:05:49 +0900 Subject: [PATCH 33/36] feat(notification): Scheduler --- .../application/port/CompanyRepository.java | 4 +- .../persistence/JpaCompanyRepository.java | 8 ++ .../DueSoonNotificationScheduler.java | 75 ++++++++++++ .../task/application/port/TaskRepository.java | 1 + .../persistence/JpaTaskRepository.java | 7 ++ .../SpringDataTaskJpaRepository.java | 15 +++ .../seed/DemoAuthSeedRunnerTest.java | 6 + .../DashboardQueryServiceTest.java | 5 + .../DueSoonNotificationSchedulerTest.java | 114 ++++++++++++++++++ .../seed/DemoWorkerSeedRunnerTest.java | 5 + 10 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/fowoco/server/notification/application/DueSoonNotificationScheduler.java create mode 100644 src/test/java/com/fowoco/server/notification/application/DueSoonNotificationSchedulerTest.java diff --git a/src/main/java/com/fowoco/server/company/application/port/CompanyRepository.java b/src/main/java/com/fowoco/server/company/application/port/CompanyRepository.java index 12331773..ad75780d 100644 --- a/src/main/java/com/fowoco/server/company/application/port/CompanyRepository.java +++ b/src/main/java/com/fowoco/server/company/application/port/CompanyRepository.java @@ -1,12 +1,12 @@ package com.fowoco.server.company.application.port; import com.fowoco.server.company.domain.Company; +import java.util.List; import java.util.Optional; import java.util.UUID; public interface CompanyRepository { - Optional findById(UUID companyId); - void insert(Company company); + List findAllIds(); } diff --git a/src/main/java/com/fowoco/server/company/infrastructure/persistence/JpaCompanyRepository.java b/src/main/java/com/fowoco/server/company/infrastructure/persistence/JpaCompanyRepository.java index 78038033..1d179e7b 100644 --- a/src/main/java/com/fowoco/server/company/infrastructure/persistence/JpaCompanyRepository.java +++ b/src/main/java/com/fowoco/server/company/infrastructure/persistence/JpaCompanyRepository.java @@ -3,6 +3,7 @@ import com.fowoco.server.company.application.port.CompanyRepository; import com.fowoco.server.company.domain.Company; import jakarta.persistence.EntityManager; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -30,4 +31,11 @@ public void insert(Company company) { entityManager.persist(CompanyJpaEntity.fromDomain(company)); entityManager.flush(); } + + @Override + public List findAllIds() { + return entityManager + .createQuery("SELECT company.companyId FROM CompanyJpaEntity company", UUID.class) + .getResultList(); + } } diff --git a/src/main/java/com/fowoco/server/notification/application/DueSoonNotificationScheduler.java b/src/main/java/com/fowoco/server/notification/application/DueSoonNotificationScheduler.java new file mode 100644 index 00000000..de489eb2 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/DueSoonNotificationScheduler.java @@ -0,0 +1,75 @@ +package com.fowoco.server.notification.application; + +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.company.application.port.CompanyRepository; +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.notification.domain.NotificationTargetType; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.UUID; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class DueSoonNotificationScheduler { + + private static final int UPCOMING_DAYS = 7; + + private final CompanyRepository companyRepository; + private final TaskRepository taskRepository; + private final NotificationRepository notificationRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public DueSoonNotificationScheduler( + CompanyRepository companyRepository, + TaskRepository taskRepository, + NotificationRepository notificationRepository, + TenantDatabaseContext tenantDatabaseContext, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.companyRepository = companyRepository; + this.taskRepository = taskRepository; + this.notificationRepository = notificationRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void notifyDueSoonTasks() { + LocalDate today = LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))); + LocalDate windowEnd = today.plusDays(UPCOMING_DAYS); + + companyRepository.findAllIds().forEach(companyId -> + processCompany(companyId, today, windowEnd) + ); + } + + private void processCompany(UUID companyId, LocalDate today, LocalDate windowEnd) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + taskRepository.findOpenTasksDueBetween(companyId, today, windowEnd) + .forEach(this::createDueSoonNotification); + } + + private void createDueSoonNotification(Task task) { + Notification notification = Notification.create( + uuidGenerator.generate(), + task.companyId(), + task.createdBy(), + NotificationTargetType.TASK, + task.taskId(), + "마감이 임박했습니다: " + task.title(), + clock.instant(), + clock.instant() + ); + notificationRepository.insert(notification); + } +} 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 18bb6033..34c6b937 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 @@ -17,6 +17,7 @@ public interface TaskRepository { long countByCompanyIdAndStatus(UUID companyId, TaskStatus status); long countOpenTasksDueOn(UUID companyId, LocalDate dueDate); long countOpenTasksByCompanyId(UUID companyId); + List findOpenTasksDueBetween(UUID companyId, LocalDate from, LocalDate to); 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 3c78bec1..7c906b04 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 @@ -78,6 +78,13 @@ public long countOpenTasksByCompanyId(UUID companyId) { return repository.countOpenTasksByCompanyId(companyId); } + @Override + public List findOpenTasksDueBetween(UUID companyId, LocalDate from, LocalDate to) { + return repository.findOpenTasksDueBetween(companyId, from, to).stream() + .map(TaskJpaEntity::toDomain) + .toList(); + } + @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 e4851abd..fedb3df9 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 @@ -82,4 +82,19 @@ SELECT COUNT(task) AND task.status <> com.fowoco.server.task.domain.TaskStatus.CANCELLED """) long countOpenTasksByCompanyId(@Param("companyId") UUID companyId); + + @Query(""" + SELECT task + FROM TaskJpaEntity task + WHERE task.companyId = :companyId + AND task.dueDate BETWEEN :fromDate AND :toDate + AND task.status <> com.fowoco.server.task.domain.TaskStatus.COMPLETED + AND task.status <> com.fowoco.server.task.domain.TaskStatus.CANCELLED + ORDER BY task.dueDate ASC + """) + java.util.List findOpenTasksDueBetween( + @Param("companyId") UUID companyId, + @Param("fromDate") LocalDate fromDate, + @Param("toDate") LocalDate toDate + ); } diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java index 1161c1f5..97fe3ccd 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java @@ -16,6 +16,7 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.boot.DefaultApplicationArguments; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -219,6 +220,11 @@ public void insert(Company company) { throw new IllegalStateException("duplicate company"); } } + + @Override + public List findAllIds() { + return List.copyOf(companies.keySet()); + } } private static final class InMemoryUserAccountRepository implements UserAccountRepository { diff --git a/src/test/java/com/fowoco/server/dashboard/application/DashboardQueryServiceTest.java b/src/test/java/com/fowoco/server/dashboard/application/DashboardQueryServiceTest.java index c6fa5d39..ea1a46b2 100644 --- a/src/test/java/com/fowoco/server/dashboard/application/DashboardQueryServiceTest.java +++ b/src/test/java/com/fowoco/server/dashboard/application/DashboardQueryServiceTest.java @@ -88,6 +88,11 @@ public long countOpenTasksDueOn(UUID companyId, LocalDate dueDate) { public long countOpenTasksByCompanyId(UUID companyId) { return 0; } + + @Override + public List findOpenTasksDueBetween(UUID companyId, LocalDate from, LocalDate to) { + return List.of(); + } } private static final class NoopWorkerRepository implements WorkerRepository { diff --git a/src/test/java/com/fowoco/server/notification/application/DueSoonNotificationSchedulerTest.java b/src/test/java/com/fowoco/server/notification/application/DueSoonNotificationSchedulerTest.java new file mode 100644 index 00000000..bdc605b1 --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/application/DueSoonNotificationSchedulerTest.java @@ -0,0 +1,114 @@ +package com.fowoco.server.notification.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.company.application.port.CompanyRepository; +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class DueSoonNotificationSchedulerTest { + + private static final UUID COMPANY_A = UUID.fromString("50000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("50000000-0000-0000-0000-000000000002"); + private static final UUID CREATOR_ID = UUID.fromString("51000000-0000-0000-0000-000000000001"); + private static final UUID NEW_ID = UUID.fromString("99000000-0000-0000-0000-000000000001"); + private static final Instant NOW = Instant.parse("2026-08-10T03:00:00Z"); + + private final CompanyRepository companyRepository = mock(CompanyRepository.class); + private final TaskRepository taskRepository = mock(TaskRepository.class); + private final NotificationRepository notificationRepository = mock(NotificationRepository.class); + private final TenantDatabaseContext tenantDatabaseContext = mock(TenantDatabaseContext.class); + private final UuidGenerator uuidGenerator = mock(UuidGenerator.class); + private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + + private final DueSoonNotificationScheduler scheduler = new DueSoonNotificationScheduler( + companyRepository, taskRepository, notificationRepository, tenantDatabaseContext, uuidGenerator, clock + ); + + @Test + void createsNotificationForEachDueSoonTaskPerCompany() { + when(companyRepository.findAllIds()).thenReturn(List.of(COMPANY_A, COMPANY_B)); + Task taskA = dueSoonTask(COMPANY_A); + Task taskB1 = dueSoonTask(COMPANY_B); + Task taskB2 = dueSoonTask(COMPANY_B); + when(taskRepository.findOpenTasksDueBetween( + org.mockito.ArgumentMatchers.eq(COMPANY_A), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + )).thenReturn(List.of(taskA)); + when(taskRepository.findOpenTasksDueBetween( + org.mockito.ArgumentMatchers.eq(COMPANY_B), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + )).thenReturn(List.of(taskB1, taskB2)); + when(uuidGenerator.generate()).thenReturn(NEW_ID); + + scheduler.notifyDueSoonTasks(); + + verify(notificationRepository, times(3)).insert(org.mockito.ArgumentMatchers.any(Notification.class)); + } + + @Test + void notificationGoesToTaskCreator() { + when(companyRepository.findAllIds()).thenReturn(List.of(COMPANY_A)); + Task task = dueSoonTask(COMPANY_A); + when(taskRepository.findOpenTasksDueBetween( + org.mockito.ArgumentMatchers.eq(COMPANY_A), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + )).thenReturn(List.of(task)); + when(uuidGenerator.generate()).thenReturn(NEW_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + + scheduler.notifyDueSoonTasks(); + + verify(notificationRepository).insert(captor.capture()); + Notification notification = captor.getValue(); + assertThat(notification.userId()).isEqualTo(CREATOR_ID); + assertThat(notification.title()).contains("마감이 임박했습니다"); + } + + @Test + void queriesUsingKoreaDateNotUtcDate() { + when(companyRepository.findAllIds()).thenReturn(List.of(COMPANY_A)); + when(taskRepository.findOpenTasksDueBetween( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + )).thenReturn(List.of()); + ArgumentCaptor fromCaptor = ArgumentCaptor.forClass(LocalDate.class); + ArgumentCaptor toCaptor = ArgumentCaptor.forClass(LocalDate.class); + + scheduler.notifyDueSoonTasks(); + + verify(taskRepository).findOpenTasksDueBetween( + org.mockito.ArgumentMatchers.eq(COMPANY_A), fromCaptor.capture(), toCaptor.capture() + ); + assertThat(fromCaptor.getValue()).isEqualTo(LocalDate.of(2026, 8, 10)); + assertThat(toCaptor.getValue()).isEqualTo(LocalDate.of(2026, 8, 17)); + } + + private Task dueSoonTask(UUID companyId) { + return Task.create( + UUID.randomUUID(), companyId, UUID.randomUUID(), UUID.randomUUID(), + TaskType.RECONTRACT, "WF-CON-001", "0.2.0", + "재계약 준비", "설명", "{}", "a".repeat(64), + TaskSource.MANUAL, TaskStatus.DRAFT, LocalDate.of(2026, 8, 14), + CREATOR_ID, NOW + ); + } +} diff --git a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java index 07758f64..ea92a720 100644 --- a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java @@ -153,6 +153,11 @@ public void insert(Company company) { throw new IllegalStateException("duplicate company"); } } + + @Override + public List findAllIds() { + return List.copyOf(companies.keySet()); + } } private static final class InMemoryWorkerRepository implements WorkerRepository { From 11ab4a2953ff3e910b6100daf8f8418ed070fea9 Mon Sep 17 00:00:00 2001 From: chaelin Date: Wed, 12 Aug 2026 01:00:14 +0900 Subject: [PATCH 34/36] =?UTF-8?q?fix(test):=20RenewalExecutionIntegrationT?= =?UTF-8?q?est=20=EC=A0=95=EB=A6=AC=20=EB=AA=A9=EB=A1=9D=EC=97=90=20event?= =?UTF-8?q?=5Fpublication,=20event=5Fconsumption=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/fowoco/server/task/RenewalExecutionIntegrationTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java b/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java index 13fc840a..7a83c72c 100644 --- a/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java +++ b/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java @@ -99,6 +99,8 @@ void resetAndSeed() { )); capturedRequest.set(null); jdbcTemplate.update("DELETE FROM document_request_draft"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); jdbcTemplate.update("DELETE FROM audit_event"); jdbcTemplate.update("DELETE FROM approval_request"); jdbcTemplate.update("DELETE FROM task_transition_history"); From eb2c3dbf4c600fc8ddb7a35c67ef5df90177745e Mon Sep 17 00:00:00 2001 From: chaelin Date: Wed, 12 Aug 2026 03:06:52 +0900 Subject: [PATCH 35/36] =?UTF-8?q?fix(notification):=20DueSoonCompanyNotifi?= =?UTF-8?q?er=EB=A1=9C=20=ED=9A=8C=EC=82=AC=EB=B3=84=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EB=B6=84=EB=A6=AC,=20@Transactional(REQUIRES=5FNEW?= =?UTF-8?q?)=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/DueSoonCompanyNotifier.java | 60 +++++++++++++++++++ .../DueSoonNotificationScheduler.java | 45 ++------------ 2 files changed, 64 insertions(+), 41 deletions(-) create mode 100644 src/main/java/com/fowoco/server/notification/application/DueSoonCompanyNotifier.java diff --git a/src/main/java/com/fowoco/server/notification/application/DueSoonCompanyNotifier.java b/src/main/java/com/fowoco/server/notification/application/DueSoonCompanyNotifier.java new file mode 100644 index 00000000..5417f0b9 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/DueSoonCompanyNotifier.java @@ -0,0 +1,60 @@ +package com.fowoco.server.notification.application; + +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.notification.domain.NotificationTargetType; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import java.time.Clock; +import java.time.LocalDate; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DueSoonCompanyNotifier { + + private final TaskRepository taskRepository; + private final NotificationRepository notificationRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public DueSoonCompanyNotifier( + TaskRepository taskRepository, + NotificationRepository notificationRepository, + TenantDatabaseContext tenantDatabaseContext, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.taskRepository = taskRepository; + this.notificationRepository = notificationRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void processCompany(UUID companyId, LocalDate today, LocalDate windowEnd) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + taskRepository.findOpenTasksDueBetween(companyId, today, windowEnd) + .forEach(this::createDueSoonNotification); + } + + private void createDueSoonNotification(Task task) { + Notification notification = Notification.create( + uuidGenerator.generate(), + task.companyId(), + task.createdBy(), + NotificationTargetType.TASK, + task.taskId(), + "마감이 임박했습니다: " + task.title(), + clock.instant(), + clock.instant() + ); + notificationRepository.insert(notification); + } +} diff --git a/src/main/java/com/fowoco/server/notification/application/DueSoonNotificationScheduler.java b/src/main/java/com/fowoco/server/notification/application/DueSoonNotificationScheduler.java index de489eb2..c637a368 100644 --- a/src/main/java/com/fowoco/server/notification/application/DueSoonNotificationScheduler.java +++ b/src/main/java/com/fowoco/server/notification/application/DueSoonNotificationScheduler.java @@ -1,17 +1,9 @@ package com.fowoco.server.notification.application; -import com.fowoco.server.common.id.UuidGenerator; -import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.company.application.port.CompanyRepository; -import com.fowoco.server.notification.application.port.NotificationRepository; -import com.fowoco.server.notification.domain.Notification; -import com.fowoco.server.notification.domain.NotificationTargetType; -import com.fowoco.server.task.application.port.TaskRepository; -import com.fowoco.server.task.domain.Task; import java.time.Clock; import java.time.LocalDate; import java.time.ZoneId; -import java.util.UUID; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -21,25 +13,16 @@ public class DueSoonNotificationScheduler { private static final int UPCOMING_DAYS = 7; private final CompanyRepository companyRepository; - private final TaskRepository taskRepository; - private final NotificationRepository notificationRepository; - private final TenantDatabaseContext tenantDatabaseContext; - private final UuidGenerator uuidGenerator; + private final DueSoonCompanyNotifier companyNotifier; private final Clock clock; public DueSoonNotificationScheduler( CompanyRepository companyRepository, - TaskRepository taskRepository, - NotificationRepository notificationRepository, - TenantDatabaseContext tenantDatabaseContext, - UuidGenerator uuidGenerator, + DueSoonCompanyNotifier companyNotifier, Clock clock ) { this.companyRepository = companyRepository; - this.taskRepository = taskRepository; - this.notificationRepository = notificationRepository; - this.tenantDatabaseContext = tenantDatabaseContext; - this.uuidGenerator = uuidGenerator; + this.companyNotifier = companyNotifier; this.clock = clock; } @@ -49,27 +32,7 @@ public void notifyDueSoonTasks() { LocalDate windowEnd = today.plusDays(UPCOMING_DAYS); companyRepository.findAllIds().forEach(companyId -> - processCompany(companyId, today, windowEnd) + companyNotifier.processCompany(companyId, today, windowEnd) ); } - - private void processCompany(UUID companyId, LocalDate today, LocalDate windowEnd) { - tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); - taskRepository.findOpenTasksDueBetween(companyId, today, windowEnd) - .forEach(this::createDueSoonNotification); - } - - private void createDueSoonNotification(Task task) { - Notification notification = Notification.create( - uuidGenerator.generate(), - task.companyId(), - task.createdBy(), - NotificationTargetType.TASK, - task.taskId(), - "마감이 임박했습니다: " + task.title(), - clock.instant(), - clock.instant() - ); - notificationRepository.insert(notification); - } } From 3b0b188f7ec3666aae25a7141a60a4bf1e508ae4 Mon Sep 17 00:00:00 2001 From: chaelin Date: Wed, 12 Aug 2026 03:55:02 +0900 Subject: [PATCH 36/36] =?UTF-8?q?test(notification):=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...nNotificationSchedulerIntegrationTest.java | 106 ++++++++++++++++++ .../DueSoonCompanyNotifierTest.java | 97 ++++++++++++++++ .../DueSoonNotificationSchedulerTest.java | 81 +++---------- 3 files changed, 219 insertions(+), 65 deletions(-) create mode 100644 src/test/java/com/fowoco/server/notification/DueSoonNotificationSchedulerIntegrationTest.java create mode 100644 src/test/java/com/fowoco/server/notification/application/DueSoonCompanyNotifierTest.java diff --git a/src/test/java/com/fowoco/server/notification/DueSoonNotificationSchedulerIntegrationTest.java b/src/test/java/com/fowoco/server/notification/DueSoonNotificationSchedulerIntegrationTest.java new file mode 100644 index 00000000..aadd8859 --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/DueSoonNotificationSchedulerIntegrationTest.java @@ -0,0 +1,106 @@ +package com.fowoco.server.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.notification.application.DueSoonNotificationScheduler; +import java.time.LocalDate; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DueSoonNotificationSchedulerIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("56000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_A = UUID.fromString("57000000-0000-0000-0000-000000000001"); + private static final UUID CREATOR_ID = UUID.fromString("58000000-0000-0000-0000-000000000001"); + private static final UUID CASE_ID = UUID.fromString("59000000-0000-0000-0000-000000000001"); + @Autowired + private DueSoonNotificationScheduler scheduler; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeAll + void seedCompanyAndWorker() { + jdbcTemplate.update( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + COMPANY_A, "마감임박스케줄러테스트사업장" + ); + jdbcTemplate.update( + """ + INSERT INTO worker (worker_id, company_id, display_name, work_status) + VALUES (?, ?, ?, 'ACTIVE') + """, + WORKER_A, COMPANY_A, "테스트근로자" + ); + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, 'x', 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + CREATOR_ID, COMPANY_A, "duesoon.creator@example.com", "duesoon.creator@example.com" + ); + } + + @BeforeEach + void resetTaskAndNotification() { + jdbcTemplate.update("DELETE FROM notification"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + } + + @Test + void notifyDueSoonTasksRunsWithoutTransactionErrorAndCreatesNotification() { + UUID taskId = UUID.randomUUID(); + jdbcTemplate.update( + """ + INSERT INTO task ( + task_id, company_id, target_type, worker_id, case_id, task_type, + workflow_id, workflow_catalog_version, title, description, + business_data_json, critical_fingerprint, source, status, + due_date, content_revision, version, created_by, updated_by, + created_at, updated_at + ) VALUES (?, ?, 'WORKER', ?, ?, 'RECONTRACT', 'WF-CON-001', '0.2.0', + '마감임박테스트업무', '설명', '{}', ?, 'MANUAL', 'DRAFT', + ?, 0, 0, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + taskId, COMPANY_A, WORKER_A, CASE_ID, "a".repeat(64), + java.sql.Date.valueOf(LocalDate.now().plusDays(3)), + CREATOR_ID, CREATOR_ID + ); + + scheduler.notifyDueSoonTasks(); + + Long notificationCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM notification WHERE company_id = ?", Long.class, COMPANY_A + ); + assertThat(notificationCount).isEqualTo(1L); + + String title = jdbcTemplate.queryForObject( + "SELECT title FROM notification WHERE company_id = ?", String.class, COMPANY_A + ); + UUID notifiedUserId = jdbcTemplate.queryForObject( + "SELECT user_id FROM notification WHERE company_id = ?", UUID.class, COMPANY_A + ); + System.out.println("=== DueSoonNotificationScheduler 실제 생성 결과 ==="); + System.out.println("title: " + title); + System.out.println("user_id (Task 생성자): " + notifiedUserId); + System.out.println("expected creator_id : " + CREATOR_ID); + assertThat(title).contains("마감이 임박했습니다"); + assertThat(notifiedUserId).isEqualTo(CREATOR_ID); + } +} diff --git a/src/test/java/com/fowoco/server/notification/application/DueSoonCompanyNotifierTest.java b/src/test/java/com/fowoco/server/notification/application/DueSoonCompanyNotifierTest.java new file mode 100644 index 00000000..083156f3 --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/application/DueSoonCompanyNotifierTest.java @@ -0,0 +1,97 @@ +package com.fowoco.server.notification.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class DueSoonCompanyNotifierTest { + + private static final UUID COMPANY_A = UUID.fromString("50000000-0000-0000-0000-000000000001"); + private static final UUID CREATOR_ID = UUID.fromString("51000000-0000-0000-0000-000000000001"); + private static final UUID NEW_ID = UUID.fromString("99000000-0000-0000-0000-000000000001"); + private static final Instant NOW = Instant.parse("2026-08-10T03:00:00Z"); + + private final TaskRepository taskRepository = mock(TaskRepository.class); + private final NotificationRepository notificationRepository = mock(NotificationRepository.class); + private final TenantDatabaseContext tenantDatabaseContext = mock(TenantDatabaseContext.class); + private final UuidGenerator uuidGenerator = mock(UuidGenerator.class); + private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + + private final DueSoonCompanyNotifier notifier = new DueSoonCompanyNotifier( + taskRepository, notificationRepository, tenantDatabaseContext, uuidGenerator, clock + ); + + @Test + void createsNotificationForEachDueSoonTask() { + Task taskA = dueSoonTask(); + Task taskB = dueSoonTask(); + when(taskRepository.findOpenTasksDueBetween( + org.mockito.ArgumentMatchers.eq(COMPANY_A), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + )).thenReturn(List.of(taskA, taskB)); + when(uuidGenerator.generate()).thenReturn(NEW_ID); + + notifier.processCompany(COMPANY_A, LocalDate.of(2026, 8, 10), LocalDate.of(2026, 8, 17)); + + verify(notificationRepository, times(2)).insert(org.mockito.ArgumentMatchers.any(Notification.class)); + } + + @Test + void notificationGoesToTaskCreator() { + Task task = dueSoonTask(); + when(taskRepository.findOpenTasksDueBetween( + org.mockito.ArgumentMatchers.eq(COMPANY_A), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + )).thenReturn(List.of(task)); + when(uuidGenerator.generate()).thenReturn(NEW_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + + notifier.processCompany(COMPANY_A, LocalDate.of(2026, 8, 10), LocalDate.of(2026, 8, 17)); + + verify(notificationRepository).insert(captor.capture()); + Notification notification = captor.getValue(); + assertThat(notification.userId()).isEqualTo(CREATOR_ID); + assertThat(notification.title()).contains("마감이 임박했습니다"); + } + + @Test + void setsTenantContextForTheGivenCompany() { + when(taskRepository.findOpenTasksDueBetween( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + )).thenReturn(List.of()); + + notifier.processCompany(COMPANY_A, LocalDate.of(2026, 8, 10), LocalDate.of(2026, 8, 17)); + + verify(tenantDatabaseContext).setCompanyIdForCurrentTransaction(COMPANY_A); + } + + private Task dueSoonTask() { + return Task.create( + UUID.randomUUID(), COMPANY_A, UUID.randomUUID(), UUID.randomUUID(), + TaskType.RECONTRACT, "WF-CON-001", "0.2.0", + "재계약 준비", "설명", "{}", "a".repeat(64), + TaskSource.MANUAL, TaskStatus.DRAFT, LocalDate.of(2026, 8, 14), + CREATOR_ID, NOW + ); + } +} diff --git a/src/test/java/com/fowoco/server/notification/application/DueSoonNotificationSchedulerTest.java b/src/test/java/com/fowoco/server/notification/application/DueSoonNotificationSchedulerTest.java index bdc605b1..0061a2d2 100644 --- a/src/test/java/com/fowoco/server/notification/application/DueSoonNotificationSchedulerTest.java +++ b/src/test/java/com/fowoco/server/notification/application/DueSoonNotificationSchedulerTest.java @@ -1,21 +1,11 @@ package com.fowoco.server.notification.application; -import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.fowoco.server.common.id.UuidGenerator; -import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.company.application.port.CompanyRepository; -import com.fowoco.server.notification.application.port.NotificationRepository; -import com.fowoco.server.notification.domain.Notification; -import com.fowoco.server.task.application.port.TaskRepository; -import com.fowoco.server.task.domain.Task; -import com.fowoco.server.task.domain.TaskSource; -import com.fowoco.server.task.domain.TaskStatus; -import com.fowoco.server.task.domain.TaskType; import java.time.Clock; import java.time.Instant; import java.time.LocalDate; @@ -29,86 +19,47 @@ class DueSoonNotificationSchedulerTest { private static final UUID COMPANY_A = UUID.fromString("50000000-0000-0000-0000-000000000001"); private static final UUID COMPANY_B = UUID.fromString("50000000-0000-0000-0000-000000000002"); - private static final UUID CREATOR_ID = UUID.fromString("51000000-0000-0000-0000-000000000001"); - private static final UUID NEW_ID = UUID.fromString("99000000-0000-0000-0000-000000000001"); + // KST 기준 2026-08-10 정오 (경계값 문제를 피하기 위해 자정 근처는 피함) private static final Instant NOW = Instant.parse("2026-08-10T03:00:00Z"); private final CompanyRepository companyRepository = mock(CompanyRepository.class); - private final TaskRepository taskRepository = mock(TaskRepository.class); - private final NotificationRepository notificationRepository = mock(NotificationRepository.class); - private final TenantDatabaseContext tenantDatabaseContext = mock(TenantDatabaseContext.class); - private final UuidGenerator uuidGenerator = mock(UuidGenerator.class); + private final DueSoonCompanyNotifier companyNotifier = mock(DueSoonCompanyNotifier.class); private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); private final DueSoonNotificationScheduler scheduler = new DueSoonNotificationScheduler( - companyRepository, taskRepository, notificationRepository, tenantDatabaseContext, uuidGenerator, clock + companyRepository, companyNotifier, clock ); @Test - void createsNotificationForEachDueSoonTaskPerCompany() { + void processesEachCompanySeparately() { when(companyRepository.findAllIds()).thenReturn(List.of(COMPANY_A, COMPANY_B)); - Task taskA = dueSoonTask(COMPANY_A); - Task taskB1 = dueSoonTask(COMPANY_B); - Task taskB2 = dueSoonTask(COMPANY_B); - when(taskRepository.findOpenTasksDueBetween( - org.mockito.ArgumentMatchers.eq(COMPANY_A), - org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() - )).thenReturn(List.of(taskA)); - when(taskRepository.findOpenTasksDueBetween( - org.mockito.ArgumentMatchers.eq(COMPANY_B), - org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() - )).thenReturn(List.of(taskB1, taskB2)); - when(uuidGenerator.generate()).thenReturn(NEW_ID); scheduler.notifyDueSoonTasks(); - verify(notificationRepository, times(3)).insert(org.mockito.ArgumentMatchers.any(Notification.class)); - } - - @Test - void notificationGoesToTaskCreator() { - when(companyRepository.findAllIds()).thenReturn(List.of(COMPANY_A)); - Task task = dueSoonTask(COMPANY_A); - when(taskRepository.findOpenTasksDueBetween( + verify(companyNotifier, times(1)).processCompany( org.mockito.ArgumentMatchers.eq(COMPANY_A), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() - )).thenReturn(List.of(task)); - when(uuidGenerator.generate()).thenReturn(NEW_ID); - ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); - - scheduler.notifyDueSoonTasks(); - - verify(notificationRepository).insert(captor.capture()); - Notification notification = captor.getValue(); - assertThat(notification.userId()).isEqualTo(CREATOR_ID); - assertThat(notification.title()).contains("마감이 임박했습니다"); + ); + verify(companyNotifier, times(1)).processCompany( + org.mockito.ArgumentMatchers.eq(COMPANY_B), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + ); } @Test - void queriesUsingKoreaDateNotUtcDate() { + void usesKoreaDateNotUtcDateForWindow() { when(companyRepository.findAllIds()).thenReturn(List.of(COMPANY_A)); - when(taskRepository.findOpenTasksDueBetween( - org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() - )).thenReturn(List.of()); ArgumentCaptor fromCaptor = ArgumentCaptor.forClass(LocalDate.class); ArgumentCaptor toCaptor = ArgumentCaptor.forClass(LocalDate.class); scheduler.notifyDueSoonTasks(); - verify(taskRepository).findOpenTasksDueBetween( + verify(companyNotifier).processCompany( org.mockito.ArgumentMatchers.eq(COMPANY_A), fromCaptor.capture(), toCaptor.capture() ); - assertThat(fromCaptor.getValue()).isEqualTo(LocalDate.of(2026, 8, 10)); - assertThat(toCaptor.getValue()).isEqualTo(LocalDate.of(2026, 8, 17)); - } - - private Task dueSoonTask(UUID companyId) { - return Task.create( - UUID.randomUUID(), companyId, UUID.randomUUID(), UUID.randomUUID(), - TaskType.RECONTRACT, "WF-CON-001", "0.2.0", - "재계약 준비", "설명", "{}", "a".repeat(64), - TaskSource.MANUAL, TaskStatus.DRAFT, LocalDate.of(2026, 8, 14), - CREATOR_ID, NOW - ); + // NOW = 2026-08-10T03:00:00Z = KST 2026-08-10 정오이므로, + // from은 오늘(8/10), to는 7일 후(8/17)여야 한다. + org.assertj.core.api.Assertions.assertThat(fromCaptor.getValue()).isEqualTo(LocalDate.of(2026, 8, 10)); + org.assertj.core.api.Assertions.assertThat(toCaptor.getValue()).isEqualTo(LocalDate.of(2026, 8, 17)); } }