diff --git a/docs/demo-seed-fixture-manifest.md b/docs/demo-seed-fixture-manifest.md index 8a3129c..feeeaa2 100644 --- a/docs/demo-seed-fixture-manifest.md +++ b/docs/demo-seed-fixture-manifest.md @@ -22,7 +22,7 @@ Figma의 재계약·고용기간 연장·체류기간 연장 초안 흐름은 | Figma 의미 | 예약 fixture | 저장 내용 | 현재 노출 | | --- | --- | --- | --- | | 대상 근로자 | Worker `92000000-0000-0000-0000-000000000006` | 응웬반A, `VN`, `vi`, 체류 만료 `D+45` | `GET /api/v1/workers`, `GET /api/v1/workers/{workerId}` | -| 복합 Case | Case ID `94100000-0000-0000-0000-000000000006` | 세 업무의 연속성을 나타내는 공통 식별자 | Task 응답의 `case_id`로 노출; Case 전용 API는 없음 | +| 복합 Case | Case ID `94100000-0000-0000-0000-000000000006` | 세 업무의 연속성과 생성 당시 Workflow Snapshot | Case 목록·Projection API와 Task 응답의 `case_id`로 노출 | | 재계약 조건 검토 | Task `94000000-0000-0000-0000-000000000006` | `RECONTRACT`, `READY_FOR_REVIEW`, candidate order 1 | Task 목록·상세 API | | 후행 고용기간 연장 | Task `94000000-0000-0000-0000-000000000007` | `DRAFT`, candidate order 3, Task 6 의존 | Task 목록·상세 API | | 여권 사본 요청 | Task `94000000-0000-0000-0000-000000000008` | `WAITING_WORKER`, candidate order 2, 제출 기한 7일 | Task 목록·상세 API | @@ -30,8 +30,8 @@ Figma의 재계약·고용기간 연장·체류기간 연장 초안 흐름은 | 베트남어 요청 초안 | Draft `94700000-0000-0000-0000-000000000002` | `vi`, `PASSPORT_COPY`, 7일 이내 제출 문구 | 저장 전용; Draft 조회 API 없음 | | AI 처리 흔적 | trace ID `demo-compound-draft-flow` | 대상 확인부터 후행 후보 준비까지 AI Agent 이벤트 5건 | 업무 활동 API에서 노출 | -Case 진행률, Case 상태와 Case 전용 엔티티는 만들지 않는다. 클라이언트가 세 Task의 -`case_id`와 `business_data`를 조합해야 한다. +Case 진행률과 표시 상태는 `GET /api/v1/cases`에서 확인하고, 세 Task와 준비도 요약은 +`GET /api/v1/cases/{caseId}/projection`에서 확인한다. ## 검토·승인·제출 lifecycle @@ -85,6 +85,7 @@ Demo Company의 근로자 28명은 AI 팀이 지원하는 locale 15개를 모두 | 데이터 | 저장 | 조회 API | 비고 | | --- | --- | --- | --- | | Worker | O | O | 이름·국적·선호 언어·체류/계약일 노출 | +| Case와 Workflow Snapshot | O | O | 진행률·현재 Task·준비도 요약 노출 | | Task와 Checklist | O | O | `case_id`, `business_data`, 상태와 마감일 노출 | | WorkerDocument | O | O | 통합 문서함에서 상태·만료일·선택적 `file_id` 노출 | | Audit Event | O | O | 업무 활동과 ADMIN 감사 검색 지원 | diff --git a/docs/demo-seed.md b/docs/demo-seed.md index 25e230d..7d518be 100644 --- a/docs/demo-seed.md +++ b/docs/demo-seed.md @@ -63,6 +63,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 | --- | ---: | --- | | 계정 | 20 | `ADMIN` 2, `HR` 12, `VIEWER` 6 | | 근로자 | 28 | `ACTIVE`와 `ON_LEAVE`, AI 지원 locale 15개, 다양한 체류 만료 구간 | +| Case | 22 | 단일 업무 Case 21개, 세 업무를 묶은 복합 Case 1개 | | 업무 | 24 | 세 가지 지원 업무 유형과 여덟 가지 상태 | | 근로자 서류 | 84 | `VERIFIED` 48, `SUBMITTED` 20, `MISSING` 16 | | 체크리스트 항목 | 68 | 24개 업무에 연결 | @@ -100,6 +101,7 @@ Demo Company 업무 유형은 `STAY_PERIOD_EXTENSION` 10개, `RECONTRACT` 8개, | --- | ---: | | 계정 | 3 | | 근로자 | 5 | +| Case | 3 | | 업무 | 3 | | 근로자 서류 | 8 | | Audit Event | 8 | @@ -124,8 +126,8 @@ Test Company에는 Demo Company의 전체 운영 데이터를 복제하지 않 | 아디 수르야 | 고용기간 연장 자료 보완 | 지원되는 업무 유형의 `NEEDS_INFO`; 신규 등록 업무는 만들지 않음 | | 모하메드 라힘 | 오늘 마감된 체류기간 연장 완료 | `COMPLETED`, 승인 상태, 외부 제출, 완료 증빙, 상태 전이 이력 | -응웬반A의 세 업무는 별도 Case 엔티티 없이 같은 `caseId`로 연속성만 표현한다. -서버는 Case 진행률이나 Case 전용 API를 제공하지 않는다. +응웬반A의 세 업무는 하나의 Case 엔티티에 연결된다. 서버는 Case 목록과 Projection +API에서 진행률, 현재 Task, Workflow Snapshot과 준비도 요약을 제공한다. ## 클라이언트에서 확인되는 데이터 diff --git a/src/main/java/com/fowoco/server/casework/api/CaseController.java b/src/main/java/com/fowoco/server/casework/api/CaseController.java new file mode 100644 index 0000000..5f566aa --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/api/CaseController.java @@ -0,0 +1,78 @@ +package com.fowoco.server.casework.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.casework.application.CaseQueryService; +import com.fowoco.server.casework.application.CaseSearchQuery; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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.util.UUID; +import org.springframework.http.MediaType; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Case", description = "업무함 Case와 Workflow 진행 현황") +@SecurityRequirement(name = "bearerAuth") +@Validated +@RestController +@RequestMapping("/api/v1/cases") +public class CaseController { + + private final CaseQueryService caseQueryService; + private final ActorContextProvider actorContextProvider; + + public CaseController( + CaseQueryService caseQueryService, + ActorContextProvider actorContextProvider + ) { + this.caseQueryService = caseQueryService; + this.actorContextProvider = actorContextProvider; + } + + @Operation(operationId = "listCases", summary = "업무함 Case 목록 조회") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사업장 범위 Case 목록"), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public CasePageResponse findAll( + @Parameter(description = "근로자 표시 이름 또는 Case 제목 검색") + @RequestParam(required = false) String keyword, + @Parameter(description = "페이지 번호 (0부터 시작)") + @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "페이지당 Case 수 (1~100)") + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return CasePageResponse.from(caseQueryService.findPage( + new CaseSearchQuery(keyword, page, size), + actor() + )); + } + + @Operation(operationId = "getCaseProjection", summary = "Case 진행 현황 조회") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Case와 하위 업무 진행 현황"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + @GetMapping(path = "/{caseId}/projection", produces = MediaType.APPLICATION_JSON_VALUE) + public CaseProjectionResponse findProjection(@PathVariable UUID caseId) { + return CaseProjectionResponse.from(caseQueryService.findById(caseId, actor())); + } + + private ActorContext actor() { + return actorContextProvider.requireCurrentActor(); + } +} diff --git a/src/main/java/com/fowoco/server/casework/api/CasePageResponse.java b/src/main/java/com/fowoco/server/casework/api/CasePageResponse.java new file mode 100644 index 0000000..8f35a79 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/api/CasePageResponse.java @@ -0,0 +1,25 @@ +package com.fowoco.server.casework.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.casework.application.CasePageResult; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record CasePageResponse( + List items, + int page, + int size, + long totalElements, + int totalPages +) { + static CasePageResponse from(CasePageResult result) { + return new CasePageResponse( + result.items().stream().map(CaseSummaryResponse::from).toList(), + result.page(), + result.size(), + result.totalElements(), + result.totalPages() + ); + } +} diff --git a/src/main/java/com/fowoco/server/casework/api/CaseProgressResponse.java b/src/main/java/com/fowoco/server/casework/api/CaseProgressResponse.java new file mode 100644 index 0000000..2d25985 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/api/CaseProgressResponse.java @@ -0,0 +1,16 @@ +package com.fowoco.server.casework.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.casework.application.CaseProgress; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record CaseProgressResponse(int completedSteps, int totalSteps, int percentage) { + static CaseProgressResponse from(CaseProgress progress) { + return new CaseProgressResponse( + progress.completedSteps(), + progress.totalSteps(), + progress.percentage() + ); + } +} diff --git a/src/main/java/com/fowoco/server/casework/api/CaseProjectionResponse.java b/src/main/java/com/fowoco/server/casework/api/CaseProjectionResponse.java new file mode 100644 index 0000000..1491ef4 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/api/CaseProjectionResponse.java @@ -0,0 +1,54 @@ +package com.fowoco.server.casework.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.casework.application.CaseProjection; +import com.fowoco.server.casework.domain.CaseDisplayStatus; +import com.fowoco.server.casework.domain.CaseLifecycleStatus; +import com.fowoco.server.casework.domain.CasePriority; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record CaseProjectionResponse( + UUID caseId, + UUID workerId, + String workerDisplayName, + String title, + CaseLifecycleStatus lifecycleStatus, + CaseDisplayStatus displayStatus, + boolean hasUnreadResponse, + CasePriority priority, + CaseProgressResponse progress, + CaseReadinessResponse readiness, + LocalDate dueDate, + CaseTaskResponse currentTask, + List tasks, + String workflowCatalogVersion, + Map workflowSnapshot, + Instant updatedAt +) { + static CaseProjectionResponse from(CaseProjection projection) { + return new CaseProjectionResponse( + projection.caseId(), + projection.workerId(), + projection.workerDisplayName(), + projection.title(), + projection.lifecycleStatus(), + projection.displayStatus(), + projection.hasUnreadResponse(), + projection.priority(), + CaseProgressResponse.from(projection.progress()), + CaseReadinessResponse.from(projection.readiness()), + projection.dueDate(), + CaseTaskResponse.from(projection.currentTask()), + projection.tasks().stream().map(CaseTaskResponse::from).toList(), + projection.workflowCatalogVersion(), + projection.workflowSnapshot(), + projection.updatedAt() + ); + } +} diff --git a/src/main/java/com/fowoco/server/casework/api/CaseReadinessResponse.java b/src/main/java/com/fowoco/server/casework/api/CaseReadinessResponse.java new file mode 100644 index 0000000..d772ec1 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/api/CaseReadinessResponse.java @@ -0,0 +1,30 @@ +package com.fowoco.server.casework.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.casework.application.CaseReadiness; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record CaseReadinessResponse( + int completedChecklistItems, + int totalChecklistItems, + int verifiedDocuments, + int totalDocuments, + int pendingApprovals, + int approvedApprovals, + int workerResponses, + int evidenceItems +) { + static CaseReadinessResponse from(CaseReadiness readiness) { + return new CaseReadinessResponse( + readiness.completedChecklistItems(), + readiness.totalChecklistItems(), + readiness.verifiedDocuments(), + readiness.totalDocuments(), + readiness.pendingApprovals(), + readiness.approvedApprovals(), + readiness.workerResponses(), + readiness.evidenceItems() + ); + } +} diff --git a/src/main/java/com/fowoco/server/casework/api/CaseSummaryResponse.java b/src/main/java/com/fowoco/server/casework/api/CaseSummaryResponse.java new file mode 100644 index 0000000..6d1ca05 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/api/CaseSummaryResponse.java @@ -0,0 +1,41 @@ +package com.fowoco.server.casework.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.casework.application.CaseProjection; +import com.fowoco.server.casework.domain.CaseDisplayStatus; +import com.fowoco.server.casework.domain.CasePriority; +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record CaseSummaryResponse( + UUID caseId, + UUID workerId, + String workerDisplayName, + String title, + CaseDisplayStatus displayStatus, + boolean hasUnreadResponse, + CasePriority priority, + CaseProgressResponse progress, + LocalDate dueDate, + CaseTaskResponse currentTask, + Instant updatedAt +) { + static CaseSummaryResponse from(CaseProjection projection) { + return new CaseSummaryResponse( + projection.caseId(), + projection.workerId(), + projection.workerDisplayName(), + projection.title(), + projection.displayStatus(), + projection.hasUnreadResponse(), + projection.priority(), + CaseProgressResponse.from(projection.progress()), + projection.dueDate(), + CaseTaskResponse.from(projection.currentTask()), + projection.updatedAt() + ); + } +} diff --git a/src/main/java/com/fowoco/server/casework/api/CaseTaskResponse.java b/src/main/java/com/fowoco/server/casework/api/CaseTaskResponse.java new file mode 100644 index 0000000..70ee524 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/api/CaseTaskResponse.java @@ -0,0 +1,28 @@ +package com.fowoco.server.casework.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.casework.application.CaseTaskProjection; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.time.LocalDate; +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record CaseTaskResponse( + UUID taskId, + TaskType taskType, + String title, + TaskStatus status, + LocalDate dueDate +) { + static CaseTaskResponse from(CaseTaskProjection task) { + return task == null ? null : new CaseTaskResponse( + task.taskId(), + task.taskType(), + task.title(), + task.status(), + task.dueDate() + ); + } +} diff --git a/src/main/java/com/fowoco/server/casework/application/CaseDisplayFacts.java b/src/main/java/com/fowoco/server/casework/application/CaseDisplayFacts.java index 6f983b6..8038057 100644 --- a/src/main/java/com/fowoco/server/casework/application/CaseDisplayFacts.java +++ b/src/main/java/com/fowoco/server/casework/application/CaseDisplayFacts.java @@ -4,12 +4,14 @@ * Case 표시 상태를 계산할 때 사용하는 조회 사실입니다. * * @param completed Case 업무가 모두 완료됐는지 여부 + * @param cancelled Case가 취소됐는지 여부 * @param linkIssued 근로자용 요청 링크를 발급했는지 여부 * @param reviewRequired 도착한 답변이나 서류에 HR 검토가 남아 있는지 여부 * @param unreadResponse 도착했지만 아직 읽지 않은 근로자 응답이 있는지 여부 */ public record CaseDisplayFacts( boolean completed, + boolean cancelled, boolean linkIssued, boolean reviewRequired, boolean unreadResponse diff --git a/src/main/java/com/fowoco/server/casework/application/CaseDisplayStatusResolver.java b/src/main/java/com/fowoco/server/casework/application/CaseDisplayStatusResolver.java index 139d418..a80c82e 100644 --- a/src/main/java/com/fowoco/server/casework/application/CaseDisplayStatusResolver.java +++ b/src/main/java/com/fowoco/server/casework/application/CaseDisplayStatusResolver.java @@ -11,6 +11,9 @@ public final class CaseDisplayStatusResolver { public CaseDisplayState resolve(CaseDisplayFacts facts) { Objects.requireNonNull(facts, "facts must not be null"); + if (facts.cancelled()) { + return new CaseDisplayState(CaseDisplayStatus.CANCELLED, false); + } if (facts.completed()) { return new CaseDisplayState(CaseDisplayStatus.COMPLETED, false); } diff --git a/src/main/java/com/fowoco/server/casework/application/CasePageResult.java b/src/main/java/com/fowoco/server/casework/application/CasePageResult.java new file mode 100644 index 0000000..a8448a0 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/application/CasePageResult.java @@ -0,0 +1,21 @@ +package com.fowoco.server.casework.application; + +import java.util.List; + +public record CasePageResult( + List items, + int page, + int size, + long totalElements, + int totalPages +) { + public CasePageResult(List items, int page, int size, long totalElements) { + this( + List.copyOf(items), + page, + size, + totalElements, + totalElements == 0 ? 0 : (int) Math.ceil((double) totalElements / size) + ); + } +} diff --git a/src/main/java/com/fowoco/server/casework/application/CaseProgress.java b/src/main/java/com/fowoco/server/casework/application/CaseProgress.java new file mode 100644 index 0000000..a80169a --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/application/CaseProgress.java @@ -0,0 +1,4 @@ +package com.fowoco.server.casework.application; + +public record CaseProgress(int completedSteps, int totalSteps, int percentage) { +} diff --git a/src/main/java/com/fowoco/server/casework/application/CaseProjection.java b/src/main/java/com/fowoco/server/casework/application/CaseProjection.java new file mode 100644 index 0000000..86ed725 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/application/CaseProjection.java @@ -0,0 +1,30 @@ +package com.fowoco.server.casework.application; + +import com.fowoco.server.casework.domain.CaseDisplayStatus; +import com.fowoco.server.casework.domain.CaseLifecycleStatus; +import com.fowoco.server.casework.domain.CasePriority; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record CaseProjection( + UUID caseId, + UUID workerId, + String workerDisplayName, + String title, + CaseLifecycleStatus lifecycleStatus, + CaseDisplayStatus displayStatus, + boolean hasUnreadResponse, + CasePriority priority, + CaseProgress progress, + CaseReadiness readiness, + LocalDate dueDate, + CaseTaskProjection currentTask, + List tasks, + String workflowCatalogVersion, + Map workflowSnapshot, + Instant updatedAt +) { +} diff --git a/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java b/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java new file mode 100644 index 0000000..8dfc4d8 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java @@ -0,0 +1,241 @@ +package com.fowoco.server.casework.application; + +import com.fowoco.server.auth.application.ActorAuthorizer; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.casework.application.error.CaseErrorCode; +import com.fowoco.server.casework.application.port.CaseQueryRepository; +import com.fowoco.server.casework.application.port.CaseQueryRepository.CaseRecord; +import com.fowoco.server.casework.application.port.CaseQueryRepository.CaseRecordPage; +import com.fowoco.server.casework.application.port.CaseQueryRepository.CaseTaskRecord; +import com.fowoco.server.casework.domain.CaseLifecycleStatus; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.task.domain.TaskStatus; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +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; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Service +public class CaseQueryService { + + private static final Comparator CURRENT_TASK_ORDER = Comparator + .comparing(CaseTaskProjection::dueDate, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(CaseTaskProjection::taskId); + + private final ActorAuthorizer actorAuthorizer; + private final TenantDatabaseContext tenantDatabaseContext; + private final CaseQueryRepository repository; + private final CaseDisplayStatusResolver displayStatusResolver; + private final ObjectMapper objectMapper; + + public CaseQueryService( + ActorAuthorizer actorAuthorizer, + TenantDatabaseContext tenantDatabaseContext, + CaseQueryRepository repository, + ObjectMapper objectMapper + ) { + this.actorAuthorizer = actorAuthorizer; + this.tenantDatabaseContext = tenantDatabaseContext; + this.repository = repository; + this.displayStatusResolver = new CaseDisplayStatusResolver(); + this.objectMapper = objectMapper; + } + + @Transactional(readOnly = true) + public CasePageResult findPage(CaseSearchQuery query, ActorContext actor) { + bindAndAuthorize(actor); + CaseRecordPage page = repository.findPage(actor.companyId(), query); + Map> tasksByCase = tasksByCase( + actor.companyId(), + page.items().stream().map(CaseRecord::caseId).toList() + ); + List items = page.items().stream() + .map(item -> toProjection(item, tasksByCase.getOrDefault(item.caseId(), List.of()))) + .toList(); + return new CasePageResult(items, query.page(), query.size(), page.totalElements()); + } + + @Transactional(readOnly = true) + public CaseProjection findById(UUID caseId, ActorContext actor) { + bindAndAuthorize(actor); + CaseRecord record = repository.findById(actor.companyId(), caseId) + .orElseThrow(() -> new ApiException(CaseErrorCode.CASE_NOT_FOUND)); + List tasks = repository.findTasks(actor.companyId(), List.of(caseId)) + .stream() + .map(CaseQueryService::toTaskProjection) + .toList(); + return toProjection(record, tasks); + } + + private void bindAndAuthorize(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR, UserRole.VIEWER); + } + + private Map> tasksByCase(UUID companyId, List caseIds) { + if (caseIds.isEmpty()) { + return Map.of(); + } + return repository.findTasks(companyId, caseIds).stream() + .collect(Collectors.groupingBy( + CaseTaskRecord::caseId, + Collectors.mapping(CaseQueryService::toTaskProjection, Collectors.toList()) + )); + } + + private CaseProjection toProjection(CaseRecord record, List tasks) { + Map workflowSnapshot = decodeSnapshot(record.workflowSnapshotJson()); + Map snapshotSteps = snapshotSteps(workflowSnapshot); + List orderedTasks = tasks.stream() + .sorted(Comparator + .comparingInt((CaseTaskProjection task) -> snapshotSteps + .getOrDefault(task.taskId(), SnapshotStep.fallback()) + .order()) + .thenComparing(CURRENT_TASK_ORDER)) + .toList(); + List countedTasks = orderedTasks.stream() + .filter(task -> task.status() != TaskStatus.CANCELLED) + .toList(); + int completedSteps = (int) countedTasks.stream() + .filter(task -> task.status() == TaskStatus.COMPLETED) + .count(); + int totalSteps = countedTasks.size(); + boolean completed = record.lifecycleStatus() == CaseLifecycleStatus.COMPLETED + || (totalSteps > 0 && completedSteps == totalSteps); + CaseDisplayState displayState = displayStatusResolver.resolve(new CaseDisplayFacts( + completed, + record.lifecycleStatus() == CaseLifecycleStatus.CANCELLED, + record.linkIssued(), + record.reviewRequired(), + record.unreadResponse() + )); + Map statusesByTaskId = orderedTasks.stream() + .collect(Collectors.toMap(CaseTaskProjection::taskId, CaseTaskProjection::status)); + CaseTaskProjection currentTask = orderedTasks.stream() + .filter(task -> !task.status().isTerminal()) + .filter(task -> dependencySatisfied(task, snapshotSteps, statusesByTaskId)) + .findFirst() + .orElse(null); + LocalDate dueDate = orderedTasks.stream() + .filter(task -> !task.status().isTerminal()) + .map(CaseTaskProjection::dueDate) + .filter(java.util.Objects::nonNull) + .min(LocalDate::compareTo) + .orElse(null); + int percentage = totalSteps == 0 ? 0 : (completedSteps * 100) / totalSteps; + return new CaseProjection( + record.caseId(), + record.workerId(), + record.workerDisplayName(), + record.title(), + record.lifecycleStatus(), + displayState.status(), + displayState.hasUnreadResponse(), + record.priority(), + new CaseProgress(completedSteps, totalSteps, percentage), + new CaseReadiness( + record.completedChecklistItems(), + record.totalChecklistItems(), + record.verifiedDocuments(), + record.totalDocuments(), + record.pendingApprovals(), + record.approvedApprovals(), + record.workerResponses(), + record.evidenceItems() + ), + dueDate, + currentTask, + orderedTasks, + record.workflowCatalogVersion(), + workflowSnapshot, + record.updatedAt() + ); + } + + private boolean dependencySatisfied( + CaseTaskProjection task, + Map snapshotSteps, + Map statusesByTaskId + ) { + SnapshotStep step = snapshotSteps.get(task.taskId()); + if (step == null || step.dependsOnTaskId() == null) { + return true; + } + return statusesByTaskId.get(step.dependsOnTaskId()) == TaskStatus.COMPLETED; + } + + private Map snapshotSteps(Map snapshot) { + Object rawSteps = snapshot.get("steps"); + if (!(rawSteps instanceof List steps)) { + return Map.of(); + } + Map result = new HashMap<>(); + for (Object rawStep : steps) { + if (!(rawStep instanceof Map step)) { + continue; + } + UUID taskId = uuidValue(step.get("task_id")); + if (taskId == null) { + continue; + } + int order = step.get("order") instanceof Number number + ? number.intValue() + : Integer.MAX_VALUE; + UUID dependsOnTaskId = null; + if (step.get("required_conditions") instanceof Map conditions) { + dependsOnTaskId = uuidValue(conditions.get("depends_on_task_id")); + } + result.put(taskId, new SnapshotStep(order, dependsOnTaskId)); + } + return Map.copyOf(result); + } + + private UUID uuidValue(Object value) { + if (!(value instanceof String text)) { + return null; + } + try { + return UUID.fromString(text); + } catch (IllegalArgumentException exception) { + return null; + } + } + + private static CaseTaskProjection toTaskProjection(CaseTaskRecord task) { + return new CaseTaskProjection( + task.taskId(), + task.taskType(), + task.title(), + task.status(), + task.dueDate() + ); + } + + @SuppressWarnings("unchecked") + private Map decodeSnapshot(String snapshotJson) { + try { + Map snapshot = objectMapper.readValue(snapshotJson, Map.class); + return Collections.unmodifiableMap(new LinkedHashMap<>(snapshot)); + } catch (JacksonException | NullPointerException exception) { + throw new IllegalStateException("저장된 Workflow Snapshot을 읽을 수 없습니다.", exception); + } + } + + private record SnapshotStep(int order, UUID dependsOnTaskId) { + + private static SnapshotStep fallback() { + return new SnapshotStep(Integer.MAX_VALUE, null); + } + } +} diff --git a/src/main/java/com/fowoco/server/casework/application/CaseReadiness.java b/src/main/java/com/fowoco/server/casework/application/CaseReadiness.java new file mode 100644 index 0000000..27c69c7 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/application/CaseReadiness.java @@ -0,0 +1,13 @@ +package com.fowoco.server.casework.application; + +public record CaseReadiness( + int completedChecklistItems, + int totalChecklistItems, + int verifiedDocuments, + int totalDocuments, + int pendingApprovals, + int approvedApprovals, + int workerResponses, + int evidenceItems +) { +} diff --git a/src/main/java/com/fowoco/server/casework/application/CaseSearchQuery.java b/src/main/java/com/fowoco/server/casework/application/CaseSearchQuery.java new file mode 100644 index 0000000..5603c4b --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/application/CaseSearchQuery.java @@ -0,0 +1,14 @@ +package com.fowoco.server.casework.application; + +public record CaseSearchQuery(String keyword, int page, int size) { + + public CaseSearchQuery { + keyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); + if (page < 0) { + throw new IllegalArgumentException("page must not be negative"); + } + if (size < 1 || size > 100) { + throw new IllegalArgumentException("size must be between 1 and 100"); + } + } +} diff --git a/src/main/java/com/fowoco/server/casework/application/CaseTaskProjection.java b/src/main/java/com/fowoco/server/casework/application/CaseTaskProjection.java new file mode 100644 index 0000000..dca0881 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/application/CaseTaskProjection.java @@ -0,0 +1,15 @@ +package com.fowoco.server.casework.application; + +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.time.LocalDate; +import java.util.UUID; + +public record CaseTaskProjection( + UUID taskId, + TaskType taskType, + String title, + TaskStatus status, + LocalDate dueDate +) { +} diff --git a/src/main/java/com/fowoco/server/casework/application/error/CaseErrorCode.java b/src/main/java/com/fowoco/server/casework/application/error/CaseErrorCode.java new file mode 100644 index 0000000..34e7574 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/application/error/CaseErrorCode.java @@ -0,0 +1,31 @@ +package com.fowoco.server.casework.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum CaseErrorCode implements ApiErrorCode { + CASE_NOT_FOUND(HttpStatus.NOT_FOUND, "Case를 찾을 수 없습니다."); + + private final HttpStatus status; + private final String defaultMessage; + + CaseErrorCode(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/main/java/com/fowoco/server/casework/application/port/CaseQueryRepository.java b/src/main/java/com/fowoco/server/casework/application/port/CaseQueryRepository.java new file mode 100644 index 0000000..d219cc5 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/application/port/CaseQueryRepository.java @@ -0,0 +1,58 @@ +package com.fowoco.server.casework.application.port; + +import com.fowoco.server.casework.application.CaseSearchQuery; +import com.fowoco.server.casework.domain.CaseLifecycleStatus; +import com.fowoco.server.casework.domain.CasePriority; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface CaseQueryRepository { + + CaseRecordPage findPage(UUID companyId, CaseSearchQuery query); + + Optional findById(UUID companyId, UUID caseId); + + List findTasks(UUID companyId, List caseIds); + + record CaseRecordPage(List items, long totalElements) { + } + + record CaseRecord( + UUID caseId, + UUID workerId, + String workerDisplayName, + String title, + CaseLifecycleStatus lifecycleStatus, + CasePriority priority, + String workflowCatalogVersion, + String workflowSnapshotJson, + boolean linkIssued, + boolean reviewRequired, + boolean unreadResponse, + int completedChecklistItems, + int totalChecklistItems, + int verifiedDocuments, + int totalDocuments, + int pendingApprovals, + int approvedApprovals, + int workerResponses, + int evidenceItems, + Instant updatedAt + ) { + } + + record CaseTaskRecord( + UUID caseId, + UUID taskId, + TaskType taskType, + String title, + TaskStatus status, + LocalDate dueDate + ) { + } +} diff --git a/src/main/java/com/fowoco/server/casework/domain/CaseDisplayStatus.java b/src/main/java/com/fowoco/server/casework/domain/CaseDisplayStatus.java index e0328e2..6d01f3b 100644 --- a/src/main/java/com/fowoco/server/casework/domain/CaseDisplayStatus.java +++ b/src/main/java/com/fowoco/server/casework/domain/CaseDisplayStatus.java @@ -10,5 +10,6 @@ public enum CaseDisplayStatus { DOCUMENT_PENDING, REQUEST_SENT, REVIEW_REQUIRED, - COMPLETED + COMPLETED, + CANCELLED } diff --git a/src/main/java/com/fowoco/server/casework/domain/CaseLifecycleStatus.java b/src/main/java/com/fowoco/server/casework/domain/CaseLifecycleStatus.java new file mode 100644 index 0000000..59cf44f --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/domain/CaseLifecycleStatus.java @@ -0,0 +1,7 @@ +package com.fowoco.server.casework.domain; + +public enum CaseLifecycleStatus { + ACTIVE, + COMPLETED, + CANCELLED +} diff --git a/src/main/java/com/fowoco/server/casework/domain/CasePriority.java b/src/main/java/com/fowoco/server/casework/domain/CasePriority.java new file mode 100644 index 0000000..4b17cdb --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/domain/CasePriority.java @@ -0,0 +1,8 @@ +package com.fowoco.server.casework.domain; + +public enum CasePriority { + URGENT, + HIGH, + NORMAL, + LOW +} diff --git a/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcCaseQueryRepository.java b/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcCaseQueryRepository.java new file mode 100644 index 0000000..fb0dcc8 --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcCaseQueryRepository.java @@ -0,0 +1,264 @@ +package com.fowoco.server.casework.infrastructure.persistence; + +import com.fowoco.server.casework.application.CaseSearchQuery; +import com.fowoco.server.casework.application.port.CaseQueryRepository; +import com.fowoco.server.casework.domain.CaseLifecycleStatus; +import com.fowoco.server.casework.domain.CasePriority; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class JdbcCaseQueryRepository implements CaseQueryRepository { + + private static final String CASE_SELECT = """ + SELECT c.case_id, + c.worker_id, + w.display_name AS worker_display_name, + c.title, + c.lifecycle_status, + c.priority, + c.workflow_catalog_version, + c.workflow_snapshot_json, + c.updated_at, + EXISTS ( + SELECT 1 + FROM task linked_task + JOIN worker_link link + ON link.task_id = linked_task.task_id + AND link.company_id = linked_task.company_id + WHERE linked_task.case_id = c.case_id + AND linked_task.company_id = c.company_id + ) AS link_issued, + ( + EXISTS ( + SELECT 1 FROM task review_task + WHERE review_task.case_id = c.case_id + AND review_task.company_id = c.company_id + AND review_task.status = 'READY_FOR_REVIEW' + ) + OR EXISTS ( + SELECT 1 + FROM task approval_task + JOIN approval_request approval + ON approval.task_id = approval_task.task_id + AND approval.company_id = approval_task.company_id + WHERE approval_task.case_id = c.case_id + AND approval_task.company_id = c.company_id + AND approval.status = 'PENDING' + ) + OR EXISTS ( + SELECT 1 + FROM task response_task + JOIN worker_link response_link + ON response_link.task_id = response_task.task_id + AND response_link.company_id = response_task.company_id + WHERE response_task.case_id = c.case_id + AND response_task.company_id = c.company_id + AND response_link.conversation_status IN ('NEEDS_FOLLOWUP', 'REOPENED') + ) + ) AS review_required, + EXISTS ( + SELECT 1 + FROM task unread_task + JOIN worker_link unread_link + ON unread_link.task_id = unread_task.task_id + AND unread_link.company_id = unread_task.company_id + JOIN worker_response response + ON response.worker_link_id = unread_link.worker_link_id + AND response.company_id = unread_link.company_id + WHERE unread_task.case_id = c.case_id + AND unread_task.company_id = c.company_id + AND unread_link.conversation_status = 'NEEDS_FOLLOWUP' + ) AS unread_response + ,( + SELECT COUNT(*) + FROM task checklist_task + JOIN task_checklist_item checklist + ON checklist.task_id = checklist_task.task_id + AND checklist.company_id = checklist_task.company_id + WHERE checklist_task.case_id = c.case_id + AND checklist_task.company_id = c.company_id + AND checklist.completed = TRUE + ) AS completed_checklist_items + ,( + SELECT COUNT(*) + FROM task checklist_task + JOIN task_checklist_item checklist + ON checklist.task_id = checklist_task.task_id + AND checklist.company_id = checklist_task.company_id + WHERE checklist_task.case_id = c.case_id + AND checklist_task.company_id = c.company_id + ) AS total_checklist_items + ,( + SELECT COUNT(*) + FROM task document_task + JOIN worker_document document + ON document.task_id = document_task.task_id + AND document.company_id = document_task.company_id + WHERE document_task.case_id = c.case_id + AND document_task.company_id = c.company_id + AND document.submission_status = 'VERIFIED' + ) AS verified_documents + ,( + SELECT COUNT(*) + FROM task document_task + JOIN worker_document document + ON document.task_id = document_task.task_id + AND document.company_id = document_task.company_id + WHERE document_task.case_id = c.case_id + AND document_task.company_id = c.company_id + ) AS total_documents + ,( + SELECT COUNT(*) + FROM task approval_task + JOIN approval_request approval + ON approval.task_id = approval_task.task_id + AND approval.company_id = approval_task.company_id + WHERE approval_task.case_id = c.case_id + AND approval_task.company_id = c.company_id + AND approval.status = 'PENDING' + ) AS pending_approvals + ,( + SELECT COUNT(*) + FROM task approval_task + JOIN approval_request approval + ON approval.task_id = approval_task.task_id + AND approval.company_id = approval_task.company_id + WHERE approval_task.case_id = c.case_id + AND approval_task.company_id = c.company_id + AND approval.status = 'APPROVED' + ) AS approved_approvals + ,( + SELECT COUNT(*) + FROM task response_task + JOIN worker_link response_link + ON response_link.task_id = response_task.task_id + AND response_link.company_id = response_task.company_id + JOIN worker_response response + ON response.worker_link_id = response_link.worker_link_id + AND response.company_id = response_link.company_id + WHERE response_task.case_id = c.case_id + AND response_task.company_id = c.company_id + ) AS worker_responses + ,( + SELECT COUNT(*) + FROM task evidence_task + JOIN task_evidence evidence + ON evidence.task_id = evidence_task.task_id + AND evidence.company_id = evidence_task.company_id + WHERE evidence_task.case_id = c.case_id + AND evidence_task.company_id = c.company_id + ) AS evidence_items + FROM workflow_case c + JOIN worker w + ON w.worker_id = c.worker_id + AND w.company_id = c.company_id + """; + + private final NamedParameterJdbcTemplate jdbcTemplate; + + public JdbcCaseQueryRepository(NamedParameterJdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public CaseRecordPage findPage(UUID companyId, CaseSearchQuery query) { + Map parameters = new HashMap<>(); + parameters.put("companyId", companyId); + parameters.put("limit", query.size()); + parameters.put("offset", query.page() * query.size()); + String filter = " WHERE c.company_id = :companyId"; + if (query.keyword() != null) { + filter += " AND (LOWER(c.title) LIKE :keyword OR LOWER(w.display_name) LIKE :keyword)"; + parameters.put("keyword", "%" + query.keyword().toLowerCase() + "%"); + } + Long total = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM workflow_case c JOIN worker w" + + " ON w.worker_id = c.worker_id AND w.company_id = c.company_id" + + filter, + parameters, + Long.class + ); + List items = jdbcTemplate.query( + CASE_SELECT + filter + + " ORDER BY CASE c.priority" + + " WHEN 'URGENT' THEN 0 WHEN 'HIGH' THEN 1" + + " WHEN 'NORMAL' THEN 2 ELSE 3 END, c.updated_at DESC" + + ", c.case_id ASC" + + " LIMIT :limit OFFSET :offset", + parameters, + (resultSet, rowNumber) -> mapCase(resultSet) + ); + return new CaseRecordPage(items, total == null ? 0 : total); + } + + @Override + public Optional findById(UUID companyId, UUID caseId) { + Map parameters = Map.of("companyId", companyId, "caseId", caseId); + List rows = jdbcTemplate.query( + CASE_SELECT + " WHERE c.company_id = :companyId AND c.case_id = :caseId", + parameters, + (resultSet, rowNumber) -> mapCase(resultSet) + ); + return rows.stream().findFirst(); + } + + @Override + public List findTasks(UUID companyId, List caseIds) { + if (caseIds.isEmpty()) { + return List.of(); + } + return jdbcTemplate.query( + """ + SELECT case_id, task_id, task_type, title, status, due_date + FROM task + WHERE company_id = :companyId + AND case_id IN (:caseIds) + ORDER BY due_date ASC, created_at ASC + """, + Map.of("companyId", companyId, "caseIds", caseIds), + (resultSet, rowNumber) -> new CaseTaskRecord( + resultSet.getObject("case_id", UUID.class), + resultSet.getObject("task_id", UUID.class), + TaskType.valueOf(resultSet.getString("task_type")), + resultSet.getString("title"), + TaskStatus.valueOf(resultSet.getString("status")), + resultSet.getObject("due_date", java.time.LocalDate.class) + ) + ); + } + + private CaseRecord mapCase(ResultSet resultSet) throws SQLException { + return new CaseRecord( + resultSet.getObject("case_id", UUID.class), + resultSet.getObject("worker_id", UUID.class), + resultSet.getString("worker_display_name"), + resultSet.getString("title"), + CaseLifecycleStatus.valueOf(resultSet.getString("lifecycle_status")), + CasePriority.valueOf(resultSet.getString("priority")), + resultSet.getString("workflow_catalog_version"), + resultSet.getString("workflow_snapshot_json"), + resultSet.getBoolean("link_issued"), + resultSet.getBoolean("review_required"), + resultSet.getBoolean("unread_response"), + resultSet.getInt("completed_checklist_items"), + resultSet.getInt("total_checklist_items"), + resultSet.getInt("verified_documents"), + resultSet.getInt("total_documents"), + resultSet.getInt("pending_approvals"), + resultSet.getInt("approved_approvals"), + resultSet.getInt("worker_responses"), + resultSet.getInt("evidence_items"), + resultSet.getTimestamp("updated_at").toInstant() + ); + } +} diff --git a/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcTaskCaseRegistrar.java b/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcTaskCaseRegistrar.java new file mode 100644 index 0000000..931b7aa --- /dev/null +++ b/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcTaskCaseRegistrar.java @@ -0,0 +1,110 @@ +package com.fowoco.server.casework.infrastructure.persistence; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.task.application.port.TaskCaseRegistrar; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.workflow.domain.WorkflowDefinition; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Repository +public class JdbcTaskCaseRegistrar implements TaskCaseRegistrar { + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + public JdbcTaskCaseRegistrar(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) { + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + } + + @Override + public void register(Task task, WorkflowDefinition workflow, LocalDate today) { + List existingWorkerIds = jdbcTemplate.query( + "SELECT worker_id FROM workflow_case WHERE case_id = ? AND company_id = ?", + (resultSet, rowNumber) -> resultSet.getObject("worker_id", UUID.class), + task.caseId(), + task.companyId() + ); + if (!existingWorkerIds.isEmpty()) { + if (!existingWorkerIds.get(0).equals(task.workerId())) { + throw new ApiException(TaskErrorCode.CASE_WORKER_MISMATCH); + } + return; + } + + jdbcTemplate.update( + """ + INSERT INTO workflow_case ( + case_id, company_id, worker_id, title, lifecycle_status, priority, + workflow_catalog_version, workflow_snapshot_json, created_by, + created_at, updated_at, version + ) VALUES (?, ?, ?, ?, 'ACTIVE', ?, ?, ?, ?, ?, ?, 0) + """, + task.caseId(), + task.companyId(), + task.workerId(), + task.title(), + priority(task.dueDate(), today), + task.workflowCatalogVersion(), + snapshot(task, workflow), + task.createdBy(), + task.createdAt(), + task.updatedAt() + ); + } + + private String snapshot(Task task, WorkflowDefinition workflow) { + Map conditions = new LinkedHashMap<>(); + conditions.put("required_slots", sorted(workflow.requiredSlots())); + conditions.put("completion_evidence", List.copyOf(workflow.completionEvidence())); + + Map step = new LinkedHashMap<>(); + step.put("order", 1); + step.put("task_id", task.taskId().toString()); + step.put("workflow_id", task.workflowId()); + step.put("task_type", task.taskType().name()); + step.put("required_conditions", conditions); + + Map snapshot = new LinkedHashMap<>(); + snapshot.put("workflow_catalog_version", task.workflowCatalogVersion()); + snapshot.put("steps", List.of(step)); + try { + return objectMapper.writeValueAsString(snapshot); + } catch (JacksonException exception) { + throw new IllegalStateException("manual task workflow snapshot cannot be encoded", exception); + } + } + + private List sorted(Iterable values) { + List result = new ArrayList<>(); + values.forEach(result::add); + return result.stream().sorted().toList(); + } + + private String priority(LocalDate dueDate, LocalDate today) { + if (dueDate == null) { + return "NORMAL"; + } + long remainingDays = java.time.temporal.ChronoUnit.DAYS.between(today, dueDate); + if (remainingDays <= 3) { + return "URGENT"; + } + if (remainingDays <= 7) { + return "HIGH"; + } + if (remainingDays <= 30) { + return "NORMAL"; + } + return "LOW"; + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeeder.java new file mode 100644 index 0000000..4295b2c --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeeder.java @@ -0,0 +1,164 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.task.domain.TaskStatus; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; +import org.springframework.jdbc.core.JdbcTemplate; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +final class DemoCaseSeeder { + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + DemoCaseSeeder(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); + } + + void seed(List tasks, DemoOperationalSeedContext context) { + tasks.stream() + .collect(Collectors.groupingBy(TaskSeed::caseId, LinkedHashMap::new, Collectors.toList())) + .forEach((caseId, caseTasks) -> seedCase(caseId, caseTasks, context)); + } + + private void seedCase(UUID caseId, List tasks, DemoOperationalSeedContext context) { + Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM workflow_case WHERE case_id = ? AND company_id = ?", + Integer.class, + caseId, + context.companyId() + ); + if (existing != null && existing > 0) { + return; + } + TaskSeed first = tasks.get(0); + UUID workerId = first.workerId(); + if (tasks.stream().anyMatch(task -> !workerId.equals(task.workerId()))) { + throw new IllegalStateException("one demo case must belong to one worker"); + } + Instant createdAt = context.now().minus( + tasks.stream().mapToInt(TaskSeed::createdDaysAgo).max().orElse(0), + ChronoUnit.DAYS + ); + jdbcTemplate.update( + """ + INSERT INTO workflow_case ( + case_id, company_id, worker_id, title, lifecycle_status, priority, + workflow_catalog_version, workflow_snapshot_json, created_by, + created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + """, + caseId, + context.companyId(), + workerId, + title(tasks), + lifecycleStatus(tasks), + priority(tasks), + DemoOperationalSeedCatalog.WORKFLOW_CATALOG_VERSION, + snapshot(tasks), + context.actorId(), + createdAt, + context.now() + ); + } + + private String title(List tasks) { + Object displayName = tasks.get(0).businessData().get("case_display_name"); + if (displayName instanceof String name && !name.isBlank()) { + return name; + } + return tasks.size() == 1 ? tasks.get(0).title() : "통합 업무 준비"; + } + + private String lifecycleStatus(List tasks) { + if (tasks.stream().allMatch(task -> task.status() == TaskStatus.COMPLETED)) { + return "COMPLETED"; + } + if (tasks.stream().allMatch(task -> task.status() == TaskStatus.CANCELLED)) { + return "CANCELLED"; + } + return "ACTIVE"; + } + + private String priority(List tasks) { + int nearestDueDays = tasks.stream().mapToInt(TaskSeed::dueDays).min().orElse(30); + if (nearestDueDays <= 3) { + return "URGENT"; + } + if (nearestDueDays <= 7) { + return "HIGH"; + } + if (nearestDueDays <= 30) { + return "NORMAL"; + } + return "LOW"; + } + + String snapshot(List tasks) { + List orderedTasks = tasks.stream() + .sorted(Comparator + .comparingInt((TaskSeed task) -> candidateOrder(task)) + .thenComparing(TaskSeed::taskId)) + .toList(); + List> steps = java.util.stream.IntStream + .range(0, orderedTasks.size()) + .mapToObj(index -> snapshotStep(orderedTasks.get(index), index + 1)) + .toList(); + try { + return objectMapper.writeValueAsString(Map.of( + "workflow_catalog_version", DemoOperationalSeedCatalog.WORKFLOW_CATALOG_VERSION, + "steps", steps + )); + } catch (JacksonException exception) { + throw new IllegalStateException("demo workflow snapshot cannot be encoded", exception); + } + } + + private Map snapshotStep(TaskSeed task, int fallbackOrder) { + Map step = new LinkedHashMap<>(); + step.put("order", candidateOrder(task, fallbackOrder)); + step.put("task_id", task.taskId().toString()); + step.put("workflow_id", task.workflowId()); + step.put("task_type", task.taskType().name()); + step.put("required_conditions", requiredConditions(task.businessData())); + return Map.copyOf(step); + } + + private int candidateOrder(TaskSeed task) { + return candidateOrder(task, Integer.MAX_VALUE); + } + + private int candidateOrder(TaskSeed task, int fallback) { + Object value = task.businessData().get("candidate_order"); + if (value instanceof Number number && number.intValue() > 0) { + return number.intValue(); + } + return fallback; + } + + private Map requiredConditions(Map businessData) { + Map conditions = new LinkedHashMap<>(); + List.of( + "approval_required", + "depends_on_task_id", + "dependency_reason", + "missing_information", + "submission_due_offset_days" + ).forEach(key -> { + if (businessData.containsKey(key)) { + conditions.put(key, businessData.get(key)); + } + }); + return Map.copyOf(conditions); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java index 9075673..f6901b4 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java @@ -1,6 +1,8 @@ package com.fowoco.server.demo.infrastructure.seed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.task.domain.TaskType; import com.fowoco.server.worker.domain.DocumentType; import com.fowoco.server.worker.domain.SubmissionStatus; import java.util.ArrayList; @@ -19,7 +21,7 @@ final class DemoDocumentSeedCatalog { private DemoDocumentSeedCatalog() { } - static List demoDocuments() { + static List demoDocuments(List tasks) { List documents = new ArrayList<>(84); addExistingDocuments(documents); int documentNumber = 8; @@ -42,11 +44,11 @@ static List demoDocuments() { additionIndex++; } } - return List.copyOf(documents); + return linkToTasks(documents, tasks); } - static List testDocuments() { - return List.of( + static List testDocuments(List tasks) { + return linkToTasks(List.of( testDocument(1, 1, DocumentType.PASSPORT_COPY, SubmissionStatus.VERIFIED, 120), testDocument(2, 1, DocumentType.ARC, SubmissionStatus.MISSING, 15), testDocument(3, 2, DocumentType.CONTRACT, SubmissionStatus.SUBMITTED, 45), @@ -55,7 +57,40 @@ static List testDocuments() { testDocument(6, 3, DocumentType.ARC, SubmissionStatus.SUBMITTED, 7), testDocument(7, 4, DocumentType.CONTRACT, SubmissionStatus.MISSING, -10), testDocument(8, 5, DocumentType.PERMIT, SubmissionStatus.VERIFIED, 240) - ); + ), tasks); + } + + private static List linkToTasks( + List documents, + List tasks + ) { + return documents.stream() + .map(document -> new DocumentSeed( + document.documentId(), + document.workerId(), + matchingTaskId(document, tasks), + document.documentType(), + document.submissionStatus(), + document.expiryDays(), + document.destination(), + document.note(), + document.fileId() + )) + .toList(); + } + + private static UUID matchingTaskId(DocumentSeed document, List tasks) { + TaskType taskType = switch (document.documentType()) { + case PASSPORT_COPY, ARC -> TaskType.STAY_PERIOD_EXTENSION; + case CONTRACT -> TaskType.RECONTRACT; + case PERMIT -> TaskType.EMPLOYMENT_PERIOD_EXTENSION; + }; + return tasks.stream() + .filter(task -> task.workerId().equals(document.workerId())) + .filter(task -> task.taskType() == taskType) + .map(TaskSeed::taskId) + .findFirst() + .orElse(null); } private static void addExistingDocuments(List documents) { @@ -232,6 +267,7 @@ private static DocumentSeed document( return new DocumentSeed( demoUuid(documentPrefix, documentNumber), demoUuid(workerPrefix, workerNumber), + null, documentType, status, expiryDays, diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java index 3e11f07..cfdde96 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java @@ -32,8 +32,8 @@ final class DemoOperationalSeedCatalog { private final List testTasks = DemoTaskSeedCatalog.testTasks(); private final List demoStoredFiles = DemoStoredFileSeedCatalog.demoStoredFiles(demoTasks); - private final List demoDocuments = DemoDocumentSeedCatalog.demoDocuments(); - private final List testDocuments = DemoDocumentSeedCatalog.testDocuments(); + private final List demoDocuments = DemoDocumentSeedCatalog.demoDocuments(demoTasks); + private final List testDocuments = DemoDocumentSeedCatalog.testDocuments(testTasks); private final List demoChecklists = DemoTaskWorkflowSeedCatalog.demoChecklists(demoTasks); private final List demoApprovals = @@ -540,6 +540,7 @@ record TaskSeed( record DocumentSeed( UUID documentId, UUID workerId, + UUID taskId, DocumentType documentType, SubmissionStatus submissionStatus, Integer expiryDays, diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java index 88c8c41..fb8c0ec 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java @@ -22,6 +22,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.core.JdbcTemplate; +import tools.jackson.databind.ObjectMapper; @Configuration(proxyBeanMethods = false) public class DemoOperationalSeedConfiguration { @@ -45,6 +46,7 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( AuditEventRepository auditEventRepository, EntityManager entityManager, JdbcTemplate jdbcTemplate, + ObjectMapper objectMapper, Clock clock, @Value("${app.file-storage.local-path}") String localFileStoragePath ) { @@ -54,6 +56,7 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( ); } DemoOperationalSeedCatalog catalog = new DemoOperationalSeedCatalog(); + DemoCaseSeeder caseSeeder = new DemoCaseSeeder(jdbcTemplate, objectMapper); DemoTaskSeeder taskSeeder = new DemoTaskSeeder(taskRepository, taskContentCodec); DemoStoredFileSeeder storedFileSeeder = new DemoStoredFileSeeder( storedFileRepository, @@ -104,6 +107,7 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( properties, clock, catalog, + caseSeeder, taskSeeder, documentSeeder, checklistSeeder, diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java index b659efb..ab660f1 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java @@ -31,6 +31,7 @@ class DemoOperationalSeedRunner implements ApplicationRunner { private final DemoAuthSeedProperties properties; private final Clock clock; private final DemoOperationalSeedCatalog catalog; + private final DemoCaseSeeder caseSeeder; private final DemoTaskSeeder taskSeeder; private final DemoWorkerDocumentSeeder documentSeeder; private final DemoTaskChecklistSeeder checklistSeeder; @@ -47,6 +48,7 @@ class DemoOperationalSeedRunner implements ApplicationRunner { DemoAuthSeedProperties properties, Clock clock, DemoOperationalSeedCatalog catalog, + DemoCaseSeeder caseSeeder, DemoTaskSeeder taskSeeder, DemoWorkerDocumentSeeder documentSeeder, DemoTaskChecklistSeeder checklistSeeder, @@ -62,6 +64,7 @@ class DemoOperationalSeedRunner implements ApplicationRunner { this.properties = Objects.requireNonNull(properties, "properties must not be null"); this.clock = Objects.requireNonNull(clock, "clock must not be null"); this.catalog = Objects.requireNonNull(catalog, "catalog must not be null"); + this.caseSeeder = Objects.requireNonNull(caseSeeder, "caseSeeder must not be null"); this.taskSeeder = Objects.requireNonNull(taskSeeder, "taskSeeder must not be null"); this.documentSeeder = Objects.requireNonNull(documentSeeder, "documentSeeder must not be null"); this.checklistSeeder = Objects.requireNonNull(checklistSeeder, "checklistSeeder must not be null"); @@ -156,6 +159,7 @@ private void seedDataset( List audits, DemoOperationalSeedContext context ) { + caseSeeder.seed(tasks, context); tasks.forEach(seed -> taskSeeder.seed(seed, context)); storedFiles.forEach(seed -> storedFileSeeder.seed(seed, context)); documents.forEach(seed -> documentSeeder.seed(seed, context)); diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java index 6c8e035..916b17c 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java @@ -29,14 +29,32 @@ void seed(DocumentSeed seed, DemoOperationalSeedContext context) { context.companyId() ); if (existing.isPresent()) { - verifyExisting(existing.get(), seed, context); + WorkerDocument document = existing.get(); + verifyExistingExceptTask(document, seed, context); + if (!Objects.equals(document.taskId(), seed.taskId())) { + workerDocumentRepository.update(new WorkerDocument( + document.workerDocumentId(), + document.workerId(), + document.companyId(), + seed.taskId(), + document.documentType(), + document.submissionStatus(), + document.expiryDate(), + document.destination(), + document.note(), + document.fileId(), + document.createdAt(), + context.now(), + document.version() + )); + } return; } workerDocumentRepository.insert(new WorkerDocument( seed.documentId(), seed.workerId(), context.companyId(), - null, + seed.taskId(), seed.documentType(), seed.submissionStatus(), expiryDate, @@ -53,6 +71,19 @@ void verifyExisting( WorkerDocument document, DocumentSeed seed, DemoOperationalSeedContext context + ) { + verifyExistingExceptTask(document, seed, context); + if (!Objects.equals(seed.taskId(), document.taskId())) { + throw new IllegalStateException( + "a reserved demo worker document id already belongs to different task data" + ); + } + } + + private void verifyExistingExceptTask( + WorkerDocument document, + DocumentSeed seed, + DemoOperationalSeedContext context ) { if (!seed.documentId().equals(document.workerDocumentId()) || !seed.workerId().equals(document.workerId()) 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 ff239cf..8574c70 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java +++ b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java @@ -17,6 +17,7 @@ import com.fowoco.server.task.application.TaskContentCodec.EncodedTaskContent; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.port.TaskChecklistRepository; +import com.fowoco.server.task.application.port.TaskCaseRegistrar; import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.task.application.port.TaskRepository.TaskPage; import com.fowoco.server.task.application.port.TaskRepository.TaskSearchCriteria; @@ -49,6 +50,7 @@ public class TaskWorkflowService { private final TenantDatabaseContext tenantDatabaseContext; private final TaskRepository taskRepository; private final TaskChecklistRepository checklistRepository; + private final TaskCaseRegistrar taskCaseRegistrar; private final TaskTransitionRecorder transitionRecorder; private final WorkerTaskContextReader workerReader; private final WorkflowCatalogService catalogService; @@ -64,6 +66,7 @@ public TaskWorkflowService( TenantDatabaseContext tenantDatabaseContext, TaskRepository taskRepository, TaskChecklistRepository checklistRepository, + TaskCaseRegistrar taskCaseRegistrar, TaskTransitionRecorder transitionRecorder, WorkerTaskContextReader workerReader, WorkflowCatalogService catalogService, @@ -78,6 +81,7 @@ public TaskWorkflowService( this.tenantDatabaseContext = tenantDatabaseContext; this.taskRepository = taskRepository; this.checklistRepository = checklistRepository; + this.taskCaseRegistrar = taskCaseRegistrar; this.transitionRecorder = transitionRecorder; this.workerReader = workerReader; this.catalogService = catalogService; @@ -123,11 +127,13 @@ public TaskResult create( businessData ); Instant now = Instant.now(clock); + UUID taskId = uuidGenerator.generate(); + UUID caseId = command.caseId() == null ? uuidGenerator.generate() : command.caseId(); Task task = Task.create( - uuidGenerator.generate(), + taskId, actor.companyId(), command.workerId(), - command.caseId() == null ? uuidGenerator.generate() : command.caseId(), + caseId, command.taskType(), workflow.workflowId(), catalogService.getActiveCatalog().bundleVersion(), @@ -141,6 +147,7 @@ public TaskResult create( actor.actorId(), now ); + taskCaseRegistrar.register(task, workflow, LocalDate.now(clock)); Task savedTask = taskRepository.save(task); List checklistItems = checklistRepository.saveAll( workflow.checklistItems().stream() diff --git a/src/main/java/com/fowoco/server/task/application/error/TaskErrorCode.java b/src/main/java/com/fowoco/server/task/application/error/TaskErrorCode.java index 6149a89..96f7a7c 100644 --- a/src/main/java/com/fowoco/server/task/application/error/TaskErrorCode.java +++ b/src/main/java/com/fowoco/server/task/application/error/TaskErrorCode.java @@ -21,6 +21,7 @@ public enum TaskErrorCode implements ApiErrorCode { "업무카드에 저장할 수 없는 개인정보 또는 Secret이 포함되어 있습니다." ), CHECKLIST_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "체크리스트 항목을 찾을 수 없습니다."), + CASE_WORKER_MISMATCH(HttpStatus.CONFLICT, "Case와 업무카드의 근로자가 일치하지 않습니다."), CONCURRENT_MODIFICATION(HttpStatus.CONFLICT, "업무카드가 다른 요청에서 변경되었습니다."), TASK_TRANSITION_NOT_ALLOWED( HttpStatus.UNPROCESSABLE_CONTENT, diff --git a/src/main/java/com/fowoco/server/task/application/port/TaskCaseRegistrar.java b/src/main/java/com/fowoco/server/task/application/port/TaskCaseRegistrar.java new file mode 100644 index 0000000..ede9f5e --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/port/TaskCaseRegistrar.java @@ -0,0 +1,10 @@ +package com.fowoco.server.task.application.port; + +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.workflow.domain.WorkflowDefinition; +import java.time.LocalDate; + +public interface TaskCaseRegistrar { + + void register(Task task, WorkflowDefinition workflow, LocalDate today); +} diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/WorkerDocumentJpaEntity.java b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/WorkerDocumentJpaEntity.java index dfd67c4..b77cb86 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/WorkerDocumentJpaEntity.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/WorkerDocumentJpaEntity.java @@ -143,6 +143,7 @@ public void applyState(WorkerDocument document) { if (version != document.version()) { throw new IllegalArgumentException("worker document version does not match"); } + this.taskId = document.taskId(); this.documentType = document.documentType(); this.submissionStatus = document.submissionStatus(); this.expiryDate = document.expiryDate(); diff --git a/src/main/resources/db/migration-postgresql/V19__prepare_workflow_case_rls.sql b/src/main/resources/db/migration-postgresql/V19__prepare_workflow_case_rls.sql new file mode 100644 index 0000000..5906499 --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V19__prepare_workflow_case_rls.sql @@ -0,0 +1,12 @@ +CREATE POLICY pl_workflow_case_tenant_isolation + ON public.workflow_case + 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 + ); diff --git a/src/main/resources/db/migration/V18__create_workflow_case.sql b/src/main/resources/db/migration/V18__create_workflow_case.sql new file mode 100644 index 0000000..7ef6f07 --- /dev/null +++ b/src/main/resources/db/migration/V18__create_workflow_case.sql @@ -0,0 +1,42 @@ +CREATE TABLE workflow_case ( + case_id UUID NOT NULL, + company_id UUID NOT NULL, + worker_id UUID NOT NULL, + title VARCHAR(160) NOT NULL, + lifecycle_status VARCHAR(20) NOT NULL, + priority VARCHAR(20) NOT NULL, + workflow_catalog_version VARCHAR(80) NOT NULL, + workflow_snapshot_json TEXT NOT NULL, + created_by UUID NOT NULL, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT pk_workflow_case PRIMARY KEY (case_id), + CONSTRAINT uq_workflow_case_id_company UNIQUE (case_id, company_id), + CONSTRAINT fk_workflow_case_company + FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, + CONSTRAINT fk_workflow_case_worker_company + FOREIGN KEY (worker_id, company_id) + REFERENCES worker (worker_id, company_id) ON DELETE CASCADE, + CONSTRAINT fk_workflow_case_created_by_company + FOREIGN KEY (created_by, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT, + CONSTRAINT ck_workflow_case_title_not_blank CHECK (CHAR_LENGTH(TRIM(title)) > 0), + CONSTRAINT ck_workflow_case_lifecycle_status CHECK ( + lifecycle_status IN ('ACTIVE', 'COMPLETED', 'CANCELLED') + ), + CONSTRAINT ck_workflow_case_priority CHECK ( + priority IN ('URGENT', 'HIGH', 'NORMAL', 'LOW') + ), + CONSTRAINT ck_workflow_case_catalog_version_not_blank + CHECK (CHAR_LENGTH(TRIM(workflow_catalog_version)) > 0), + CONSTRAINT ck_workflow_case_snapshot_not_blank + CHECK (CHAR_LENGTH(TRIM(workflow_snapshot_json)) > 0), + CONSTRAINT ck_workflow_case_version CHECK (version >= 0), + CONSTRAINT ck_workflow_case_updated_at CHECK (updated_at >= created_at) +); + +CREATE INDEX idx_workflow_case_company_updated + ON workflow_case (company_id, updated_at); +CREATE INDEX idx_workflow_case_company_worker + ON workflow_case (company_id, worker_id); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 0cc06a9..e7cb8f1 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -82,7 +82,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "ai_run", "ai_attempt", "ai_question", - "ai_candidate" + "ai_candidate", + "workflow_case" ); assertThat(columnSpecs(connection, "company")) @@ -200,6 +201,13 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("ai_attempt_id", new ColumnSpec("uuid", false)) .containsEntry("worker_id", new ColumnSpec("uuid", false)) .containsEntry("confidence", new ColumnSpec("numeric", false)); + assertThat(columnSpecs(connection, "workflow_case")) + .containsEntry("case_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("worker_id", new ColumnSpec("uuid", false)) + .containsEntry("lifecycle_status", new ColumnSpec("varchar", false)) + .containsEntry("workflow_snapshot_json", new ColumnSpec("text", false)) + .containsEntry("version", new ColumnSpec("int8", false)); assertThat(constraintNames(connection)) .contains( @@ -237,7 +245,11 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pk_ai_question", "fk_ai_question_attempt_company", "pk_ai_candidate", - "fk_ai_candidate_worker_company" + "fk_ai_candidate_worker_company", + "pk_workflow_case", + "uq_workflow_case_id_company", + "fk_workflow_case_worker_company", + "fk_workflow_case_created_by_company" ); assertThat(indexNames(connection)) .contains( @@ -258,7 +270,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { "idx_ai_run_company_created", "idx_ai_attempt_run", "idx_ai_question_run", - "idx_ai_candidate_run" + "idx_ai_candidate_run", + "idx_workflow_case_company_updated", + "idx_workflow_case_company_worker" ); assertThat(policyNames(connection)) .containsExactlyInAnyOrder( @@ -283,6 +297,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_ai_attempt_tenant_isolation", "pl_ai_question_tenant_isolation", "pl_ai_candidate_tenant_isolation", + "pl_workflow_case_tenant_isolation", "pl_worker_link_tenant_isolation", "pl_worker_response_tenant_isolation", "pl_worker_response_upload_tenant_isolation" @@ -344,6 +359,17 @@ INSERT INTO worker ( '%s', '%s', 'Worker A', 'VNM', 'vi', 'ACTIVE', CURRENT_DATE + 30 ) """.formatted(WORKER_A, COMPANY_A)); + execute(connection, """ + INSERT INTO workflow_case ( + case_id, company_id, worker_id, title, lifecycle_status, + priority, workflow_catalog_version, workflow_snapshot_json, + created_by, created_at, updated_at + ) VALUES ( + '14000000-0000-0000-0000-000000000001', '%s', '%s', + 'Recontract case', 'ACTIVE', 'NORMAL', '2026.07', '{}', '%s', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """.formatted(COMPANY_A, WORKER_A, USER_A)); execute(connection, """ INSERT INTO task ( task_id, company_id, worker_id, case_id, task_type, @@ -515,6 +541,17 @@ INSERT INTO refresh_token ( "23503", "DELETE FROM company WHERE company_id = '%s'".formatted(COMPANY_A) ); + assertSqlState(connection, "23503", """ + INSERT INTO workflow_case ( + case_id, company_id, worker_id, title, lifecycle_status, + priority, workflow_catalog_version, workflow_snapshot_json, + created_by, created_at, updated_at + ) VALUES ( + '14000000-0000-0000-0000-000000000002', '%s', '%s', + 'Wrong tenant case', 'ACTIVE', 'NORMAL', '2026.07', '{}', '%s', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """.formatted(COMPANY_B, WORKER_A, USER_B)); assertSqlState(connection, "23503", """ INSERT INTO approval_request ( approval_request_id, task_id, company_id, diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index d01a21f..58a3153 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -105,6 +105,7 @@ class DemoAuthSeedIntegrationTest { Map.entry("user_account", 20), Map.entry("worker", 28), Map.entry("task", 24), + Map.entry("workflow_case", 22), Map.entry("worker_document", 84), Map.entry("stored_file", 3), Map.entry("task_checklist_item", 68), @@ -119,6 +120,7 @@ class DemoAuthSeedIntegrationTest { "user_account", 3, "worker", 5, "task", 3, + "workflow_case", 3, "worker_document", 8, "audit_event", 8 ); @@ -412,12 +414,13 @@ private void assertCompoundDraftScenario() { )).isEqualTo(7); Map> documentsByType = jdbcTemplate.query( - "SELECT document_type, submission_status, expiry_date, destination, note " + "SELECT document_type, task_id, submission_status, expiry_date, destination, note " + "FROM worker_document WHERE worker_id = ? AND company_id = ?", resultSet -> { Map> result = new LinkedHashMap<>(); while (resultSet.next()) { Map document = new LinkedHashMap<>(); + document.put("task_id", resultSet.getObject("task_id", UUID.class)); document.put("submission_status", resultSet.getString("submission_status")); document.put("expiry_date", resultSet.getObject("expiry_date", LocalDate.class)); document.put("destination", resultSet.getString("destination")); @@ -431,12 +434,15 @@ private void assertCompoundDraftScenario() { ); assertThat(documentsByType).containsOnlyKeys("PASSPORT_COPY", "ARC", "CONTRACT"); assertThat(documentsByType.get("PASSPORT_COPY")) + .containsEntry("task_id", PASSPORT_REQUEST_TASK_ID) .containsEntry("submission_status", "MISSING") .containsEntry("destination", "근로자 문서 요청"); assertThat(documentsByType.get("PASSPORT_COPY").get("expiry_date")).isNull(); assertThat(documentsByType.get("ARC")) + .containsEntry("task_id", PASSPORT_REQUEST_TASK_ID) .containsEntry("submission_status", "VERIFIED"); assertThat(documentsByType.get("CONTRACT")) + .containsEntry("task_id", RECONTRACT_CANDIDATE_TASK_ID) .containsEntry("submission_status", "VERIFIED"); } @@ -778,7 +784,7 @@ private Map>> seedSnapshot() { + "WHERE company_id IN (?, ?) ORDER BY company_id, worker_id" )); snapshot.put("worker_document", snapshotRows( - "SELECT worker_document_id, company_id, expiry_date, file_id, " + "SELECT worker_document_id, company_id, task_id, expiry_date, file_id, " + "created_at, updated_at, version " + "FROM worker_document WHERE company_id IN (?, ?) " + "ORDER BY company_id, worker_document_id" @@ -794,6 +800,12 @@ private Map>> seedSnapshot() { + "content_revision, created_at, updated_at, version FROM task " + "WHERE company_id IN (?, ?) ORDER BY company_id, task_id" )); + snapshot.put("workflow_case", snapshotRows( + "SELECT case_id, company_id, worker_id, title, lifecycle_status, priority, " + + "workflow_catalog_version, workflow_snapshot_json, created_by, " + + "created_at, updated_at, version FROM workflow_case " + + "WHERE company_id IN (?, ?) ORDER BY company_id, case_id" + )); snapshot.put("task_checklist_item", snapshotRows( "SELECT checklist_item_id, company_id, completed_at, created_at, updated_at, version " + "FROM task_checklist_item WHERE company_id IN (?, ?) " @@ -874,6 +886,31 @@ private void assertFrontendRelevantApiResultsAndTenantIsolation() throws Excepti assertThat(JsonPath.read(tasks.body(), "$.total_elements").intValue()).isEqualTo(24); List> taskItems = JsonPath.read(tasks.body(), "$.items"); assertThat(taskItems).hasSize(24); + HttpResponse compoundCase = authorizedGet( + "/api/v1/cases/" + COMPOUND_CASE_ID + "/projection", + demoToken + ); + assertOk(compoundCase); + assertThat(JsonPath.read(compoundCase.body(), "$.display_status")) + .isEqualTo("REVIEW_REQUIRED"); + assertThat(JsonPath.read(compoundCase.body(), "$.current_task.task_id")) + .isEqualTo(RECONTRACT_CANDIDATE_TASK_ID.toString()); + assertThat(JsonPath.read( + compoundCase.body(), + "$.readiness.verified_documents" + ).intValue()).isEqualTo(2); + assertThat(JsonPath.read( + compoundCase.body(), + "$.readiness.total_documents" + ).intValue()).isEqualTo(3); + assertThat(JsonPath.>read( + compoundCase.body(), + "$.tasks[*].task_id" + )).containsExactly( + RECONTRACT_CANDIDATE_TASK_ID.toString(), + PASSPORT_REQUEST_TASK_ID.toString(), + EMPLOYMENT_EXTENSION_CANDIDATE_TASK_ID.toString() + ); assertStatusApiCount(demoToken, "READY_FOR_REVIEW", 4); assertStatusApiCount(demoToken, "DRAFT", 3); assertThat(taskItems).anyMatch(task -> today.toString().equals(task.get("due_date"))); diff --git a/src/test/java/com/fowoco/server/casework/CaseQueryIntegrationTest.java b/src/test/java/com/fowoco/server/casework/CaseQueryIntegrationTest.java new file mode 100644 index 0000000..0d21414 --- /dev/null +++ b/src/test/java/com/fowoco/server/casework/CaseQueryIntegrationTest.java @@ -0,0 +1,389 @@ +package com.fowoco.server.casework; + +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.LocalDate; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +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) +class CaseQueryIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("ca000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("cb000000-0000-0000-0000-000000000001"); + private static final UUID HR_A = UUID.fromString("ca100000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("cb100000-0000-0000-0000-000000000001"); + private static final UUID WORKER_A = UUID.fromString("ca200000-0000-0000-0000-000000000001"); + private static final UUID WORKER_B = UUID.fromString("cb200000-0000-0000-0000-000000000001"); + private static final UUID CASE_A = UUID.fromString("ca300000-0000-0000-0000-000000000001"); + private static final UUID CASE_B = UUID.fromString("cb300000-0000-0000-0000-000000000001"); + private static final UUID CASE_CANCELLED = + UUID.fromString("ca300000-0000-0000-0000-000000000002"); + private static final UUID CASE_ORDER_FIRST = + UUID.fromString("ca310000-0000-0000-0000-000000000001"); + private static final UUID CASE_ORDER_SECOND = + UUID.fromString("ca320000-0000-0000-0000-000000000001"); + private static final UUID TASK_A_DONE = UUID.fromString("ca400000-0000-0000-0000-000000000001"); + private static final UUID TASK_A_WAITING = UUID.fromString("ca400000-0000-0000-0000-000000000002"); + private static final UUID TASK_B = UUID.fromString("cb400000-0000-0000-0000-000000000001"); + private static final UUID TASK_CANCELLED = + UUID.fromString("ca400000-0000-0000-0000-000000000003"); + private static final String PASSWORD = "Test-password-1!"; + private static final String HR_A_EMAIL = "case.hr.a@example.com"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeEach + void resetAndSeed() { + cleanDatabase(); + + insertCompany(COMPANY_A, "Case 테스트 사업장 A"); + insertCompany(COMPANY_B, "Case 테스트 사업장 B"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash); + insertUser(HR_B, COMPANY_B, "case.hr.b@example.com", passwordHash); + insertWorker(WORKER_A, COMPANY_A, "응웬반안"); + insertWorker(WORKER_B, COMPANY_B, "다른 사업장 근로자"); + insertCase(CASE_A, COMPANY_A, WORKER_A, HR_A, "재계약·연장 준비", "URGENT"); + insertCase(CASE_B, COMPANY_B, WORKER_B, HR_B, "다른 사업장 Case", "NORMAL"); + insertCancelledCase(); + insertTask(TASK_A_DONE, CASE_A, COMPANY_A, WORKER_A, HR_A, "RECONTRACT", "COMPLETED", 10); + insertTask( + TASK_A_WAITING, + CASE_A, + COMPANY_A, + WORKER_A, + HR_A, + "STAY_PERIOD_EXTENSION", + "WAITING_WORKER", + 5 + ); + insertTask(TASK_B, CASE_B, COMPANY_B, WORKER_B, HR_B, "RECONTRACT", "DRAFT", 20); + insertTask( + TASK_CANCELLED, + CASE_CANCELLED, + COMPANY_A, + WORKER_A, + HR_A, + "RECONTRACT", + "CANCELLED", + 30 + ); + insertWorkerResponse(); + } + + @AfterEach + void cleanAfterTest() { + cleanDatabase(); + } + + private void cleanDatabase() { + jdbcTemplate.update("DELETE FROM worker_response"); + jdbcTemplate.update("DELETE FROM worker_link"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM workflow_case"); + 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"); + } + + @Test + void returnsCaseProjectionOnlyInsideTheAuthenticatedCompany() throws Exception { + String token = login(); + + HttpResponse page = get("/api/v1/cases?page=0&size=20", token); + + assertThat(page.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(page.body(), "$.total_elements").intValue()).isEqualTo(2); + assertThat(JsonPath.>read(page.body(), "$.items[*].case_id")) + .containsExactly(CASE_A.toString(), CASE_CANCELLED.toString()); + assertThat(JsonPath.read(page.body(), "$.items[0].display_status")) + .isEqualTo("REVIEW_REQUIRED"); + assertThat(JsonPath.read(page.body(), "$.items[0].has_unread_response")).isTrue(); + assertThat(JsonPath.read(page.body(), "$.items[0].progress.percentage").intValue()) + .isEqualTo(50); + assertThat(JsonPath.read(page.body(), "$.items[1].display_status")) + .isEqualTo("CANCELLED"); + + HttpResponse detail = get("/api/v1/cases/" + CASE_A + "/projection", token); + assertThat(detail.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(detail.body(), "$.worker_display_name")) + .isEqualTo("응웬반안"); + assertThat(JsonPath.read(detail.body(), "$.current_task.task_id")) + .isEqualTo(TASK_A_WAITING.toString()); + assertThat(JsonPath.>read(detail.body(), "$.tasks")).hasSize(2); + assertThat(JsonPath.read(detail.body(), "$.readiness.worker_responses").intValue()) + .isEqualTo(1); + assertThat(JsonPath.read(detail.body(), "$.readiness.pending_approvals").intValue()) + .isZero(); + assertThat(JsonPath.read(detail.body(), "$.workflow_snapshot.steps[0].order").intValue()) + .isEqualTo(1); + assertThat(JsonPath.read( + detail.body(), + "$.workflow_snapshot.steps[1].required_conditions.depends_on_task_id" + )).isEqualTo(TASK_A_DONE.toString()); + + HttpResponse hidden = get("/api/v1/cases/" + CASE_B + "/projection", token); + assertThat(hidden.statusCode()).isEqualTo(404); + } + + @Test + void documentsCaseEndpointsInOpenApi() throws Exception { + HttpResponse response = getWithoutToken("/v3/api-docs"); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(response.body(), "$.paths['/api/v1/cases'].get.operationId")) + .isEqualTo("listCases"); + assertThat(JsonPath.read( + response.body(), + "$.paths['/api/v1/cases/{caseId}/projection'].get.operationId" + )).isEqualTo("getCaseProjection"); + } + + @Test + void keepsStableCaseOrderAcrossPagesWhenPriorityAndUpdatedAtAreEqual() throws Exception { + insertOrderingCase(CASE_ORDER_SECOND, "정렬 Case B"); + insertOrderingCase(CASE_ORDER_FIRST, "정렬 Case A"); + String token = login(); + + HttpResponse firstPage = get("/api/v1/cases?page=0&size=2", token); + HttpResponse secondPage = get("/api/v1/cases?page=1&size=2", token); + + assertThat(JsonPath.>read(firstPage.body(), "$.items[*].case_id")) + .containsExactly(CASE_A.toString(), CASE_ORDER_FIRST.toString()); + assertThat(JsonPath.>read(secondPage.body(), "$.items[*].case_id")) + .containsExactly(CASE_ORDER_SECOND.toString(), CASE_CANCELLED.toString()); + } + + private String login() throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/auth/login")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + "{\"email\":\"%s\",\"password\":\"%s\"}".formatted(HR_A_EMAIL, PASSWORD) + )) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + return JsonPath.read(response.body(), "$.access_token"); + } + + private HttpResponse get(String path, String token) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .GET() + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse getWithoutToken(String path) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)).GET().build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } + + private void insertCompany(UUID id, String name) { + jdbcTemplate.update( + "INSERT INTO company (company_id, name, status, created_at, updated_at, version)" + + " VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + id, + name + ); + } + + private void insertUser(UUID id, 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) + """, + id, + companyId, + email, + email, + passwordHash + ); + } + + private void insertWorker(UUID id, UUID companyId, String displayName) { + jdbcTemplate.update( + """ + INSERT INTO worker ( + worker_id, company_id, display_name, work_status, + created_at, updated_at, version + ) VALUES (?, ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + id, + companyId, + displayName + ); + } + + private void insertCase( + UUID caseId, + UUID companyId, + UUID workerId, + UUID actorId, + String title, + String priority + ) { + jdbcTemplate.update( + """ + INSERT INTO workflow_case ( + case_id, company_id, worker_id, title, lifecycle_status, priority, + workflow_catalog_version, workflow_snapshot_json, created_by, + created_at, updated_at, version + ) VALUES (?, ?, ?, ?, 'ACTIVE', ?, '0.2.0', ?, ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + caseId, + companyId, + workerId, + title, + priority, + caseId.equals(CASE_A) ? """ + {"steps":[ + {"order":1,"task_id":"%s","required_conditions":{}}, + {"order":2,"task_id":"%s","required_conditions":{ + "depends_on_task_id":"%s" + }} + ]} + """.formatted(TASK_A_DONE, TASK_A_WAITING, TASK_A_DONE) : "{}", + actorId + ); + } + + private void insertTask( + UUID taskId, + UUID caseId, + UUID companyId, + UUID workerId, + UUID actorId, + String taskType, + String status, + int dueDays + ) { + jdbcTemplate.update( + """ + INSERT INTO task ( + task_id, company_id, worker_id, case_id, task_type, workflow_id, + workflow_catalog_version, title, description, business_data_json, + critical_fingerprint, content_revision, source, status, due_date, + created_by, updated_by, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, 'WF-DEMO-001', '0.2.0', ?, NULL, '{}', ?, 0, + 'MANUAL', ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + taskId, + companyId, + workerId, + caseId, + taskType, + taskType + " 준비", + "a".repeat(64), + status, + LocalDate.now().plusDays(dueDays), + actorId, + actorId + ); + } + + private void insertCancelledCase() { + jdbcTemplate.update( + """ + INSERT INTO workflow_case ( + case_id, company_id, worker_id, title, lifecycle_status, priority, + workflow_catalog_version, workflow_snapshot_json, created_by, + created_at, updated_at, version + ) VALUES (?, ?, ?, '재계약 검토 취소', 'CANCELLED', 'LOW', '0.2.0', '{}', ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + CASE_CANCELLED, + COMPANY_A, + WORKER_A, + HR_A + ); + } + + private void insertOrderingCase(UUID caseId, String title) { + jdbcTemplate.update( + """ + INSERT INTO workflow_case ( + case_id, company_id, worker_id, title, lifecycle_status, priority, + workflow_catalog_version, workflow_snapshot_json, created_by, + created_at, updated_at, version + ) VALUES (?, ?, ?, ?, 'ACTIVE', 'NORMAL', '0.2.0', '{}', ?, ?, ?, 0) + """, + caseId, + COMPANY_A, + WORKER_A, + title, + HR_A, + java.time.Instant.parse("2026-08-01T00:00:00Z"), + java.time.Instant.parse("2026-08-01T00:00:00Z") + ); + } + + private void insertWorkerResponse() { + UUID linkId = UUID.fromString("ca500000-0000-0000-0000-000000000001"); + jdbcTemplate.update( + """ + INSERT INTO worker_link ( + worker_link_id, task_id, company_id, token_hash, expires_at, status, + conversation_status, issued_by, idempotency_key, + created_at, updated_at, version + ) VALUES (?, ?, ?, ?, DATEADD('DAY', 1, CURRENT_TIMESTAMP), 'ACTIVE', + 'NEEDS_FOLLOWUP', ?, 'case-test-link', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + linkId, + TASK_A_WAITING, + COMPANY_A, + "b".repeat(64), + HR_A + ); + jdbcTemplate.update( + """ + INSERT INTO worker_response ( + response_id, worker_link_id, company_id, response_type, + message, idempotency_key, received_at + ) VALUES (?, ?, ?, 'QUESTION', '질문이 있습니다.', 'case-test-response', CURRENT_TIMESTAMP) + """, + UUID.fromString("ca600000-0000-0000-0000-000000000001"), + linkId, + COMPANY_A + ); + } +} diff --git a/src/test/java/com/fowoco/server/casework/application/CaseDisplayStatusResolverTest.java b/src/test/java/com/fowoco/server/casework/application/CaseDisplayStatusResolverTest.java index c7e576f..3180afc 100644 --- a/src/test/java/com/fowoco/server/casework/application/CaseDisplayStatusResolverTest.java +++ b/src/test/java/com/fowoco/server/casework/application/CaseDisplayStatusResolverTest.java @@ -1,6 +1,7 @@ package com.fowoco.server.casework.application; import static com.fowoco.server.casework.domain.CaseDisplayStatus.COMPLETED; +import static com.fowoco.server.casework.domain.CaseDisplayStatus.CANCELLED; import static com.fowoco.server.casework.domain.CaseDisplayStatus.DOCUMENT_PENDING; import static com.fowoco.server.casework.domain.CaseDisplayStatus.REQUEST_SENT; import static com.fowoco.server.casework.domain.CaseDisplayStatus.REVIEW_REQUIRED; @@ -46,20 +47,39 @@ void showsReviewRequiredAndUnreadBadgeForNewWorkerResponse() { @Test void completedTakesPriorityAndClearsUnreadBadge() { - CaseDisplayState result = resolver.resolve(facts(true, true, true, true)); + CaseDisplayState result = resolver.resolve(facts(true, false, true, true, true)); assertThat(result.status()).isEqualTo(COMPLETED); assertThat(result.hasUnreadResponse()).isFalse(); } + @Test + void cancelledTakesPriorityAndClearsUnreadBadge() { + CaseDisplayState result = resolver.resolve(facts(false, true, true, true, true)); + + assertThat(result.status()).isEqualTo(CANCELLED); + assertThat(result.hasUnreadResponse()).isFalse(); + } + + private CaseDisplayFacts facts( + boolean completed, + boolean linkIssued, + boolean reviewRequired, + boolean unreadResponse + ) { + return facts(completed, false, linkIssued, reviewRequired, unreadResponse); + } + private CaseDisplayFacts facts( boolean completed, + boolean cancelled, boolean linkIssued, boolean reviewRequired, boolean unreadResponse ) { return new CaseDisplayFacts( completed, + cancelled, linkIssued, reviewRequired, unreadResponse diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java index 8494fa6..29f2278 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java @@ -45,6 +45,12 @@ class PostgreSqlRlsIsolationTest { UUID.fromString("a6000000-0000-0000-0000-000000000001"); private static final UUID DRAFT_B = UUID.fromString("b6000000-0000-0000-0000-000000000002"); + private static final UUID CASE_A = + UUID.fromString("a7000000-0000-0000-0000-000000000001"); + private static final UUID CASE_B = + UUID.fromString("b7000000-0000-0000-0000-000000000002"); + private static final UUID CASE_A_NEW = + UUID.fromString("a7000000-0000-0000-0000-000000000003"); @Test void restrictedRoleEnforcesTenantCrudAndFailsClosedWithoutValidContext() @@ -114,7 +120,7 @@ private void prepareFixture( "GRANT SELECT, INSERT, UPDATE, DELETE " + "ON TABLE public.company, public.worker, " + "public.stored_file, public.document_request_draft, " - + "public.document_request_draft_type TO " + + "public.document_request_draft_type, public.workflow_case TO " + quotedRole ); @@ -142,6 +148,20 @@ INSERT INTO user_account ( ('%s', '%s', 'rls-b@example.com', 'rls-b@example.com', 'test-password-hash-b', 'ADMIN', 'ACTIVE') """.formatted(USER_A, COMPANY_A, USER_B, COMPANY_B)); + statement.execute(""" + INSERT INTO workflow_case ( + case_id, company_id, worker_id, title, lifecycle_status, + priority, workflow_catalog_version, workflow_snapshot_json, + created_by, created_at, updated_at + ) VALUES + ('%s', '%s', '%s', 'RLS Case A', 'ACTIVE', 'NORMAL', + '2026.07', '{}', '%s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('%s', '%s', '%s', 'RLS Case B', 'ACTIVE', 'NORMAL', + '2026.07', '{}', '%s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """.formatted( + CASE_A, COMPANY_A, WORKER_A, USER_A, + CASE_B, COMPANY_B, WORKER_B, USER_B + )); statement.execute(""" INSERT INTO task ( task_id, company_id, worker_id, case_id, task_type, @@ -188,6 +208,7 @@ INSERT INTO document_request_draft_type (draft_id, document_type) statement.execute("ALTER TABLE public.company ENABLE ROW LEVEL SECURITY"); statement.execute("ALTER TABLE public.worker ENABLE ROW LEVEL SECURITY"); statement.execute("ALTER TABLE public.stored_file ENABLE ROW LEVEL SECURITY"); + statement.execute("ALTER TABLE public.workflow_case ENABLE ROW LEVEL SECURITY"); statement.execute("ALTER TABLE public.document_request_draft ENABLE ROW LEVEL SECURITY"); statement.execute( "ALTER TABLE public.document_request_draft_type ENABLE ROW LEVEL SECURITY" @@ -203,6 +224,7 @@ private void assertMissingAndInvalidContextFailClosed(Connection connection) assertThat(tableCount(connection, "stored_file")).isZero(); assertThat(tableCount(connection, "document_request_draft")).isZero(); assertThat(tableCount(connection, "document_request_draft_type")).isZero(); + assertThat(tableCount(connection, "workflow_case")).isZero(); setTenantContext(connection, ""); assertThat(workerCount(connection)).isZero(); @@ -234,6 +256,10 @@ private void assertTenantCrudIsolation(Connection connection) throws SQLExceptio "SELECT document_type FROM public.document_request_draft_type " + "ORDER BY document_type" )).containsExactly("PASSPORT_COPY"); + assertThat(uuidValues( + connection, + "SELECT case_id FROM public.workflow_case ORDER BY case_id" + )).containsExactly(CASE_A); assertThat(executeUpdate( connection, """ @@ -244,6 +270,26 @@ INSERT INTO worker ( WORKER_A_NEW, COMPANY_A )).isOne(); + assertThat(executeUpdate( + connection, + """ + INSERT INTO workflow_case ( + case_id, company_id, worker_id, title, lifecycle_status, + priority, workflow_catalog_version, workflow_snapshot_json, + created_by, created_at, updated_at + ) VALUES (?, ?, ?, 'RLS Case A New', 'ACTIVE', 'NORMAL', + '2026.07', '{}', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + CASE_A_NEW, + COMPANY_A, + WORKER_A, + USER_A + )).isOne(); + assertThat(executeUpdate( + connection, + "UPDATE workflow_case SET title = 'RLS Case A Updated' WHERE case_id = ?", + CASE_A_NEW + )).isOne(); assertThat(executeUpdate( connection, "UPDATE worker SET display_name = 'Worker A Updated' WHERE worker_id = ?", @@ -261,6 +307,30 @@ INSERT INTO worker ( ) """.formatted(WORKER_B_NEW, COMPANY_B) ); + assertSqlState( + connection, + "42501", + """ + INSERT INTO workflow_case ( + case_id, company_id, worker_id, title, lifecycle_status, + priority, workflow_catalog_version, workflow_snapshot_json, + created_by, created_at, updated_at + ) VALUES ( + 'b7000000-0000-0000-0000-000000000099', '%s', '%s', + 'Forbidden Case B', 'ACTIVE', 'NORMAL', '2026.07', '{}', '%s', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """.formatted(COMPANY_B, WORKER_B, USER_B) + ); + assertSqlState( + connection, + "42501", + """ + UPDATE workflow_case + SET company_id = '%s', worker_id = '%s', created_by = '%s' + WHERE case_id = '%s' + """.formatted(COMPANY_B, WORKER_B, USER_B, CASE_A_NEW) + ); assertSqlState( connection, "42501", @@ -298,6 +368,21 @@ INSERT INTO document_request_draft_type (draft_id, document_type) "UPDATE worker SET display_name = 'Hidden Update' WHERE worker_id = ?", WORKER_B )).isZero(); + assertThat(executeUpdate( + connection, + "UPDATE workflow_case SET title = 'Hidden Update' WHERE case_id = ?", + CASE_B + )).isZero(); + assertThat(executeUpdate( + connection, + "DELETE FROM workflow_case WHERE case_id = ?", + CASE_B + )).isZero(); + assertThat(executeUpdate( + connection, + "DELETE FROM workflow_case WHERE case_id = ?", + CASE_A_NEW + )).isOne(); assertThat(executeUpdate( connection, "DELETE FROM worker WHERE worker_id = ?", @@ -320,8 +405,13 @@ private void assertCommittedContextDoesNotLeak(Connection connection) throws SQL connection.commit(); assertThat(workerCount(connection)).isZero(); + assertThat(tableCount(connection, "workflow_case")).isZero(); setTenantContext(connection, COMPANY_B.toString()); assertThat(workerIds(connection)).containsExactly(WORKER_B); + assertThat(uuidValues( + connection, + "SELECT case_id FROM public.workflow_case ORDER BY case_id" + )).containsExactly(CASE_B); connection.rollback(); } @@ -334,6 +424,7 @@ private void restoreFixture(Connection connection, String runtimeRole) throws SQ "ALTER TABLE public.document_request_draft DISABLE ROW LEVEL SECURITY" ); statement.execute("ALTER TABLE public.stored_file DISABLE ROW LEVEL SECURITY"); + statement.execute("ALTER TABLE public.workflow_case DISABLE ROW LEVEL SECURITY"); statement.execute("ALTER TABLE public.worker DISABLE ROW LEVEL SECURITY"); statement.execute("ALTER TABLE public.company DISABLE ROW LEVEL SECURITY"); deleteFixtureRows(statement); @@ -361,6 +452,10 @@ WHERE stored_file_id IN ('%s', '%s') DELETE FROM task WHERE task_id IN ('%s', '%s') """.formatted(TASK_A, TASK_B)); + statement.execute(""" + DELETE FROM workflow_case + WHERE case_id IN ('%s', '%s', '%s') + """.formatted(CASE_A, CASE_B, CASE_A_NEW)); statement.execute(""" DELETE FROM worker WHERE worker_id IN ( diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederSnapshotTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederSnapshotTest.java new file mode 100644 index 0000000..49079ff --- /dev/null +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederSnapshotTest.java @@ -0,0 +1,48 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.jayway.jsonpath.JsonPath; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import tools.jackson.databind.ObjectMapper; + +class DemoCaseSeederSnapshotTest { + + private static final UUID COMPOUND_CASE_ID = + UUID.fromString("94100000-0000-0000-0000-000000000006"); + + @Test + void keepsCandidateOrderAndAllowlistedWorkflowConditions() { + List compoundTasks = new DemoOperationalSeedCatalog().demoTasks().stream() + .filter(task -> task.caseId().equals(COMPOUND_CASE_ID)) + .toList(); + DemoCaseSeeder seeder = new DemoCaseSeeder( + mock(JdbcTemplate.class), + new ObjectMapper() + ); + + String snapshot = seeder.snapshot(compoundTasks); + + assertThat(JsonPath.>read(snapshot, "$.steps[*].order")) + .containsExactly(1, 2, 3); + assertThat(JsonPath.>read(snapshot, "$.steps[*].task_id")) + .containsExactly( + "94000000-0000-0000-0000-000000000006", + "94000000-0000-0000-0000-000000000008", + "94000000-0000-0000-0000-000000000007" + ); + assertThat(JsonPath.read( + snapshot, + "$.steps[0].required_conditions.approval_required" + )).isTrue(); + assertThat(JsonPath.read( + snapshot, + "$.steps[2].required_conditions.depends_on_task_id" + )).isEqualTo("94000000-0000-0000-0000-000000000006"); + } +} diff --git a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java index 0214a76..3781a48 100644 --- a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java +++ b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java @@ -97,6 +97,7 @@ void supportsTheCatalogTaskChecklistAndCancelApiFlow() throws Exception { assertThat(created.statusCode()).isEqualTo(201); assertThat(created.headers().firstValue(HttpHeaders.LOCATION)).isPresent(); UUID taskId = UUID.fromString(JsonPath.read(created.body(), "$.task_id")); + UUID caseId = UUID.fromString(JsonPath.read(created.body(), "$.case_id")); assertThat(JsonPath.read(created.body(), "$.status")).isEqualTo("DRAFT"); assertThat(JsonPath.read(created.body(), "$.workflow_catalog_version")) .isEqualTo("0.2.0"); @@ -112,6 +113,20 @@ void supportsTheCatalogTaskChecklistAndCancelApiFlow() throws Exception { ); assertThat(checklistIds).hasSize(2); + HttpResponse caseProjection = get( + "/api/v1/cases/" + caseId + "/projection", + token + ); + assertThat(caseProjection.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(caseProjection.body(), "$.current_task.task_id")) + .isEqualTo(taskId.toString()); + assertThat(JsonPath.>read(caseProjection.body(), "$.tasks[*].task_id")) + .containsExactly(taskId.toString()); + assertThat(JsonPath.read( + caseProjection.body(), + "$.workflow_snapshot.steps[0].task_id" + )).isEqualTo(taskId.toString()); + HttpResponse page = get( "/api/v1/tasks?worker_id=" + WORKER_A + "&status=DRAFT", token