diff --git a/.env.example b/.env.example index f7a117b..be6fe7d 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,13 @@ DB_PASSWORD= # React 개발 서버 또는 배포 Client 주소를 쉼표로 구분합니다. CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 +# Workflow Catalog는 fowoco/knowledge release로부터 만든 Server용 read-only projection입니다. +# local/test는 저장소의 개발용 DRAFT projection을 사용합니다. +# prod에서는 RELEASED projection 파일 위치를 반드시 지정해야 합니다. +# WORKFLOW_CATALOG_LOCATION=file:/app/config/catalog-projection.json +# local/dev에서 DRAFT projection 실험이 필요할 때만 true로 둡니다. prod는 항상 false입니다. +WORKFLOW_CATALOG_ALLOW_UNRELEASED=true + # Access Token 서명 키입니다. dev/prod에서는 반드시 32바이트 이상의 난수를 Base64로 넣습니다. # 생성 예시: openssl rand -base64 32 # local 프로필은 로컬 전용 기본 키를 사용합니다. dev/prod로 바꿀 때 아래 줄을 활성화하세요. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebe217c..0b775a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,15 +29,16 @@ docs/23-architecture-adr ### Commit과 PR 작성 규칙 - Commit message는 Conventional Commits 형식을 사용합니다. -- type과 scope는 영문으로 쓰고, commit subject도 간결한 영문으로 작성합니다. +- type과 scope는 영문으로 쓰고, commit subject와 필요한 설명은 팀이 읽기 쉬운 한국어로 작성합니다. +- class·method·API·JWT·Workflow 같은 코드 식별자와 일반 기술 용어는 억지로 번역하지 않습니다. - PR 제목은 한국어로 핵심을 설명합니다. 코드 식별자와 일반적인 기술 용어는 영어를 그대로 사용해도 됩니다. - PR 본문은 변경 이유와 영향, 검증 결과가 명확하게 전달되는 것을 우선합니다. 코드 식별자와 기술 용어는 억지로 번역하지 않습니다. ```text -feat(auth): implement access token refresh -fix(worker-link): validate token expiration -chore: configure server development environment -docs: update local setup guide +feat(auth): Access Token 재발급 구현 +fix(worker-link): token 만료 검증 보완 +chore: 서버 개발환경 구성 +docs: 로컬 실행 가이드 갱신 PR title: feat: 인증 API와 Refresh Token rotation 구현 ``` diff --git a/README.md b/README.md index 18cce9e..0dc9cc2 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,13 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 체류·계약·서류·신 | 항목 | 현재 | | --- | --- | | 기술 | Java 17, Spring Boot 4.1.0, Gradle | -| 구현 API | Health·Auth 5개, Approval·Audit 8개. 전체 계약은 실행 중인 Swagger에서 확인 | +| 구현 API | Health, Auth 5개, Task Workflow 7개, Approval·Audit 8개. 전체 계약은 실행 중인 Swagger에서 확인 | | 계획 API | Wiki API 카탈로그와 관련 Issue에서 설계·추적 | | 로컬 DB | H2 + Flyway Auth·Company·Worker core·Task core·Approval·Audit schema | | 개발·배포 DB | PostgreSQL + Flyway | | 보안 | JWT Access Token, `ADMIN`·`HR`·`VIEWER` 역할, `company_id` 기반 ActorContext | | 개발 기반 | Swagger UI, 공통 오류, `request_id`, CI 구성 완료 | -| AI·Workflow | Task 상태 전이 core와 승인·감사 구현, Task CRUD·AI 연동은 후속 Issue | +| AI·Workflow | Knowledge Catalog projection, Task·Checklist·승인·감사 구현. AiRun 연동은 후속 Issue | 계획 문서는 현재 동작하는 API가 아닙니다. 구현의 원본은 코드·테스트와 실행 시 생성되는 OpenAPI이고, 장기 아키텍처 결정은 [ADR](docs/adr/README.md), 계획 범위와 예시는 [API 카탈로그](https://github.com/fowoco/server/wiki/09-API-Specification)와 Issue에서 확인합니다. @@ -70,6 +70,30 @@ local은 기본 Profile이라 별도 데이터베이스가 필요하지 않습 로그아웃은 새 Access Token 발급 수단을 폐기하지만 이미 발급된 stateless JWT를 즉시 삭제하지는 못합니다. 현재 기본 설정에서는 기존 Access Token이 만료까지 최대 15분간 유효하므로 Client는 로그아웃 응답을 받는 즉시 메모리나 상태 저장소의 Access Token을 삭제해야 합니다. +### 업무카드·체크리스트 흐름 + +Task API는 `ADMIN`과 `HR`이 업무카드를 만들고 수정하게 하며, `VIEWER`는 같은 사업장의 업무만 조회할 수 있습니다. + +```text +GET /api/v1/workflow-catalogs +POST /api/v1/tasks +GET /api/v1/tasks +GET /api/v1/tasks/{taskId} +PATCH /api/v1/tasks/{taskId} +PATCH /api/v1/tasks/{taskId}/checklist-items/{itemId} +POST /api/v1/tasks/{taskId}/cancel +``` + +1. Server는 `fowoco/knowledge`가 소유한 Workflow release를 read-only projection으로 읽습니다. +2. Task를 만들 때 `workflow_id`와 `workflow_catalog_version`을 함께 고정합니다. +3. Workflow의 필수 slot이 부족하면 `NEEDS_INFO`, 충분하면 `DRAFT`로 생성합니다. +4. Checklist template은 Task별 항목으로 복사되며 Client가 임의 항목을 추가하거나 필수 여부를 바꾸지 못합니다. +5. 수정·체크·취소 요청은 응답에 있는 최신 `version`을 `expected_version`으로 보내야 합니다. 오래된 화면의 값이면 `409 CONCURRENT_MODIFICATION`입니다. +6. 승인된 날짜·금액·설명 같은 중요값을 바꾸면 기존 승인을 무효화합니다. 필수정보와 checklist가 충분하면 수정본 승인 snapshot을 새로 만들고 `READY_FOR_REVIEW`, 부족하면 `NEEDS_INFO`가 됩니다. +7. `status`와 `company_id`는 쓰기 요청으로 받지 않습니다. 상태는 명시적인 Server command가, 사업장은 JWT의 ActorContext가 결정합니다. + +로컬·테스트에서는 저장소의 `catalog-projection.local.json`으로 개발할 수 있습니다. 이 파일은 Knowledge `0.2.0 DRAFT`의 개발용 projection이며 원본 Catalog가 아닙니다. 운영 `prod` Profile은 `WORKFLOW_CATALOG_LOCATION`에 배포된 `RELEASED` projection을 반드시 지정해야 하고 DRAFT bundle이면 서버 시작을 거부합니다. + ### 승인·감사 흐름 승인 API는 `ADMIN` 또는 `HR` 역할만 변경할 수 있고, 조회용 업무 활동은 `VIEWER`도 볼 수 있습니다. 사업장 전체 감사 검색은 `ADMIN`만 가능합니다. @@ -122,6 +146,7 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 또는 배포 Secret의 12자 이상 값 | --- | --- | --- | | Profile | `local`은 H2, `dev`·`prod`는 PostgreSQL을 사용합니다. | `application.yaml` | | Flyway | 서버 시작 시 적용하지 않은 DB 변경 파일을 순서대로 실행합니다. | `db/migration` | +| Workflow Catalog | Knowledge release의 Server용 read-only projection을 시작 시 검증합니다. | `workflow/` | | Security | JWT에서 ActorContext와 역할을 만들고 VIEWER의 쓰기 요청을 기본 차단합니다. | `SecurityConfig` | | Swagger | Controller의 API 설명을 브라우저 문서로 보여줍니다. | `OpenApiConfig` | | 공통 오류 | 모든 실패를 같은 JSON 구조로 반환합니다. | `common/error` | @@ -240,6 +265,8 @@ server/ │ │ └── reliability/ # Outbox와 event 복구 │ └── resources/ │ ├── application.yaml + │ ├── workflow/ + │ │ └── catalog-projection.local.json # 개발용 Knowledge projection │ └── db/migration/ │ ├── V1__baseline.sql │ ├── V2__create_auth_company.sql # Auth·Company·Refresh Token @@ -270,6 +297,8 @@ server/ - 다른 기능의 `infrastructure`와 JPA Entity를 직접 import하지 않습니다. - `task` 이외의 기능은 Task 상태를 직접 변경하지 않습니다. - `aiintegration`은 AI Runtime 연결만 담당하며 Prompt와 Provider SDK는 `ai` 저장소에 둡니다. +- `workflow`은 Knowledge가 배포한 projection을 읽을 뿐 원본 Workflow 정의를 수정하지 않습니다. +- `worker`의 `WorkerTaskContextReader`는 #6이 Worker API·도메인을 대신 구현하지 않고 Task 판단에 필요한 최소 상태·날짜만 읽는 내부 경계입니다. - 최상위 `package-info.java`는 기능 경계와 책임을 Git에 남기기 위한 뼈대입니다. 빈 하위 패키지는 미리 만들지 않고 실제 코드가 추가될 때 생성합니다. - Flyway migration은 적용 후 수정할 수 없으므로 `V2`와 `V3` 빈 파일을 미리 만들지 않습니다. 각각 #4와 #5의 실제 스키마와 함께 추가합니다. - 테스트 패키지는 구현 패키지를 따라가고, `architecture`에는 향후 ArchUnit 또는 Spring Modulith 경계 검증을 둡니다. diff --git a/src/main/java/com/fowoco/server/approval/application/ApprovalControlPort.java b/src/main/java/com/fowoco/server/approval/application/ApprovalControlPort.java index e313035..8f248cf 100644 --- a/src/main/java/com/fowoco/server/approval/application/ApprovalControlPort.java +++ b/src/main/java/com/fowoco/server/approval/application/ApprovalControlPort.java @@ -2,6 +2,7 @@ import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.task.domain.Task; import java.time.Instant; import java.util.UUID; @@ -21,4 +22,12 @@ void invalidateForCriticalChange( Instant occurredAt, RequestMetadata metadata ); + + Task replaceReviewAfterCriticalChange( + UUID taskId, + ActorContext actorContext, + String reason, + Instant occurredAt, + RequestMetadata metadata + ); } diff --git a/src/main/java/com/fowoco/server/approval/application/ApprovalService.java b/src/main/java/com/fowoco/server/approval/application/ApprovalService.java index c2d22f5..02d73bf 100644 --- a/src/main/java/com/fowoco/server/approval/application/ApprovalService.java +++ b/src/main/java/com/fowoco/server/approval/application/ApprovalService.java @@ -27,7 +27,9 @@ import java.time.Clock; import java.time.Instant; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -355,14 +357,12 @@ public void invalidateForCriticalChange( ) { actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); - List active = approvalRepository.findActiveByTaskIdAndCompanyId( + List active = invalidateActiveApprovals( taskId, - actor.companyId() + actor.companyId(), + reason, + occurredAt ); - active.forEach(approval -> { - approval.invalidate(reason, occurredAt); - approvalRepository.save(approval); - }); if (!active.isEmpty()) { appendAudit( task, @@ -375,6 +375,108 @@ public void invalidateForCriticalChange( } } + @Override + @Transactional + public Task replaceReviewAfterCriticalChange( + UUID taskId, + ActorContext actor, + String reason, + Instant occurredAt, + RequestMetadata metadata + ) { + actorAuthorizer.requireHrWrite(actor); + Task task = requireTask(taskId, actor.companyId()); + List invalidated = invalidateActiveApprovals( + taskId, + actor.companyId(), + reason, + occurredAt + ); + if (!invalidated.isEmpty()) { + appendAudit( + task, + actor, + AuditAction.APPROVAL_INVALIDATED, + "중요 업무값 변경으로 기존 승인을 무효화함", + metadata, + occurredAt + ); + } + + TaskStatus previous = task.requestReview( + taskReadinessChecker.isReadyForReview(task), + task.version(), + actor.actorId(), + occurredAt + ); + Task savedTask = taskRepository.save(task); + recordTransition( + savedTask, + previous, + actor.actorId(), + "중요값 수정 후 재승인 요청", + metadata, + occurredAt + ); + + Map hrSnapshot = new LinkedHashMap<>(); + hrSnapshot.put("worker_id", savedTask.workerId().toString()); + hrSnapshot.put("task_type", savedTask.taskType().name()); + hrSnapshot.put("workflow_id", savedTask.workflowId()); + hrSnapshot.put("title", savedTask.title()); + hrSnapshot.put("description", savedTask.description()); + hrSnapshot.put( + "due_date", + savedTask.dueDate() == null ? null : savedTask.dueDate().toString() + ); + hrSnapshot.put("business_data_json", savedTask.businessDataJson()); + ApprovalRequest replacement = ApprovalRequest.create( + uuidGenerator.generate(), + taskId, + actor.companyId(), + savedTask.version(), + savedTask.contentRevision(), + savedTask.criticalFingerprint(), + null, + safeJsonService.write(hrSnapshot, true), + safeJsonService.write(List.of("task_content"), true), + safeJsonService.write( + Map.of( + "workflow_catalog_version", + savedTask.workflowCatalogVersion() + ), + true + ), + actor.actorId(), + occurredAt + ); + approvalRepository.save(replacement); + appendAudit( + savedTask, + actor, + AuditAction.APPROVAL_REQUESTED, + "수정된 현재 Task version의 재승인을 요청함", + metadata, + occurredAt + ); + return savedTask; + } + + private List invalidateActiveApprovals( + UUID taskId, + UUID companyId, + String reason, + Instant occurredAt + ) { + List active = + approvalRepository.findActiveByTaskIdAndCompanyId(taskId, companyId); + active.forEach(approval -> { + approval.invalidate(reason, occurredAt); + approvalRepository.save(approval); + }); + return active; + } + private Task requireTask(UUID taskId, UUID companyId) { return taskRepository.findByIdAndCompanyId(taskId, companyId) .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); diff --git a/src/main/java/com/fowoco/server/approval/application/SafeJsonService.java b/src/main/java/com/fowoco/server/approval/application/SafeJsonService.java index 9a2d9c5..04396ed 100644 --- a/src/main/java/com/fowoco/server/approval/application/SafeJsonService.java +++ b/src/main/java/com/fowoco/server/approval/application/SafeJsonService.java @@ -22,20 +22,23 @@ public class SafeJsonService { private static final Set FORBIDDEN_KEY_PARTS = Set.of( - "passport", - "registration", - "resident", + "passportnumber", + "passportno", + "alienregistrationnumber", + "registrationnumber", + "residentnumber", "rrn", "phone", - "account", + "accountnumber", + "bankaccount", "token", "password", "secret", "authorization", "prompt", - "여권", - "외국인등록", - "주민등록", + "여권번호", + "외국인등록번호", + "주민등록번호", "전화", "계좌", "비밀번호" diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java index 65cc950..7ccfcb1 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -3,6 +3,7 @@ public enum AuditAction { TASK_CREATED, TASK_UPDATED, + CHECKLIST_ITEM_UPDATED, TASK_CANCELLED, APPROVAL_REQUESTED, TASK_APPROVED, diff --git a/src/main/java/com/fowoco/server/task/api/CancelTaskRequest.java b/src/main/java/com/fowoco/server/task/api/CancelTaskRequest.java new file mode 100644 index 0000000..334d79e --- /dev/null +++ b/src/main/java/com/fowoco/server/task/api/CancelTaskRequest.java @@ -0,0 +1,19 @@ +package com.fowoco.server.task.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.task.application.CancelTaskCommand; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record CancelTaskRequest( + @NotNull @Min(0) Long expectedVersion, + @NotBlank @Size(max = 500) String reason +) { + CancelTaskCommand toCommand() { + return new CancelTaskCommand(expectedVersion, reason); + } +} diff --git a/src/main/java/com/fowoco/server/task/api/CreateTaskRequest.java b/src/main/java/com/fowoco/server/task/api/CreateTaskRequest.java new file mode 100644 index 0000000..beb00d5 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/api/CreateTaskRequest.java @@ -0,0 +1,37 @@ +package com.fowoco.server.task.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.task.application.CreateTaskCommand; +import com.fowoco.server.task.domain.TaskType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.util.Map; +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record CreateTaskRequest( + @NotNull UUID workerId, + UUID caseId, + @NotNull TaskType taskType, + @NotBlank @Size(max = 100) String workflowId, + @NotBlank @Size(max = 160) String title, + @Size(max = 2000) String description, + LocalDate dueDate, + Map businessData +) { + CreateTaskCommand toCommand() { + return new CreateTaskCommand( + workerId, + caseId, + taskType, + workflowId, + title, + description, + dueDate, + businessData + ); + } +} diff --git a/src/main/java/com/fowoco/server/task/api/TaskChecklistItemResponse.java b/src/main/java/com/fowoco/server/task/api/TaskChecklistItemResponse.java new file mode 100644 index 0000000..acd8224 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/api/TaskChecklistItemResponse.java @@ -0,0 +1,32 @@ +package com.fowoco.server.task.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.task.domain.TaskChecklistItem; +import java.time.Instant; +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record TaskChecklistItemResponse( + UUID checklistItemId, + String itemCode, + String label, + boolean required, + boolean completed, + UUID completedBy, + Instant completedAt, + long version +) { + static TaskChecklistItemResponse from(TaskChecklistItem item) { + return new TaskChecklistItemResponse( + item.checklistItemId(), + item.itemCode(), + item.label(), + item.required(), + item.completed(), + item.completedBy(), + item.completedAt(), + item.version() + ); + } +} diff --git a/src/main/java/com/fowoco/server/task/api/TaskController.java b/src/main/java/com/fowoco/server/task/api/TaskController.java new file mode 100644 index 0000000..fb32af6 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/api/TaskController.java @@ -0,0 +1,209 @@ +package com.fowoco.server.task.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.task.application.TaskResult; +import com.fowoco.server.task.application.TaskWorkflowService; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import io.swagger.v3.oas.annotations.Operation; +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.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.net.URI; +import java.time.LocalDate; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@Tag(name = "Task", description = "업무카드·체크리스트·상태 전이") +@SecurityRequirement(name = "bearerAuth") +@Validated +@RestController +@RequestMapping("/api/v1/tasks") +public class TaskController { + + private final TaskWorkflowService taskService; + private final ActorContextProvider actorContextProvider; + + public TaskController( + TaskWorkflowService taskService, + ActorContextProvider actorContextProvider + ) { + this.taskService = taskService; + this.actorContextProvider = actorContextProvider; + } + + @Operation(operationId = "listTasks", summary = "업무카드 목록 조회") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사업장 범위 업무카드 목록"), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public TaskPageResponse findAll( + @RequestParam(required = false) TaskStatus status, + @RequestParam(required = false) TaskType taskType, + @RequestParam(required = false) UUID workerId, + @RequestParam(required = false) LocalDate dueFrom, + @RequestParam(required = false) LocalDate dueTo, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return TaskPageResponse.from(taskService.findAll( + status, + taskType, + workerId, + dueFrom, + dueTo, + keyword, + page, + size, + actor() + )); + } + + @Operation(operationId = "createTask", summary = "수동 업무카드 생성") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "업무카드 생성"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"), + @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + @PostMapping( + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity create( + @Valid @RequestBody CreateTaskRequest request, + HttpServletRequest servletRequest + ) { + TaskResult result = taskService.create( + request.toCommand(), + actor(), + RequestMetadata.from(servletRequest) + ); + URI location = ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{taskId}") + .buildAndExpand(result.task().taskId()) + .toUri(); + return ResponseEntity.created(location).body(TaskDetailResponse.from(result)); + } + + @Operation(operationId = "getTask", summary = "업무카드 상세 조회") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "업무카드와 체크리스트"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + @GetMapping(path = "/{taskId}", produces = MediaType.APPLICATION_JSON_VALUE) + public TaskDetailResponse findById(@PathVariable UUID taskId) { + return TaskDetailResponse.from(taskService.findById(taskId, actor())); + } + + @Operation( + operationId = "updateTask", + summary = "업무카드 내용 수정", + description = "상태를 직접 받지 않으며 중요값 변경 시 기존 승인을 무효화합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정된 업무카드"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"), + @ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"), + @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + @PatchMapping( + path = "/{taskId}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public TaskDetailResponse update( + @PathVariable UUID taskId, + @Valid @RequestBody UpdateTaskRequest request, + HttpServletRequest servletRequest + ) { + return TaskDetailResponse.from(taskService.update( + taskId, + request.toCommand(), + actor(), + RequestMetadata.from(servletRequest) + )); + } + + @Operation(operationId = "updateTaskChecklistItem", summary = "체크리스트 항목 수정") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "체크리스트와 재평가된 업무 상태"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"), + @ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"), + @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + @PatchMapping( + path = "/{taskId}/checklist-items/{itemId}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public TaskDetailResponse updateChecklistItem( + @PathVariable UUID taskId, + @PathVariable UUID itemId, + @Valid @RequestBody UpdateChecklistItemRequest request, + HttpServletRequest servletRequest + ) { + return TaskDetailResponse.from(taskService.updateChecklistItem( + taskId, + itemId, + request.toCommand(), + actor(), + RequestMetadata.from(servletRequest) + )); + } + + @Operation(operationId = "cancelTask", summary = "업무카드 취소") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "취소된 업무카드"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"), + @ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"), + @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + @PostMapping( + path = "/{taskId}/cancel", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public TaskDetailResponse cancel( + @PathVariable UUID taskId, + @Valid @RequestBody CancelTaskRequest request, + HttpServletRequest servletRequest + ) { + return TaskDetailResponse.from(taskService.cancel( + taskId, + request.toCommand(), + actor(), + RequestMetadata.from(servletRequest) + )); + } + + private ActorContext actor() { + return actorContextProvider.requireCurrentActor(); + } +} diff --git a/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java b/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java new file mode 100644 index 0000000..02dc798 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java @@ -0,0 +1,64 @@ +package com.fowoco.server.task.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.task.application.TaskResult; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record TaskDetailResponse( + UUID taskId, + UUID workerId, + UUID caseId, + TaskType taskType, + String workflowId, + String workflowCatalogVersion, + String title, + String description, + Map businessData, + TaskSource source, + TaskStatus status, + LocalDate dueDate, + long contentRevision, + long version, + List missingRequiredSlots, + List checklistItems, + UUID createdBy, + UUID updatedBy, + Instant createdAt, + Instant updatedAt +) { + static TaskDetailResponse from(TaskResult result) { + Task task = result.task(); + return new TaskDetailResponse( + task.taskId(), + task.workerId(), + task.caseId(), + task.taskType(), + task.workflowId(), + task.workflowCatalogVersion(), + task.title(), + task.description(), + result.businessData(), + task.source(), + task.status(), + task.dueDate(), + task.contentRevision(), + task.version(), + result.missingRequiredSlots(), + result.checklistItems().stream().map(TaskChecklistItemResponse::from).toList(), + task.createdBy(), + task.updatedBy(), + task.createdAt(), + task.updatedAt() + ); + } +} diff --git a/src/main/java/com/fowoco/server/task/api/TaskPageResponse.java b/src/main/java/com/fowoco/server/task/api/TaskPageResponse.java new file mode 100644 index 0000000..55cf9ff --- /dev/null +++ b/src/main/java/com/fowoco/server/task/api/TaskPageResponse.java @@ -0,0 +1,25 @@ +package com.fowoco.server.task.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.task.application.TaskPageResult; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record TaskPageResponse( + List items, + int page, + int size, + long totalElements, + int totalPages +) { + static TaskPageResponse from(TaskPageResult result) { + return new TaskPageResponse( + result.items().stream().map(TaskSummaryResponse::from).toList(), + result.page(), + result.size(), + result.totalElements(), + result.totalPages() + ); + } +} diff --git a/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java b/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java new file mode 100644 index 0000000..86b88cf --- /dev/null +++ b/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java @@ -0,0 +1,48 @@ +package com.fowoco.server.task.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record TaskSummaryResponse( + UUID taskId, + UUID workerId, + UUID caseId, + TaskType taskType, + String workflowId, + String workflowCatalogVersion, + String title, + TaskSource source, + TaskStatus status, + LocalDate dueDate, + long contentRevision, + long version, + Instant createdAt, + Instant updatedAt +) { + static TaskSummaryResponse from(Task task) { + return new TaskSummaryResponse( + task.taskId(), + task.workerId(), + task.caseId(), + task.taskType(), + task.workflowId(), + task.workflowCatalogVersion(), + task.title(), + task.source(), + task.status(), + task.dueDate(), + task.contentRevision(), + task.version(), + task.createdAt(), + task.updatedAt() + ); + } +} diff --git a/src/main/java/com/fowoco/server/task/api/UpdateChecklistItemRequest.java b/src/main/java/com/fowoco/server/task/api/UpdateChecklistItemRequest.java new file mode 100644 index 0000000..42d7944 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/api/UpdateChecklistItemRequest.java @@ -0,0 +1,22 @@ +package com.fowoco.server.task.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.task.application.UpdateChecklistItemCommand; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record UpdateChecklistItemRequest( + @NotNull Boolean completed, + @NotNull @Min(0) Long expectedVersion, + @NotNull @Min(0) Long expectedTaskVersion +) { + UpdateChecklistItemCommand toCommand() { + return new UpdateChecklistItemCommand( + completed, + expectedVersion, + expectedTaskVersion + ); + } +} diff --git a/src/main/java/com/fowoco/server/task/api/UpdateTaskRequest.java b/src/main/java/com/fowoco/server/task/api/UpdateTaskRequest.java new file mode 100644 index 0000000..d86de9a --- /dev/null +++ b/src/main/java/com/fowoco/server/task/api/UpdateTaskRequest.java @@ -0,0 +1,24 @@ +package com.fowoco.server.task.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.task.application.UpdateTaskCommand; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.util.Map; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record UpdateTaskRequest( + @NotBlank @Size(max = 160) String title, + @Size(max = 2000) String description, + LocalDate dueDate, + @NotNull Map businessData, + @NotNull @Min(0) Long expectedVersion +) { + UpdateTaskCommand toCommand() { + return new UpdateTaskCommand(title, description, dueDate, businessData, expectedVersion); + } +} diff --git a/src/main/java/com/fowoco/server/task/application/CancelTaskCommand.java b/src/main/java/com/fowoco/server/task/application/CancelTaskCommand.java new file mode 100644 index 0000000..8430e79 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/CancelTaskCommand.java @@ -0,0 +1,4 @@ +package com.fowoco.server.task.application; + +public record CancelTaskCommand(long expectedVersion, String reason) { +} diff --git a/src/main/java/com/fowoco/server/task/application/CreateTaskCommand.java b/src/main/java/com/fowoco/server/task/application/CreateTaskCommand.java new file mode 100644 index 0000000..921a529 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/CreateTaskCommand.java @@ -0,0 +1,18 @@ +package com.fowoco.server.task.application; + +import com.fowoco.server.task.domain.TaskType; +import java.time.LocalDate; +import java.util.Map; +import java.util.UUID; + +public record CreateTaskCommand( + UUID workerId, + UUID caseId, + TaskType taskType, + String workflowId, + String title, + String description, + LocalDate dueDate, + Map businessData +) { +} diff --git a/src/main/java/com/fowoco/server/task/application/TaskContentCodec.java b/src/main/java/com/fowoco/server/task/application/TaskContentCodec.java new file mode 100644 index 0000000..898517b --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/TaskContentCodec.java @@ -0,0 +1,181 @@ +package com.fowoco.server.task.application; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.task.application.error.TaskErrorCode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.Normalizer; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.UUID; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Component +public class TaskContentCodec { + + private static final Set FORBIDDEN_KEY_PARTS = Set.of( + "passportnumber", + "passportno", + "alienregistrationnumber", + "registrationnumber", + "residentnumber", + "rrn", + "phone", + "accountnumber", + "bankaccount", + "token", + "password", + "secret", + "authorization", + "prompt", + "여권번호", + "외국인등록번호", + "주민등록번호", + "전화", + "계좌", + "비밀번호" + ); + private static final Pattern REGISTRATION_NUMBER = + Pattern.compile("(? businessData + ) { + Object normalizedBusinessData = normalize(businessData == null ? Map.of() : businessData, null); + TreeMap approvalTarget = new TreeMap<>(); + approvalTarget.put("business_data", normalizedBusinessData); + approvalTarget.put("description", normalizeNullableText(description, 2000)); + approvalTarget.put("due_date", dueDate == null ? null : dueDate.toString()); + approvalTarget.put("task_type", normalizeText(taskType, 40)); + approvalTarget.put("title", normalizeText(title, 160)); + approvalTarget.put("worker_id", workerId.toString()); + approvalTarget.put("workflow_id", normalizeText(workflowId, 100)); + try { + String businessDataJson = objectMapper.writeValueAsString(normalizedBusinessData); + String canonicalTarget = objectMapper.writeValueAsString(approvalTarget); + return new EncodedTaskContent(businessDataJson, sha256(canonicalTarget)); + } catch (JacksonException exception) { + throw rejected(); + } + } + + @SuppressWarnings("unchecked") + public Map decodeBusinessData(String businessDataJson) { + try { + return objectMapper.readValue(businessDataJson, Map.class); + } catch (JacksonException exception) { + throw new IllegalStateException("저장된 Task business_data_json을 읽을 수 없습니다.", exception); + } + } + + public String safeText(String value, int maxLength) { + return normalizeText(value, maxLength); + } + + private Object normalize(Object value, String key) { + if (key != null && containsForbiddenKey(key)) { + throw rejected(); + } + if (value instanceof Map map) { + TreeMap normalized = new TreeMap<>(); + map.forEach((mapKey, mapValue) -> { + if (!(mapKey instanceof String stringKey) || stringKey.isBlank()) { + throw rejected(); + } + normalized.put(stringKey, normalize(mapValue, stringKey)); + }); + return normalized; + } + if (value instanceof Collection collection) { + List normalized = new ArrayList<>(collection.size()); + collection.forEach(item -> normalized.add(normalize(item, key))); + return normalized; + } + if (value instanceof String string) { + return normalizeText(string, 4000); + } + if (value == null || value instanceof Number || value instanceof Boolean) { + return value; + } + return normalize(objectMapper.convertValue(value, Object.class), key); + } + + private String normalizeText(String value, int maxLength) { + if (value == null || value.isBlank()) { + throw rejected(); + } + String normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFKC); + if (normalized.length() > maxLength || containsSensitiveValue(normalized)) { + throw rejected(); + } + return normalized; + } + + private String normalizeNullableText(String value, int maxLength) { + return value == null || value.isBlank() ? null : normalizeText(value, maxLength); + } + + private boolean containsForbiddenKey(String key) { + String normalizedKey = Normalizer.normalize(key, Normalizer.Form.NFKC) + .toLowerCase(Locale.ROOT) + .replace("_", "") + .replace("-", ""); + return FORBIDDEN_KEY_PARTS.stream() + .map(part -> part.replace("_", "").replace("-", "")) + .anyMatch(normalizedKey::contains); + } + + private boolean containsSensitiveValue(String value) { + return REGISTRATION_NUMBER.matcher(value).find() + || PHONE_NUMBER.matcher(value).find() + || BEARER_TOKEN.matcher(value).find() + || SECRET_ASSIGNMENT.matcher(value).find(); + } + + private String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 must be available", exception); + } + } + + private ApiException rejected() { + return new ApiException(TaskErrorCode.SENSITIVE_TASK_DATA_REJECTED); + } + + public record EncodedTaskContent(String businessDataJson, String criticalFingerprint) { + } +} diff --git a/src/main/java/com/fowoco/server/task/application/TaskPageResult.java b/src/main/java/com/fowoco/server/task/application/TaskPageResult.java new file mode 100644 index 0000000..e853819 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/TaskPageResult.java @@ -0,0 +1,16 @@ +package com.fowoco.server.task.application; + +import com.fowoco.server.task.domain.Task; +import java.util.List; + +public record TaskPageResult( + List items, + int page, + int size, + long totalElements, + int totalPages +) { + public TaskPageResult { + items = List.copyOf(items); + } +} diff --git a/src/main/java/com/fowoco/server/task/application/TaskResult.java b/src/main/java/com/fowoco/server/task/application/TaskResult.java new file mode 100644 index 0000000..a21be9e --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/TaskResult.java @@ -0,0 +1,21 @@ +package com.fowoco.server.task.application; + +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskChecklistItem; +import java.util.List; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public record TaskResult( + Task task, + Map businessData, + List checklistItems, + List missingRequiredSlots +) { + public TaskResult { + businessData = Collections.unmodifiableMap(new LinkedHashMap<>(businessData)); + checklistItems = List.copyOf(checklistItems); + missingRequiredSlots = List.copyOf(missingRequiredSlots); + } +} diff --git a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java new file mode 100644 index 0000000..ac6a993 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java @@ -0,0 +1,543 @@ +package com.fowoco.server.task.application; + +import com.fowoco.server.approval.application.ApprovalControlPort; +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.audit.domain.AuditTargetType; +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.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.web.RequestMetadata; +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.TaskRepository; +import com.fowoco.server.task.application.port.TaskRepository.TaskPage; +import com.fowoco.server.task.application.port.TaskRepository.TaskSearchCriteria; +import com.fowoco.server.task.application.port.TaskTransitionRecorder; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskChecklistItem; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import com.fowoco.server.worker.application.WorkerTaskContext; +import com.fowoco.server.worker.application.port.WorkerTaskContextReader; +import com.fowoco.server.workflow.application.WorkflowCatalogService; +import com.fowoco.server.workflow.domain.WorkflowDefinition; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class TaskWorkflowService { + + private static final String AUDIT_EVENT_VERSION = "1"; + private final ActorAuthorizer actorAuthorizer; + private final TaskRepository taskRepository; + private final TaskChecklistRepository checklistRepository; + private final TaskTransitionRecorder transitionRecorder; + private final WorkerTaskContextReader workerReader; + private final WorkflowCatalogService catalogService; + private final ApprovalControlPort approvalControl; + private final AuditEventRepository auditRepository; + private final TaskContentCodec contentCodec; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public TaskWorkflowService( + ActorAuthorizer actorAuthorizer, + TaskRepository taskRepository, + TaskChecklistRepository checklistRepository, + TaskTransitionRecorder transitionRecorder, + WorkerTaskContextReader workerReader, + WorkflowCatalogService catalogService, + ApprovalControlPort approvalControl, + AuditEventRepository auditRepository, + TaskContentCodec contentCodec, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.actorAuthorizer = actorAuthorizer; + this.taskRepository = taskRepository; + this.checklistRepository = checklistRepository; + this.transitionRecorder = transitionRecorder; + this.workerReader = workerReader; + this.catalogService = catalogService; + this.approvalControl = approvalControl; + this.auditRepository = auditRepository; + this.contentCodec = contentCodec; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public TaskResult create( + CreateTaskCommand command, + ActorContext actor, + RequestMetadata metadata + ) { + actorAuthorizer.requireHrWrite(actor); + WorkflowDefinition workflow = catalogService.requireWorkflow(command.workflowId()); + if (!workflow.supportedTaskTypes().contains(command.taskType())) { + throw new ApiException(TaskErrorCode.WORKFLOW_TASK_TYPE_MISMATCH); + } + WorkerTaskContext worker = requireWorker(command.workerId(), actor.companyId()); + if (!worker.canReceiveNewTask()) { + throw new ApiException(TaskErrorCode.WORKER_NOT_ELIGIBLE); + } + Map businessData = + command.businessData() == null ? Map.of() : command.businessData(); + List missingSlots = missingRequiredSlots( + workflow, + worker, + command.dueDate(), + businessData + ); + EncodedTaskContent content = contentCodec.encode( + command.workerId(), + workflow.workflowId(), + command.taskType().name(), + command.title(), + command.description(), + command.dueDate(), + businessData + ); + Instant now = Instant.now(clock); + Task task = Task.create( + uuidGenerator.generate(), + actor.companyId(), + command.workerId(), + command.caseId() == null ? uuidGenerator.generate() : command.caseId(), + command.taskType(), + workflow.workflowId(), + catalogService.getActiveCatalog().bundleVersion(), + command.title(), + command.description(), + content.businessDataJson(), + content.criticalFingerprint(), + TaskSource.MANUAL, + missingSlots.isEmpty() ? TaskStatus.DRAFT : TaskStatus.NEEDS_INFO, + command.dueDate(), + actor.actorId(), + now + ); + Task savedTask = taskRepository.save(task); + List checklistItems = checklistRepository.saveAll( + workflow.checklistItems().stream() + .map(template -> TaskChecklistItem.create( + uuidGenerator.generate(), + savedTask.taskId(), + actor.companyId(), + template.itemCode(), + template.label(), + template.required(), + now + )) + .toList() + ); + appendAudit( + savedTask, + actor, + AuditAction.TASK_CREATED, + "수동 업무카드를 생성함", + metadata, + now + ); + return toResult(savedTask, checklistItems, worker, workflow); + } + + @Transactional(readOnly = true) + public TaskPageResult findAll( + TaskStatus status, + TaskType taskType, + UUID workerId, + LocalDate dueFrom, + LocalDate dueTo, + String keyword, + int page, + int size, + ActorContext actor + ) { + requireRead(actor); + if (dueFrom != null && dueTo != null && dueFrom.isAfter(dueTo)) { + throw new ApiException(TaskErrorCode.INVALID_TASK_FILTER); + } + TaskPage result = taskRepository.findAll(new TaskSearchCriteria( + actor.companyId(), + status, + taskType, + workerId, + dueFrom, + dueTo, + keyword, + page, + size + )); + return new TaskPageResult( + result.items(), + result.page(), + result.size(), + result.totalElements(), + result.totalPages() + ); + } + + @Transactional(readOnly = true) + public TaskResult findById(UUID taskId, ActorContext actor) { + requireRead(actor); + Task task = requireTask(taskId, actor.companyId()); + return toResult( + task, + checklistRepository.findAllByTaskIdAndCompanyId(taskId, actor.companyId()), + requireWorker(task.workerId(), actor.companyId()), + catalogService.requireWorkflow(task.workflowId()) + ); + } + + @Transactional + public TaskResult update( + UUID taskId, + UpdateTaskCommand command, + ActorContext actor, + RequestMetadata metadata + ) { + actorAuthorizer.requireHrWrite(actor); + Task task = requireTask(taskId, actor.companyId()); + WorkflowDefinition workflow = catalogService.requireWorkflow(task.workflowId()); + WorkerTaskContext worker = requireWorker(task.workerId(), actor.companyId()); + Map businessData = + command.businessData() == null ? Map.of() : command.businessData(); + List missingSlots = missingRequiredSlots( + workflow, + worker, + command.dueDate(), + businessData + ); + List checklistItems = + checklistRepository.findAllByTaskIdAndCompanyId(taskId, actor.companyId()); + boolean checklistSatisfied = checklistItems.stream() + .noneMatch(item -> item.required() && !item.completed()); + EncodedTaskContent content = contentCodec.encode( + task.workerId(), + task.workflowId(), + task.taskType().name(), + command.title(), + command.description(), + command.dueDate(), + businessData + ); + Instant now = Instant.now(clock); + TaskStatus previous = task.status(); + Task.UpdateOutcome outcome = task.updateContent( + command.title(), + command.description(), + content.businessDataJson(), + content.criticalFingerprint(), + command.dueDate(), + missingSlots.isEmpty() && checklistSatisfied, + command.expectedVersion(), + actor.actorId(), + now + ); + Task savedTask = taskRepository.save(task); + recordTransitionIfChanged( + savedTask, + previous, + actor.actorId(), + "업무 내용 수정", + metadata, + now + ); + if (outcome.approvalInvalidated()) { + if (savedTask.status() == TaskStatus.DRAFT) { + savedTask = approvalControl.replaceReviewAfterCriticalChange( + taskId, + actor, + "업무 핵심값이 변경됨", + now, + metadata + ); + } else { + approvalControl.invalidateForCriticalChange( + taskId, + actor, + "업무 핵심값이 변경됨", + now, + metadata + ); + } + } + appendAudit( + savedTask, + actor, + AuditAction.TASK_UPDATED, + outcome.criticalChanged() + ? "승인 대상 업무 내용을 수정함" + : "업무 내용을 다시 저장함", + metadata, + now + ); + return toResult( + savedTask, + checklistItems, + worker, + workflow + ); + } + + @Transactional + public TaskResult updateChecklistItem( + UUID taskId, + UUID checklistItemId, + UpdateChecklistItemCommand command, + ActorContext actor, + RequestMetadata metadata + ) { + actorAuthorizer.requireHrWrite(actor); + Task task = requireTask(taskId, actor.companyId()); + TaskChecklistItem item = checklistRepository + .findByIdAndTaskIdAndCompanyId(checklistItemId, taskId, actor.companyId()) + .orElseThrow(() -> new ApiException(TaskErrorCode.CHECKLIST_ITEM_NOT_FOUND)); + Instant now = Instant.now(clock); + boolean changed = item.updateCompletion( + command.completed(), + command.expectedVersion(), + actor.actorId(), + now + ); + if (changed) { + checklistRepository.save(item); + } + List items = + checklistRepository.findAllByTaskIdAndCompanyId(taskId, actor.companyId()); + WorkerTaskContext worker = requireWorker(task.workerId(), actor.companyId()); + WorkflowDefinition workflow = catalogService.requireWorkflow(task.workflowId()); + Map businessData = contentCodec.decodeBusinessData(task.businessDataJson()); + boolean slotsSatisfied = missingRequiredSlots( + workflow, + worker, + task.dueDate(), + businessData + ).isEmpty(); + boolean checklistSatisfied = items.stream() + .noneMatch(candidate -> candidate.required() && !candidate.completed()); + TaskStatus previous = task.status(); + Task.RequirementsOutcome requirements = task.reassessRequirements( + slotsSatisfied && checklistSatisfied, + command.expectedTaskVersion(), + actor.actorId(), + now + ); + Task savedTask = previous == task.status() ? task : taskRepository.save(task); + if (requirements.approvalInvalidated()) { + approvalControl.invalidateForCriticalChange( + taskId, + actor, + "필수 체크리스트 항목이 미완료로 변경됨", + now, + metadata + ); + } + recordTransitionIfChanged( + savedTask, + previous, + actor.actorId(), + "필수 체크리스트 재평가", + metadata, + now + ); + if (changed) { + appendAudit( + savedTask, + actor, + AuditAction.CHECKLIST_ITEM_UPDATED, + command.completed() + ? "체크리스트 항목을 완료 처리함" + : "체크리스트 항목을 미완료 처리함", + metadata, + now + ); + } + return toResult(savedTask, items, worker, workflow); + } + + @Transactional + public TaskResult cancel( + UUID taskId, + CancelTaskCommand command, + ActorContext actor, + RequestMetadata metadata + ) { + actorAuthorizer.requireHrWrite(actor); + Task task = requireTask(taskId, actor.companyId()); + String reason = contentCodec.safeText(command.reason(), 500); + Instant now = Instant.now(clock); + TaskStatus previous = task.cancel( + command.expectedVersion(), + actor.actorId(), + now + ); + Task savedTask = taskRepository.save(task); + approvalControl.invalidateForCriticalChange(taskId, actor, reason, now, metadata); + recordTransitionIfChanged( + savedTask, + previous, + actor.actorId(), + reason, + metadata, + now + ); + appendAudit( + savedTask, + actor, + AuditAction.TASK_CANCELLED, + "사유를 기록하고 업무카드를 취소함", + metadata, + now + ); + return toResult( + savedTask, + checklistRepository.findAllByTaskIdAndCompanyId(taskId, actor.companyId()), + requireWorker(task.workerId(), actor.companyId()), + catalogService.requireWorkflow(task.workflowId()) + ); + } + + private TaskResult toResult( + Task task, + List checklistItems, + WorkerTaskContext worker, + WorkflowDefinition workflow + ) { + Map businessData = contentCodec.decodeBusinessData(task.businessDataJson()); + return new TaskResult( + task, + businessData, + checklistItems, + missingRequiredSlots(workflow, worker, task.dueDate(), businessData) + ); + } + + private List missingRequiredSlots( + WorkflowDefinition workflow, + WorkerTaskContext worker, + LocalDate dueDate, + Map businessData + ) { + List missing = new ArrayList<>(); + workflow.requiredSlots().stream().sorted().forEach(slot -> { + boolean present = switch (slot) { + case "worker_id" -> worker.workerId() != null; + case "due_at", "due_date" -> dueDate != null; + case "contract_start_date" -> worker.contractStartDate() != null; + case "contract_end_date" -> worker.contractEndDate() != null; + case "stay_expiry_date" -> worker.stayExpiryDate() != null; + default -> hasBusinessValue(businessData.get(slot)); + }; + if (!present) { + missing.add(slot); + } + }); + return List.copyOf(missing); + } + + private boolean hasBusinessValue(Object value) { + if (value == null) { + return false; + } + return !(value instanceof String text) || !text.isBlank(); + } + + private WorkerTaskContext requireWorker(UUID workerId, UUID companyId) { + return workerReader.findByIdAndCompanyId(workerId, companyId) + .orElseThrow(() -> new ApiException(TaskErrorCode.WORKER_NOT_FOUND)); + } + + private Task requireTask(UUID taskId, UUID companyId) { + return taskRepository.findByIdAndCompanyId(taskId, companyId) + .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); + } + + private void requireRead(ActorContext actor) { + actorAuthorizer.requireAnyRole( + actor, + UserRole.ADMIN, + UserRole.HR, + UserRole.VIEWER + ); + } + + private void recordTransitionIfChanged( + Task task, + TaskStatus previous, + UUID actorId, + String reason, + RequestMetadata metadata, + Instant now + ) { + if (previous == task.status()) { + return; + } + transitionRecorder.record( + uuidGenerator.generate(), + task.taskId(), + task.companyId(), + previous, + task.status(), + actorId, + reason, + metadata.requestId(), + now + ); + } + + private void appendAudit( + Task task, + ActorContext actor, + AuditAction action, + String summary, + RequestMetadata metadata, + Instant now + ) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + task.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + action, + AuditTargetType.TASK, + task.taskId(), + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + summary, + now + )); + } + + private UserRole effectiveRole(ActorContext actor) { + return actor.roles().stream() + .min(Comparator.comparingInt(this::rolePriority)) + .orElseThrow(); + } + + private int rolePriority(UserRole role) { + return switch (role) { + case ADMIN -> 0; + case HR -> 1; + case VIEWER -> 2; + }; + } +} diff --git a/src/main/java/com/fowoco/server/task/application/UpdateChecklistItemCommand.java b/src/main/java/com/fowoco/server/task/application/UpdateChecklistItemCommand.java new file mode 100644 index 0000000..a312de2 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/UpdateChecklistItemCommand.java @@ -0,0 +1,8 @@ +package com.fowoco.server.task.application; + +public record UpdateChecklistItemCommand( + boolean completed, + long expectedVersion, + long expectedTaskVersion +) { +} diff --git a/src/main/java/com/fowoco/server/task/application/UpdateTaskCommand.java b/src/main/java/com/fowoco/server/task/application/UpdateTaskCommand.java new file mode 100644 index 0000000..435b4e6 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/UpdateTaskCommand.java @@ -0,0 +1,13 @@ +package com.fowoco.server.task.application; + +import java.time.LocalDate; +import java.util.Map; + +public record UpdateTaskCommand( + String title, + String description, + LocalDate dueDate, + Map businessData, + long expectedVersion +) { +} 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 fdf1cef..6149a89 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 @@ -7,6 +7,20 @@ public enum TaskErrorCode implements ApiErrorCode { TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "업무카드를 찾을 수 없습니다."), WORKER_NOT_FOUND(HttpStatus.NOT_FOUND, "근로자를 찾을 수 없습니다."), WORKFLOW_NOT_FOUND(HttpStatus.NOT_FOUND, "Workflow를 찾을 수 없습니다."), + WORKFLOW_TASK_TYPE_MISMATCH( + HttpStatus.UNPROCESSABLE_CONTENT, + "선택한 Workflow에서 지원하지 않는 업무 유형입니다." + ), + INVALID_TASK_FILTER(HttpStatus.BAD_REQUEST, "업무카드 조회 조건을 확인해 주세요."), + WORKER_NOT_ELIGIBLE( + HttpStatus.UNPROCESSABLE_CONTENT, + "현재 근무 상태의 근로자에게는 새 업무를 만들 수 없습니다." + ), + SENSITIVE_TASK_DATA_REJECTED( + HttpStatus.UNPROCESSABLE_CONTENT, + "업무카드에 저장할 수 없는 개인정보 또는 Secret이 포함되어 있습니다." + ), + CHECKLIST_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "체크리스트 항목을 찾을 수 없습니다."), CONCURRENT_MODIFICATION(HttpStatus.CONFLICT, "업무카드가 다른 요청에서 변경되었습니다."), TASK_TRANSITION_NOT_ALLOWED( HttpStatus.UNPROCESSABLE_CONTENT, diff --git a/src/main/java/com/fowoco/server/task/application/port/TaskChecklistRepository.java b/src/main/java/com/fowoco/server/task/application/port/TaskChecklistRepository.java new file mode 100644 index 0000000..ef4c199 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/port/TaskChecklistRepository.java @@ -0,0 +1,21 @@ +package com.fowoco.server.task.application.port; + +import com.fowoco.server.task.domain.TaskChecklistItem; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface TaskChecklistRepository { + + List findAllByTaskIdAndCompanyId(UUID taskId, UUID companyId); + + Optional findByIdAndTaskIdAndCompanyId( + UUID checklistItemId, + UUID taskId, + UUID companyId + ); + + List saveAll(List items); + + TaskChecklistItem save(TaskChecklistItem item); +} diff --git a/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java b/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java index 24b0599..6dec314 100644 --- a/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java +++ b/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java @@ -1,6 +1,10 @@ package com.fowoco.server.task.application.port; import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.time.LocalDate; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -8,5 +12,32 @@ public interface TaskRepository { Optional findByIdAndCompanyId(UUID taskId, UUID companyId); + TaskPage findAll(TaskSearchCriteria criteria); + Task save(Task task); + + record TaskSearchCriteria( + UUID companyId, + TaskStatus status, + TaskType taskType, + UUID workerId, + LocalDate dueFrom, + LocalDate dueTo, + String keyword, + int page, + int size + ) { + } + + record TaskPage( + List items, + int page, + int size, + long totalElements, + int totalPages + ) { + public TaskPage { + items = List.copyOf(items); + } + } } diff --git a/src/main/java/com/fowoco/server/task/domain/Task.java b/src/main/java/com/fowoco/server/task/domain/Task.java index 8a13a94..b180a94 100644 --- a/src/main/java/com/fowoco/server/task/domain/Task.java +++ b/src/main/java/com/fowoco/server/task/domain/Task.java @@ -189,6 +189,7 @@ public UpdateOutcome updateContent( String businessDataJson, String criticalFingerprint, LocalDate dueDate, + boolean requirementsSatisfied, long expectedVersion, UUID actorId, Instant now @@ -200,7 +201,8 @@ public UpdateOutcome updateContent( String nextFingerprint = requireFingerprint(criticalFingerprint); boolean criticalChanged = !this.criticalFingerprint.equals(nextFingerprint); boolean approvalInvalidated = criticalChanged - && (status == TaskStatus.APPROVED + && (status == TaskStatus.READY_FOR_REVIEW + || status == TaskStatus.APPROVED || status == TaskStatus.WAITING_WORKER || status == TaskStatus.WAITING_EXTERNAL); @@ -214,12 +216,50 @@ public UpdateOutcome updateContent( this.dueDate = dueDate; this.updatedBy = Objects.requireNonNull(actorId); this.updatedAt = Objects.requireNonNull(now); - if (approvalInvalidated) { - this.status = TaskStatus.READY_FOR_REVIEW; + if (criticalChanged && (approvalInvalidated + || status == TaskStatus.DRAFT + || status == TaskStatus.NEEDS_INFO)) { + this.status = requirementsSatisfied ? TaskStatus.DRAFT : TaskStatus.NEEDS_INFO; } return new UpdateOutcome(criticalChanged, approvalInvalidated); } + /** + * Re-evaluates required slots and checklist items after a checklist command. + * + *

A completed checklist item never approves a task automatically. If all requirements become + * complete the task only returns to {@link TaskStatus#DRAFT}. If a required item becomes + * incomplete after review or approval, the approved content revision is invalidated.

+ */ + public RequirementsOutcome reassessRequirements( + boolean requirementsSatisfied, + long expectedVersion, + UUID actorId, + Instant now + ) { + requireVersion(expectedVersion); + if (status.isTerminal()) { + throw new ApiException(TaskErrorCode.TASK_TRANSITION_NOT_ALLOWED); + } + TaskStatus previous = status; + boolean approvalInvalidated = !requirementsSatisfied + && (status == TaskStatus.READY_FOR_REVIEW + || status == TaskStatus.APPROVED + || status == TaskStatus.WAITING_WORKER + || status == TaskStatus.WAITING_EXTERNAL); + if (approvalInvalidated) { + contentRevision++; + status = TaskStatus.NEEDS_INFO; + } else if (status == TaskStatus.NEEDS_INFO && requirementsSatisfied) { + status = TaskStatus.DRAFT; + } + if (status != previous) { + updatedBy = Objects.requireNonNull(actorId); + updatedAt = Objects.requireNonNull(now); + } + return new RequirementsOutcome(previous, approvalInvalidated); + } + private TaskStatus transition(TaskStatus next, UUID actorId, Instant now) { TaskStatus previous = status; status = next; @@ -341,4 +381,7 @@ public long version() { public record UpdateOutcome(boolean criticalChanged, boolean approvalInvalidated) { } + + public record RequirementsOutcome(TaskStatus previousStatus, boolean approvalInvalidated) { + } } diff --git a/src/main/java/com/fowoco/server/task/domain/TaskChecklistItem.java b/src/main/java/com/fowoco/server/task/domain/TaskChecklistItem.java new file mode 100644 index 0000000..2efce18 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/domain/TaskChecklistItem.java @@ -0,0 +1,160 @@ +package com.fowoco.server.task.domain; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.task.application.error.TaskErrorCode; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public final class TaskChecklistItem { + + private final UUID checklistItemId; + private final UUID taskId; + private final UUID companyId; + private final String itemCode; + private final String label; + private final boolean required; + private boolean completed; + private UUID completedBy; + private Instant completedAt; + private final Instant createdAt; + private Instant updatedAt; + private long version; + + public TaskChecklistItem( + UUID checklistItemId, + UUID taskId, + UUID companyId, + String itemCode, + String label, + boolean required, + boolean completed, + UUID completedBy, + Instant completedAt, + Instant createdAt, + Instant updatedAt, + long version + ) { + this.checklistItemId = Objects.requireNonNull(checklistItemId); + this.taskId = Objects.requireNonNull(taskId); + this.companyId = Objects.requireNonNull(companyId); + this.itemCode = requireText(itemCode); + this.label = requireText(label); + this.required = required; + this.completed = completed; + this.completedBy = completedBy; + this.completedAt = completedAt; + this.createdAt = Objects.requireNonNull(createdAt); + this.updatedAt = Objects.requireNonNull(updatedAt); + this.version = version; + validateCompletion(); + } + + public static TaskChecklistItem create( + UUID checklistItemId, + UUID taskId, + UUID companyId, + String itemCode, + String label, + boolean required, + Instant now + ) { + return new TaskChecklistItem( + checklistItemId, + taskId, + companyId, + itemCode, + label, + required, + false, + null, + null, + now, + now, + 0 + ); + } + + public boolean updateCompletion( + boolean nextCompleted, + long expectedVersion, + UUID actorId, + Instant now + ) { + if (version != expectedVersion) { + throw new ApiException(TaskErrorCode.CONCURRENT_MODIFICATION); + } + if (completed == nextCompleted) { + return false; + } + completed = nextCompleted; + completedBy = nextCompleted ? Objects.requireNonNull(actorId) : null; + completedAt = nextCompleted ? Objects.requireNonNull(now) : null; + updatedAt = Objects.requireNonNull(now); + return true; + } + + private void validateCompletion() { + if (completed && (completedBy == null || completedAt == null)) { + throw new IllegalArgumentException("completed checklist needs actor and time"); + } + if (!completed && (completedBy != null || completedAt != null)) { + throw new IllegalArgumentException("checklist completion actor and time must match status"); + } + } + + private static String requireText(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("value must not be blank"); + } + return value.trim(); + } + + public UUID checklistItemId() { + return checklistItemId; + } + + public UUID taskId() { + return taskId; + } + + public UUID companyId() { + return companyId; + } + + public String itemCode() { + return itemCode; + } + + public String label() { + return label; + } + + public boolean required() { + return required; + } + + public boolean completed() { + return completed; + } + + public UUID completedBy() { + return completedBy; + } + + public Instant completedAt() { + return completedAt; + } + + public Instant createdAt() { + return createdAt; + } + + public Instant updatedAt() { + return updatedAt; + } + + public long version() { + return version; + } +} diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskChecklistRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskChecklistRepository.java new file mode 100644 index 0000000..8ba4a1c --- /dev/null +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskChecklistRepository.java @@ -0,0 +1,66 @@ +package com.fowoco.server.task.infrastructure.persistence; + +import com.fowoco.server.task.application.port.TaskChecklistRepository; +import com.fowoco.server.task.domain.TaskChecklistItem; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaTaskChecklistRepository implements TaskChecklistRepository { + + private final SpringDataTaskChecklistJpaRepository repository; + + public JpaTaskChecklistRepository(SpringDataTaskChecklistJpaRepository repository) { + this.repository = repository; + } + + @Override + public List findAllByTaskIdAndCompanyId(UUID taskId, UUID companyId) { + return repository.findAllByTaskIdAndCompanyIdOrderByCreatedAtAsc(taskId, companyId) + .stream() + .map(TaskChecklistItemJpaEntity::toDomain) + .toList(); + } + + @Override + public Optional findByIdAndTaskIdAndCompanyId( + UUID checklistItemId, + UUID taskId, + UUID companyId + ) { + return repository.findByChecklistItemIdAndTaskIdAndCompanyId( + checklistItemId, + taskId, + companyId + ) + .map(TaskChecklistItemJpaEntity::toDomain); + } + + @Override + public List saveAll(List items) { + return repository.saveAllAndFlush( + items.stream().map(TaskChecklistItemJpaEntity::new).toList() + ) + .stream() + .map(TaskChecklistItemJpaEntity::toDomain) + .toList(); + } + + @Override + public TaskChecklistItem save(TaskChecklistItem item) { + TaskChecklistItemJpaEntity entity = repository + .findByChecklistItemIdAndTaskIdAndCompanyId( + item.checklistItemId(), + item.taskId(), + item.companyId() + ) + .map(existing -> { + existing.apply(item); + return existing; + }) + .orElseGet(() -> new TaskChecklistItemJpaEntity(item)); + return repository.saveAndFlush(entity).toDomain(); + } +} diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java index 9556ae9..4bbe400 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java @@ -4,6 +4,9 @@ import com.fowoco.server.task.domain.Task; import java.util.Optional; import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; @Repository @@ -20,6 +23,31 @@ public Optional findByIdAndCompanyId(UUID taskId, UUID companyId) { return repository.findByTaskIdAndCompanyId(taskId, companyId).map(TaskJpaEntity::toDomain); } + @Override + public TaskPage findAll(TaskSearchCriteria criteria) { + Page page = repository.search( + criteria.companyId(), + criteria.status(), + criteria.taskType(), + criteria.workerId(), + criteria.dueFrom(), + criteria.dueTo(), + normalizeKeyword(criteria.keyword()), + PageRequest.of( + criteria.page(), + criteria.size(), + Sort.by(Sort.Order.asc("dueDate"), Sort.Order.desc("createdAt")) + ) + ); + return new TaskPage( + page.getContent().stream().map(TaskJpaEntity::toDomain).toList(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } + @Override public Task save(Task task) { TaskJpaEntity entity = repository.findByTaskIdAndCompanyId(task.taskId(), task.companyId()) @@ -30,4 +58,8 @@ public Task save(Task task) { .orElseGet(() -> new TaskJpaEntity(task)); return repository.saveAndFlush(entity).toDomain(); } + + private String normalizeKeyword(String keyword) { + return keyword == null || keyword.isBlank() ? null : keyword.trim(); + } } diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskChecklistJpaRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskChecklistJpaRepository.java new file mode 100644 index 0000000..4ac5207 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskChecklistJpaRepository.java @@ -0,0 +1,21 @@ +package com.fowoco.server.task.infrastructure.persistence; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +interface SpringDataTaskChecklistJpaRepository + extends JpaRepository { + + List findAllByTaskIdAndCompanyIdOrderByCreatedAtAsc( + UUID taskId, + UUID companyId + ); + + Optional findByChecklistItemIdAndTaskIdAndCompanyId( + UUID checklistItemId, + UUID taskId, + UUID companyId + ); +} diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java index e2fa08d..029471b 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java @@ -2,9 +2,42 @@ import java.util.Optional; import java.util.UUID; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.time.LocalDate; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; interface SpringDataTaskJpaRepository extends JpaRepository { Optional findByTaskIdAndCompanyId(UUID taskId, UUID companyId); + + @Query(""" + SELECT task + FROM TaskJpaEntity task + WHERE task.companyId = :companyId + AND (:status IS NULL OR task.status = :status) + AND (:taskType IS NULL OR task.taskType = :taskType) + AND (:workerId IS NULL OR task.workerId = :workerId) + AND (:dueFrom IS NULL OR task.dueDate >= :dueFrom) + AND (:dueTo IS NULL OR task.dueDate <= :dueTo) + AND ( + :keyword IS NULL + OR LOWER(task.title) LIKE LOWER(CONCAT('%', :keyword, '%')) + OR LOWER(COALESCE(task.description, '')) LIKE LOWER(CONCAT('%', :keyword, '%')) + ) + """) + Page search( + @Param("companyId") UUID companyId, + @Param("status") TaskStatus status, + @Param("taskType") TaskType taskType, + @Param("workerId") UUID workerId, + @Param("dueFrom") LocalDate dueFrom, + @Param("dueTo") LocalDate dueTo, + @Param("keyword") String keyword, + Pageable pageable + ); } diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/TaskChecklistItemJpaEntity.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/TaskChecklistItemJpaEntity.java new file mode 100644 index 0000000..7e0ce16 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/TaskChecklistItemJpaEntity.java @@ -0,0 +1,88 @@ +package com.fowoco.server.task.infrastructure.persistence; + +import com.fowoco.server.task.domain.TaskChecklistItem; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table( + name = "task_checklist_item", + uniqueConstraints = @UniqueConstraint( + name = "uq_task_checklist_code", + columnNames = {"task_id", "item_code"} + ) +) +class TaskChecklistItemJpaEntity { + + @Id + @Column(name = "checklist_item_id", nullable = false, updatable = false) + private UUID checklistItemId; + @Column(name = "task_id", nullable = false, updatable = false) + private UUID taskId; + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + @Column(name = "item_code", nullable = false, length = 100, updatable = false) + private String itemCode; + @Column(name = "label", nullable = false, length = 300, updatable = false) + private String label; + @Column(name = "required", nullable = false, updatable = false) + private boolean required; + @Column(name = "completed", nullable = false) + private boolean completed; + @Column(name = "completed_by") + private UUID completedBy; + @Column(name = "completed_at") + private Instant completedAt; + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + @Version + @Column(name = "version", nullable = false) + private long version; + + protected TaskChecklistItemJpaEntity() { + } + + TaskChecklistItemJpaEntity(TaskChecklistItem item) { + this.checklistItemId = item.checklistItemId(); + this.taskId = item.taskId(); + this.companyId = item.companyId(); + this.itemCode = item.itemCode(); + this.label = item.label(); + this.required = item.required(); + this.createdAt = item.createdAt(); + apply(item); + this.version = item.version(); + } + + void apply(TaskChecklistItem item) { + this.completed = item.completed(); + this.completedBy = item.completedBy(); + this.completedAt = item.completedAt(); + this.updatedAt = item.updatedAt(); + } + + TaskChecklistItem toDomain() { + return new TaskChecklistItem( + checklistItemId, + taskId, + companyId, + itemCode, + label, + required, + completed, + completedBy, + completedAt, + createdAt, + updatedAt, + version + ); + } +} diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerTaskContext.java b/src/main/java/com/fowoco/server/worker/application/WorkerTaskContext.java new file mode 100644 index 0000000..171f38c --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/application/WorkerTaskContext.java @@ -0,0 +1,18 @@ +package com.fowoco.server.worker.application; + +import com.fowoco.server.worker.domain.WorkerStatus; +import java.time.LocalDate; +import java.util.UUID; + +public record WorkerTaskContext( + UUID workerId, + WorkerStatus workStatus, + LocalDate stayExpiryDate, + LocalDate contractStartDate, + LocalDate contractEndDate +) { + + public boolean canReceiveNewTask() { + return workStatus.isCurrentlyEmployed(); + } +} diff --git a/src/main/java/com/fowoco/server/worker/application/port/WorkerTaskContextReader.java b/src/main/java/com/fowoco/server/worker/application/port/WorkerTaskContextReader.java new file mode 100644 index 0000000..3506bd7 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/application/port/WorkerTaskContextReader.java @@ -0,0 +1,10 @@ +package com.fowoco.server.worker.application.port; + +import com.fowoco.server.worker.application.WorkerTaskContext; +import java.util.Optional; +import java.util.UUID; + +public interface WorkerTaskContextReader { + + Optional findByIdAndCompanyId(UUID workerId, UUID companyId); +} diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/JdbcWorkerTaskContextReader.java b/src/main/java/com/fowoco/server/worker/infrastructure/JdbcWorkerTaskContextReader.java new file mode 100644 index 0000000..4e5e2be --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/infrastructure/JdbcWorkerTaskContextReader.java @@ -0,0 +1,43 @@ +package com.fowoco.server.worker.infrastructure; + +import com.fowoco.server.worker.application.WorkerTaskContext; +import com.fowoco.server.worker.application.port.WorkerTaskContextReader; +import com.fowoco.server.worker.domain.WorkerStatus; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class JdbcWorkerTaskContextReader implements WorkerTaskContextReader { + + private final JdbcTemplate jdbcTemplate; + + public JdbcWorkerTaskContextReader(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public Optional findByIdAndCompanyId(UUID workerId, UUID companyId) { + List rows = jdbcTemplate.query( + """ + SELECT worker_id, work_status, stay_expiry_date, + contract_start_date, contract_end_date + FROM worker + WHERE worker_id = ? + AND company_id = ? + """, + (resultSet, rowNumber) -> new WorkerTaskContext( + resultSet.getObject("worker_id", UUID.class), + WorkerStatus.valueOf(resultSet.getString("work_status")), + resultSet.getObject("stay_expiry_date", java.time.LocalDate.class), + resultSet.getObject("contract_start_date", java.time.LocalDate.class), + resultSet.getObject("contract_end_date", java.time.LocalDate.class) + ), + workerId, + companyId + ); + return rows.stream().findFirst(); + } +} diff --git a/src/main/java/com/fowoco/server/workflow/api/WorkflowCatalogController.java b/src/main/java/com/fowoco/server/workflow/api/WorkflowCatalogController.java new file mode 100644 index 0000000..ee85c74 --- /dev/null +++ b/src/main/java/com/fowoco/server/workflow/api/WorkflowCatalogController.java @@ -0,0 +1,48 @@ +package com.fowoco.server.workflow.api; + +import com.fowoco.server.auth.application.ActorAuthorizer; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.workflow.application.WorkflowCatalogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Workflow Catalog", description = "Knowledge release에서 투영한 업무 정의 조회") +@SecurityRequirement(name = "bearerAuth") +@RestController +@RequestMapping("/api/v1/workflow-catalogs") +@PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") +public class WorkflowCatalogController { + + private final WorkflowCatalogService catalogService; + private final ActorContextProvider actorContextProvider; + private final ActorAuthorizer actorAuthorizer; + + public WorkflowCatalogController( + WorkflowCatalogService catalogService, + ActorContextProvider actorContextProvider, + ActorAuthorizer actorAuthorizer + ) { + this.catalogService = catalogService; + this.actorContextProvider = actorContextProvider; + this.actorAuthorizer = actorAuthorizer; + } + + @Operation( + operationId = "getActiveWorkflowCatalog", + summary = "활성 Workflow Catalog 조회", + description = "Server가 검증하고 고정한 Knowledge projection과 source version을 반환합니다." + ) + @GetMapping + public WorkflowCatalogResponse getActiveCatalog() { + ActorContext actor = actorContextProvider.requireCurrentActor(); + actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR, UserRole.VIEWER); + return WorkflowCatalogResponse.from(catalogService.getActiveCatalog()); + } +} diff --git a/src/main/java/com/fowoco/server/workflow/api/WorkflowCatalogResponse.java b/src/main/java/com/fowoco/server/workflow/api/WorkflowCatalogResponse.java new file mode 100644 index 0000000..774c292 --- /dev/null +++ b/src/main/java/com/fowoco/server/workflow/api/WorkflowCatalogResponse.java @@ -0,0 +1,28 @@ +package com.fowoco.server.workflow.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.workflow.domain.WorkflowCatalog; +import java.time.Instant; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record WorkflowCatalogResponse( + String bundleId, + String bundleVersion, + String bundleStatus, + String sourceRepository, + Instant generatedAt, + List workflows +) { + static WorkflowCatalogResponse from(WorkflowCatalog catalog) { + return new WorkflowCatalogResponse( + catalog.bundleId(), + catalog.bundleVersion(), + catalog.bundleStatus(), + catalog.sourceRepository(), + catalog.generatedAt(), + catalog.workflows().stream().map(WorkflowDefinitionResponse::from).toList() + ); + } +} diff --git a/src/main/java/com/fowoco/server/workflow/api/WorkflowChecklistResponse.java b/src/main/java/com/fowoco/server/workflow/api/WorkflowChecklistResponse.java new file mode 100644 index 0000000..2b14a39 --- /dev/null +++ b/src/main/java/com/fowoco/server/workflow/api/WorkflowChecklistResponse.java @@ -0,0 +1,16 @@ +package com.fowoco.server.workflow.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.workflow.domain.WorkflowChecklistTemplate; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record WorkflowChecklistResponse( + String itemCode, + String label, + boolean required +) { + static WorkflowChecklistResponse from(WorkflowChecklistTemplate item) { + return new WorkflowChecklistResponse(item.itemCode(), item.label(), item.required()); + } +} diff --git a/src/main/java/com/fowoco/server/workflow/api/WorkflowDefinitionResponse.java b/src/main/java/com/fowoco/server/workflow/api/WorkflowDefinitionResponse.java new file mode 100644 index 0000000..4de2722 --- /dev/null +++ b/src/main/java/com/fowoco/server/workflow/api/WorkflowDefinitionResponse.java @@ -0,0 +1,37 @@ +package com.fowoco.server.workflow.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.workflow.domain.WorkflowDefinition; +import com.fowoco.server.task.domain.TaskType; +import java.util.List; +import java.util.Set; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record WorkflowDefinitionResponse( + String workflowId, + String name, + String intent, + String sensitivity, + Set supportedTaskTypes, + Set requiredSlots, + List checklistItems, + List completionEvidence, + List sourceIds +) { + static WorkflowDefinitionResponse from(WorkflowDefinition workflow) { + return new WorkflowDefinitionResponse( + workflow.workflowId(), + workflow.name(), + workflow.intent(), + workflow.sensitivity(), + workflow.supportedTaskTypes(), + workflow.requiredSlots(), + workflow.checklistItems().stream() + .map(WorkflowChecklistResponse::from) + .toList(), + workflow.completionEvidence(), + workflow.sourceIds() + ); + } +} diff --git a/src/main/java/com/fowoco/server/workflow/application/WorkflowCatalogService.java b/src/main/java/com/fowoco/server/workflow/application/WorkflowCatalogService.java new file mode 100644 index 0000000..4565dcf --- /dev/null +++ b/src/main/java/com/fowoco/server/workflow/application/WorkflowCatalogService.java @@ -0,0 +1,27 @@ +package com.fowoco.server.workflow.application; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.workflow.application.port.WorkflowCatalogRepository; +import com.fowoco.server.workflow.domain.WorkflowCatalog; +import com.fowoco.server.workflow.domain.WorkflowDefinition; +import org.springframework.stereotype.Service; + +@Service +public class WorkflowCatalogService { + + private final WorkflowCatalogRepository catalogRepository; + + public WorkflowCatalogService(WorkflowCatalogRepository catalogRepository) { + this.catalogRepository = catalogRepository; + } + + public WorkflowCatalog getActiveCatalog() { + return catalogRepository.getActiveCatalog(); + } + + public WorkflowDefinition requireWorkflow(String workflowId) { + return getActiveCatalog().findWorkflow(workflowId) + .orElseThrow(() -> new ApiException(TaskErrorCode.WORKFLOW_NOT_FOUND)); + } +} diff --git a/src/main/java/com/fowoco/server/workflow/application/port/WorkflowCatalogRepository.java b/src/main/java/com/fowoco/server/workflow/application/port/WorkflowCatalogRepository.java new file mode 100644 index 0000000..74cb0a5 --- /dev/null +++ b/src/main/java/com/fowoco/server/workflow/application/port/WorkflowCatalogRepository.java @@ -0,0 +1,8 @@ +package com.fowoco.server.workflow.application.port; + +import com.fowoco.server.workflow.domain.WorkflowCatalog; + +public interface WorkflowCatalogRepository { + + WorkflowCatalog getActiveCatalog(); +} diff --git a/src/main/java/com/fowoco/server/workflow/domain/WorkflowCatalog.java b/src/main/java/com/fowoco/server/workflow/domain/WorkflowCatalog.java new file mode 100644 index 0000000..dca4782 --- /dev/null +++ b/src/main/java/com/fowoco/server/workflow/domain/WorkflowCatalog.java @@ -0,0 +1,25 @@ +package com.fowoco.server.workflow.domain; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +public record WorkflowCatalog( + String bundleId, + String bundleVersion, + String bundleStatus, + String sourceRepository, + Instant generatedAt, + List workflows +) { + + public WorkflowCatalog { + workflows = List.copyOf(workflows); + } + + public Optional findWorkflow(String workflowId) { + return workflows.stream() + .filter(workflow -> workflow.workflowId().equals(workflowId)) + .findFirst(); + } +} diff --git a/src/main/java/com/fowoco/server/workflow/domain/WorkflowChecklistTemplate.java b/src/main/java/com/fowoco/server/workflow/domain/WorkflowChecklistTemplate.java new file mode 100644 index 0000000..62252af --- /dev/null +++ b/src/main/java/com/fowoco/server/workflow/domain/WorkflowChecklistTemplate.java @@ -0,0 +1,8 @@ +package com.fowoco.server.workflow.domain; + +public record WorkflowChecklistTemplate( + String itemCode, + String label, + boolean required +) { +} diff --git a/src/main/java/com/fowoco/server/workflow/domain/WorkflowDefinition.java b/src/main/java/com/fowoco/server/workflow/domain/WorkflowDefinition.java new file mode 100644 index 0000000..44089c7 --- /dev/null +++ b/src/main/java/com/fowoco/server/workflow/domain/WorkflowDefinition.java @@ -0,0 +1,26 @@ +package com.fowoco.server.workflow.domain; + +import com.fowoco.server.task.domain.TaskType; +import java.util.List; +import java.util.Set; + +public record WorkflowDefinition( + String workflowId, + String name, + String intent, + String sensitivity, + Set supportedTaskTypes, + Set requiredSlots, + List checklistItems, + List completionEvidence, + List sourceIds +) { + + public WorkflowDefinition { + supportedTaskTypes = Set.copyOf(supportedTaskTypes); + requiredSlots = Set.copyOf(requiredSlots); + checklistItems = List.copyOf(checklistItems); + completionEvidence = List.copyOf(completionEvidence); + sourceIds = List.copyOf(sourceIds); + } +} diff --git a/src/main/java/com/fowoco/server/workflow/infrastructure/ResourceWorkflowCatalogRepository.java b/src/main/java/com/fowoco/server/workflow/infrastructure/ResourceWorkflowCatalogRepository.java new file mode 100644 index 0000000..a4a25e2 --- /dev/null +++ b/src/main/java/com/fowoco/server/workflow/infrastructure/ResourceWorkflowCatalogRepository.java @@ -0,0 +1,166 @@ +package com.fowoco.server.workflow.infrastructure; + +import com.fowoco.server.workflow.application.port.WorkflowCatalogRepository; +import com.fowoco.server.workflow.domain.WorkflowCatalog; +import com.fowoco.server.workflow.domain.WorkflowChecklistTemplate; +import com.fowoco.server.workflow.domain.WorkflowDefinition; +import com.fowoco.server.task.domain.TaskType; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Repository; +import tools.jackson.databind.ObjectMapper; + +@Repository +public class ResourceWorkflowCatalogRepository implements WorkflowCatalogRepository { + + private static final String KNOWLEDGE_REPOSITORY = "fowoco/knowledge"; + private static final String RELEASED = "RELEASED"; + + private final ObjectMapper objectMapper; + private final Resource catalogResource; + private final boolean allowUnreleased; + private WorkflowCatalog catalog; + + public ResourceWorkflowCatalogRepository( + ObjectMapper objectMapper, + @Value("${app.workflow.catalog.location}") Resource catalogResource, + @Value("${app.workflow.catalog.allow-unreleased:false}") boolean allowUnreleased + ) { + this.objectMapper = objectMapper; + this.catalogResource = catalogResource; + this.allowUnreleased = allowUnreleased; + } + + @PostConstruct + void loadAndValidate() { + try { + CatalogProjection projection = objectMapper.readValue( + catalogResource.getInputStream(), + CatalogProjection.class + ); + validate(projection); + this.catalog = projection.toDomain(); + } catch (IOException exception) { + throw new IllegalStateException( + "Workflow Catalog projection을 읽을 수 없습니다: " + catalogResource, + exception + ); + } + } + + @Override + public WorkflowCatalog getActiveCatalog() { + return catalog; + } + + private void validate(CatalogProjection projection) { + requireText(projection.bundleId(), "bundle_id"); + requireText(projection.bundleVersion(), "bundle_version"); + requireText(projection.bundleStatus(), "bundle_status"); + if (!KNOWLEDGE_REPOSITORY.equals(projection.sourceRepository())) { + throw new IllegalStateException("Workflow Catalog source_repository는 fowoco/knowledge여야 합니다."); + } + if (!allowUnreleased && !RELEASED.equals(projection.bundleStatus().toUpperCase(Locale.ROOT))) { + throw new IllegalStateException("운영 환경은 RELEASED Workflow Catalog만 사용할 수 있습니다."); + } + if (projection.generatedAt() == null || projection.workflows() == null + || projection.workflows().isEmpty()) { + throw new IllegalStateException("Workflow Catalog 생성시각과 workflow가 필요합니다."); + } + Set workflowIds = new HashSet<>(); + projection.workflows().forEach(workflow -> { + requireText(workflow.workflowId(), "workflow_id"); + requireText(workflow.name(), "workflow name"); + requireText(workflow.intent(), "workflow intent"); + if (!workflowIds.add(workflow.workflowId())) { + throw new IllegalStateException("중복 workflow_id: " + workflow.workflowId()); + } + if (workflow.requiredSlots() == null + || workflow.supportedTaskTypes() == null + || workflow.supportedTaskTypes().isEmpty() + || workflow.checklistItems() == null + || workflow.completionEvidence() == null + || workflow.sourceIds() == null) { + throw new IllegalStateException("Workflow projection collection은 null일 수 없습니다."); + } + Set itemCodes = new HashSet<>(); + workflow.checklistItems().forEach(item -> { + requireText(item.itemCode(), "checklist item_code"); + requireText(item.label(), "checklist label"); + if (!itemCodes.add(item.itemCode())) { + throw new IllegalStateException( + "중복 checklist item_code: " + item.itemCode() + ); + } + }); + }); + } + + private void requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalStateException(field + " 값이 필요합니다."); + } + } + + private record CatalogProjection( + String bundleId, + String bundleVersion, + String bundleStatus, + String sourceRepository, + Instant generatedAt, + List workflows + ) { + + WorkflowCatalog toDomain() { + return new WorkflowCatalog( + bundleId, + bundleVersion, + bundleStatus, + sourceRepository, + generatedAt, + workflows.stream().map(WorkflowProjection::toDomain).toList() + ); + } + } + + private record WorkflowProjection( + String workflowId, + String name, + String intent, + String sensitivity, + Set supportedTaskTypes, + Set requiredSlots, + List checklistItems, + List completionEvidence, + List sourceIds + ) { + + WorkflowDefinition toDomain() { + return new WorkflowDefinition( + workflowId, + name, + intent, + sensitivity, + supportedTaskTypes, + requiredSlots, + checklistItems.stream().map(ChecklistProjection::toDomain).toList(), + completionEvidence, + sourceIds + ); + } + } + + private record ChecklistProjection(String itemCode, String label, boolean required) { + + WorkflowChecklistTemplate toDomain() { + return new WorkflowChecklistTemplate(itemCode, label, required); + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8b978b2..ca98288 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -37,6 +37,10 @@ springdoc: path: /swagger-ui.html app: + workflow: + catalog: + location: ${WORKFLOW_CATALOG_LOCATION:classpath:workflow/catalog-projection.local.json} + allow-unreleased: ${WORKFLOW_CATALOG_ALLOW_UNRELEASED:true} cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173} demo-seed: @@ -148,6 +152,10 @@ springdoc: swagger-ui: enabled: false app: + workflow: + catalog: + location: ${WORKFLOW_CATALOG_LOCATION} + allow-unreleased: false cors: allowed-origins: ${CORS_ALLOWED_ORIGINS} auth: diff --git a/src/main/resources/workflow/catalog-projection.local.json b/src/main/resources/workflow/catalog-projection.local.json new file mode 100644 index 0000000..71aec21 --- /dev/null +++ b/src/main/resources/workflow/catalog-projection.local.json @@ -0,0 +1,80 @@ +{ + "bundle_id": "FOWOCO-KNOWLEDGE", + "bundle_version": "0.2.0", + "bundle_status": "DRAFT", + "source_repository": "fowoco/knowledge", + "generated_at": "2026-07-23T00:00:00Z", + "workflows": [ + { + "workflow_id": "WF-CON-001", + "name": "근로계약 갱신과 고용허가기간 연장 준비", + "intent": "EXPIRY_RENEWAL", + "sensitivity": "high", + "supported_task_types": ["RECONTRACT", "EMPLOYMENT_PERIOD_EXTENSION"], + "required_slots": ["worker_id", "due_at"], + "checklist_items": [ + { + "item_code": "EMPLOYMENT_CONTRACT_USE_CURRENT_STANDARD_FORM", + "label": "최신 표준근로계약서 양식을 사용했는지 확인", + "required": true + }, + { + "item_code": "EMPLOYMENT_PERMIT_VERIFY_PERIOD", + "label": "고용허가기간과 연장 적용 여부 확인", + "required": true + } + ], + "completion_evidence": [ + "worker_intent", + "signed_contract_record", + "employment_permit_extension_receipt_or_note", + "hr_approval_id" + ], + "source_ids": [ + "SRC-MOEL-CONTRACT-2026", + "SRC-EPS-FORMS", + "SRC-EPS-EMPLOYMENT-PROCESS" + ] + }, + { + "workflow_id": "WF-STY-001", + "name": "체류기간 연장 준비와 제출 추적", + "intent": "EXPIRY_RENEWAL", + "sensitivity": "high", + "supported_task_types": ["STAY_PERIOD_EXTENSION"], + "required_slots": ["worker_id", "due_at"], + "checklist_items": [ + { + "item_code": "PASSPORT_COPY_VERIFY_CURRENT", + "label": "여권 사본 보유·유효 상태 확인", + "required": true + }, + { + "item_code": "ALIEN_REGISTRATION_CARD_VERIFY_CURRENT", + "label": "외국인등록증 사본 보유·유효 상태 확인", + "required": true + }, + { + "item_code": "EMPLOYMENT_CONTRACT_VERIFY_PERIOD", + "label": "근로계약기간 확인", + "required": true + }, + { + "item_code": "APPLICATION_FORM_SELECT_CURRENT", + "label": "최신 공식 통합신청서 양식 확인", + "required": true + } + ], + "completion_evidence": [ + "hr_approval_id", + "stay_extension_receipt_or_note", + "next_review_at" + ], + "source_ids": [ + "SRC-HIKOREA", + "SRC-LAW-IMMIGRATION-ACT-25", + "SRC-KEIS-REQUIRED-DOCS" + ] + } + ] +} diff --git a/src/test/java/com/fowoco/server/approval/application/SafeJsonServiceTest.java b/src/test/java/com/fowoco/server/approval/application/SafeJsonServiceTest.java index 35f8d13..41e4c02 100644 --- a/src/test/java/com/fowoco/server/approval/application/SafeJsonServiceTest.java +++ b/src/test/java/com/fowoco/server/approval/application/SafeJsonServiceTest.java @@ -35,6 +35,14 @@ void snapshotRejectsSensitiveKeysAndValues() { assertRejected(Map.of("authorization", "Bearer secret-value")); } + @Test + void snapshotAllowsDocumentTypeAndStatusWithoutDocumentNumber() { + assertThat(safeJsonService.write( + Map.of("passport_copy_status", "VERIFIED"), + true + )).contains("passport_copy_status", "VERIFIED"); + } + private void assertRejected(Map snapshot) { assertThatThrownBy(() -> safeJsonService.write(snapshot, true)) .isInstanceOfSatisfying(ApiException.class, exception -> diff --git a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java new file mode 100644 index 0000000..72f8f09 --- /dev/null +++ b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java @@ -0,0 +1,506 @@ +package com.fowoco.server.task; + +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 java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +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 TaskWorkflowIntegrationTest { + + private static final UUID COMPANY_A = + UUID.fromString("c0000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = + UUID.fromString("d0000000-0000-0000-0000-000000000001"); + private static final UUID HR_A = + UUID.fromString("c1000000-0000-0000-0000-000000000001"); + private static final UUID VIEWER_A = + UUID.fromString("c2000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = + UUID.fromString("d1000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_A = + UUID.fromString("c3000000-0000-0000-0000-000000000001"); + private static final String PASSWORD = "Test-password-1!"; + private static final String HR_A_EMAIL = "task.hr.a@example.com"; + private static final String VIEWER_A_EMAIL = "task.viewer.a@example.com"; + private static final String HR_B_EMAIL = "task.hr.b@example.com"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeEach + void resetAndSeed() { + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM task_evidence"); + jdbcTemplate.update("DELETE FROM external_submission"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task_transition_history"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM worker_document"); + jdbcTemplate.update("DELETE FROM worker"); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + + insertCompany(COMPANY_A, "Task 테스트 사업장 A"); + insertCompany(COMPANY_B, "Task 테스트 사업장 B"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash, "HR"); + insertUser(VIEWER_A, COMPANY_A, VIEWER_A_EMAIL, passwordHash, "VIEWER"); + insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash, "HR"); + insertWorker(); + } + + @Test + void supportsTheCatalogTaskChecklistAndCancelApiFlow() throws Exception { + String token = login(HR_A_EMAIL); + + HttpResponse catalog = get("/api/v1/workflow-catalogs", token); + assertThat(catalog.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(catalog.body(), "$.source_repository")) + .isEqualTo("fowoco/knowledge"); + assertThat(JsonPath.>read(catalog.body(), "$.workflows")).hasSize(2); + + HttpResponse created = post("/api/v1/tasks", validCreateBody(), token); + assertThat(created.statusCode()).isEqualTo(201); + assertThat(created.headers().firstValue(HttpHeaders.LOCATION)).isPresent(); + UUID taskId = UUID.fromString(JsonPath.read(created.body(), "$.task_id")); + assertThat(JsonPath.read(created.body(), "$.status")).isEqualTo("DRAFT"); + assertThat(JsonPath.read(created.body(), "$.workflow_catalog_version")) + .isEqualTo("0.2.0"); + List checklistIds = JsonPath.read( + created.body(), + "$.checklist_items[*].checklist_item_id" + ); + assertThat(checklistIds).hasSize(2); + + HttpResponse page = get( + "/api/v1/tasks?worker_id=" + WORKER_A + "&status=DRAFT", + token + ); + assertThat(page.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(page.body(), "$.items")).hasSize(1); + + HttpResponse detail = get("/api/v1/tasks/" + taskId, token); + assertThat(detail.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(detail.body(), "$.title")).isEqualTo("재계약 준비"); + + HttpResponse updated = patch( + "/api/v1/tasks/" + taskId, + """ + { + "title":"재계약 조건 확인", + "description":"임금과 계약기간 확인", + "due_date":"2026-08-20", + "business_data":{"monthly_wage":2500000}, + "expected_version":0 + } + """, + token + ); + assertThat(updated.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(updated.body(), "$.version").longValue()).isEqualTo(1); + + HttpResponse checklist = patch( + "/api/v1/tasks/" + taskId + "/checklist-items/" + checklistIds.get(0), + """ + { + "completed":true, + "expected_version":0, + "expected_task_version":1 + } + """, + token + ); + assertThat(checklist.statusCode()).isEqualTo(200); + assertThat(JsonPath.read( + checklist.body(), + "$.checklist_items[0].completed" + )).isTrue(); + + HttpResponse cancelled = post( + "/api/v1/tasks/" + taskId + "/cancel", + """ + {"expected_version":1,"reason":"근로계약 갱신 계획 변경"} + """, + token + ); + assertThat(cancelled.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(cancelled.body(), "$.status")).isEqualTo("CANCELLED"); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE target_id = ?", + Integer.class, + taskId + )).isEqualTo(4); + } + + @Test + void missingRequiredSlotCreatesNeedsInfoAndCannotRequestApproval() throws Exception { + String token = login(HR_A_EMAIL); + String body = validCreateBody().replace("\"due_date\":\"2026-08-20\",", ""); + + HttpResponse created = post("/api/v1/tasks", body, token); + + assertThat(created.statusCode()).isEqualTo(201); + assertThat(JsonPath.read(created.body(), "$.status")).isEqualTo("NEEDS_INFO"); + assertThat(JsonPath.>read(created.body(), "$.missing_required_slots")) + .containsExactly("due_at"); + } + + @Test + void viewerCannotWriteAndAnotherCompanyCannotDiscoverTask() throws Exception { + String hrToken = login(HR_A_EMAIL); + UUID taskId = UUID.fromString(JsonPath.read( + post("/api/v1/tasks", validCreateBody(), hrToken).body(), + "$.task_id" + )); + String viewerToken = login(VIEWER_A_EMAIL); + String otherCompanyToken = login(HR_B_EMAIL); + + assertThat(post("/api/v1/tasks", validCreateBody(), viewerToken).statusCode()) + .isEqualTo(403); + assertThat(get("/api/v1/tasks/" + taskId, otherCompanyToken).statusCode()) + .isEqualTo(404); + } + + @Test + void resignedAndTerminatedWorkersCannotReceiveNewTasks() throws Exception { + String token = login(HR_A_EMAIL); + + for (String status : List.of("RESIGNED", "TERMINATED")) { + jdbcTemplate.update( + "UPDATE worker SET work_status = ?, version = version + 1 WHERE worker_id = ?", + status, + WORKER_A + ); + + HttpResponse response = post("/api/v1/tasks", validCreateBody(), token); + + assertThat(response.statusCode()).isEqualTo(422); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("WORKER_NOT_ELIGIBLE"); + } + } + + @Test + void sensitiveBusinessDataAndStaleVersionAreRejected() throws Exception { + String token = login(HR_A_EMAIL); + String sensitive = validCreateBody().replace( + "\"monthly_wage\":2500000", + "\"phone\":\"010-1234-5678\"" + ); + HttpResponse rejected = post("/api/v1/tasks", sensitive, token); + assertThat(rejected.statusCode()).isEqualTo(422); + assertThat(JsonPath.read(rejected.body(), "$.code")) + .isEqualTo("SENSITIVE_TASK_DATA_REJECTED"); + + UUID taskId = UUID.fromString(JsonPath.read( + post("/api/v1/tasks", validCreateBody(), token).body(), + "$.task_id" + )); + HttpResponse stale = patch( + "/api/v1/tasks/" + taskId, + """ + { + "title":"오래된 화면 수정", + "description":null, + "due_date":"2026-08-20", + "business_data":{}, + "expected_version":1 + } + """, + token + ); + assertThat(stale.statusCode()).isEqualTo(409); + assertThat(JsonPath.read(stale.body(), "$.code")) + .isEqualTo("CONCURRENT_MODIFICATION"); + } + + @Test + void criticalEditInvalidatesAnApprovedSnapshot() throws Exception { + String token = login(HR_A_EMAIL); + HttpResponse created = post("/api/v1/tasks", validCreateBody(), token); + UUID taskId = UUID.fromString(JsonPath.read(created.body(), "$.task_id")); + List checklistIds = JsonPath.read( + created.body(), + "$.checklist_items[*].checklist_item_id" + ); + for (String checklistId : checklistIds) { + HttpResponse checked = patch( + "/api/v1/tasks/" + taskId + "/checklist-items/" + checklistId, + """ + { + "completed":true, + "expected_version":0, + "expected_task_version":0 + } + """, + token + ); + assertThat(checked.statusCode()).isEqualTo(200); + } + assertThat(post( + "/api/v1/tasks/" + taskId + "/approval-requests", + """ + { + "expected_version":0, + "ai_snapshot":null, + "hr_snapshot":{"worker_id":"%s"}, + "changed_fields":[], + "source_versions":{"workflow_catalog_version":"0.2.0"} + } + """.formatted(WORKER_A), + token + ).statusCode()).isEqualTo(201); + assertThat(post( + "/api/v1/tasks/" + taskId + "/approve", + """ + {"expected_version":1,"reason":"계약조건 확인"} + """, + token + ).statusCode()).isEqualTo(200); + + HttpResponse updated = patch( + "/api/v1/tasks/" + taskId, + """ + { + "title":"재계약 임금 변경", + "description":"승인 뒤 중요값 수정", + "due_date":"2026-08-21", + "business_data":{"monthly_wage":2700000}, + "expected_version":2 + } + """, + token + ); + + assertThat(updated.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(updated.body(), "$.status")) + .isEqualTo("READY_FOR_REVIEW"); + assertThat(JsonPath.read(updated.body(), "$.version").longValue()).isEqualTo(4); + assertThat(JsonPath.read(updated.body(), "$.content_revision").longValue()) + .isEqualTo(1); + assertThat(jdbcTemplate.queryForList( + "SELECT status FROM approval_request WHERE task_id = ? ORDER BY created_at", + String.class, + taskId + )).containsExactly("INVALIDATED", "PENDING"); + } + + @Test + void concurrentTaskEditsAllowOnlyOneWinner() throws Exception { + String token = login(HR_A_EMAIL); + UUID taskId = UUID.fromString(JsonPath.read( + post("/api/v1/tasks", validCreateBody(), token).body(), + "$.task_id" + )); + String body = """ + { + "title":"동시 수정", + "description":"같은 version으로 동시에 수정", + "due_date":"2026-08-20", + "business_data":{"monthly_wage":2600000}, + "expected_version":0 + } + """; + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + Future> first = executor.submit( + () -> patchAfterSignal( + "/api/v1/tasks/" + taskId, + body, + token, + ready, + start + ) + ); + Future> second = executor.submit( + () -> patchAfterSignal( + "/api/v1/tasks/" + taskId, + body, + token, + ready, + start + ) + ); + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + assertThat(List.of( + first.get(10, TimeUnit.SECONDS).statusCode(), + second.get(10, TimeUnit.SECONDS).statusCode() + )).containsExactlyInAnyOrder(200, 409); + } finally { + start.countDown(); + executor.shutdownNow(); + } + assertThat(jdbcTemplate.queryForObject( + "SELECT version FROM task WHERE task_id = ?", + Long.class, + taskId + )).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event " + + "WHERE target_id = ? AND action = 'TASK_UPDATED'", + Integer.class, + taskId + )).isEqualTo(1); + } + + private String validCreateBody() { + return """ + { + "worker_id":"%s", + "task_type":"RECONTRACT", + "workflow_id":"WF-CON-001", + "title":"재계약 준비", + "description":"기존 조건 확인", + "due_date":"2026-08-20", + "business_data":{"monthly_wage":2500000} + } + """.formatted(WORKER_A); + } + + private String login(String email) throws Exception { + HttpResponse response = post( + "/api/v1/auth/login", + """ + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD), + null + ); + assertThat(response.statusCode()).isEqualTo(200); + return JsonPath.read(response.body(), "$.access_token"); + } + + private HttpResponse get(String path, String token) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)).GET(); + authorize(builder, token); + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse post(String path, String body, String token) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + authorize(builder, token); + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse patch(String path, String body, String token) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .method("PATCH", HttpRequest.BodyPublishers.ofString(body)); + authorize(builder, token); + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse patchAfterSignal( + String path, + String body, + String token, + CountDownLatch ready, + CountDownLatch start + ) throws Exception { + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("concurrent task update start signal timed out"); + } + return patch(path, body, token); + } + + private void authorize(HttpRequest.Builder builder, String token) { + if (token != null) { + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } + + private void insertCompany(UUID companyId, String name) { + jdbcTemplate.update( + """ + INSERT INTO company ( + company_id, name, status, created_at, updated_at, version + ) VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + companyId, + name + ); + } + + private void insertUser( + UUID userId, + UUID companyId, + String email, + String passwordHash, + String role + ) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + userId, + companyId, + email, + email, + passwordHash, + role + ); + } + + private void insertWorker() { + jdbcTemplate.update( + """ + INSERT INTO worker ( + worker_id, company_id, display_name, nationality_code, preferred_language, + work_status, stay_expiry_date, contract_start_date, contract_end_date, + created_at, updated_at, version + ) VALUES (?, ?, '근로자 A', 'VNM', 'vi', 'ACTIVE', ?, ?, ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + WORKER_A, + COMPANY_A, + LocalDate.of(2027, 8, 31), + LocalDate.of(2026, 9, 1), + LocalDate.of(2027, 8, 31) + ); + } +} diff --git a/src/test/java/com/fowoco/server/task/api/TaskOpenApiContractTest.java b/src/test/java/com/fowoco/server/task/api/TaskOpenApiContractTest.java new file mode 100644 index 0000000..14714dd --- /dev/null +++ b/src/test/java/com/fowoco/server/task/api/TaskOpenApiContractTest.java @@ -0,0 +1,118 @@ +package com.fowoco.server.task.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TaskOpenApiContractTest { + + @LocalServerPort + private int port; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private JsonNode openApi; + + @BeforeAll + void loadOpenApi() throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/v3/api-docs")) + .GET() + .build(); + HttpResponse response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + assertThat(response.statusCode()).isEqualTo(200); + openApi = objectMapper.readTree(response.body()); + } + + @Test + void allTaskWorkflowOperationsArePublished() { + List operationIds = List.of( + operationId("/paths/~1api~1v1~1workflow-catalogs/get"), + operationId("/paths/~1api~1v1~1tasks/get"), + operationId("/paths/~1api~1v1~1tasks/post"), + operationId("/paths/~1api~1v1~1tasks~1{taskId}/get"), + operationId("/paths/~1api~1v1~1tasks~1{taskId}/patch"), + operationId( + "/paths/~1api~1v1~1tasks~1{taskId}~1checklist-items~1{itemId}/patch" + ), + operationId("/paths/~1api~1v1~1tasks~1{taskId}~1cancel/post") + ); + + assertThat(operationIds).containsExactly( + "getActiveWorkflowCatalog", + "listTasks", + "createTask", + "getTask", + "updateTask", + "updateTaskChecklistItem", + "cancelTask" + ); + } + + @Test + void writeContractsRequireVersionAndNeverAcceptTenantOrStatus() { + JsonNode update = openApi.at("/components/schemas/UpdateTaskRequest"); + JsonNode checklist = openApi.at("/components/schemas/UpdateChecklistItemRequest"); + JsonNode cancel = openApi.at("/components/schemas/CancelTaskRequest"); + JsonNode create = openApi.at("/components/schemas/CreateTaskRequest/properties"); + + assertThat(update.path("required").toString()) + .contains("expected_version", "business_data", "title"); + assertThat(checklist.path("required").toString()) + .contains("completed", "expected_version", "expected_task_version"); + assertThat(cancel.path("required").toString()).contains("expected_version", "reason"); + assertThat(create.has("company_id")).isFalse(); + assertThat(create.has("status")).isFalse(); + } + + @Test + void mutationOperationsDocumentBearerConflictAndBusinessRuleErrors() { + JsonNode update = openApi.at("/paths/~1api~1v1~1tasks~1{taskId}/patch"); + + assertThat(update.at("/security/0/bearerAuth").isArray()).isTrue(); + assertThat(update.at("/responses/401/$ref").asText()) + .isEqualTo("#/components/responses/Unauthorized"); + assertThat(update.at("/responses/403/$ref").asText()) + .isEqualTo("#/components/responses/Forbidden"); + assertThat(update.at("/responses/409/$ref").asText()) + .isEqualTo("#/components/responses/Conflict"); + assertThat(update.at("/responses/422/$ref").asText()) + .isEqualTo("#/components/responses/UnprocessableEntity"); + } + + @Test + void responseAndCatalogSchemasExposePinnedVersionsInSnakeCase() { + JsonNode task = openApi.at("/components/schemas/TaskDetailResponse/properties"); + JsonNode workflow = openApi.at( + "/components/schemas/WorkflowDefinitionResponse/properties" + ); + + assertThat(task.has("workflow_catalog_version")).isTrue(); + assertThat(task.has("content_revision")).isTrue(); + assertThat(task.has("missing_required_slots")).isTrue(); + assertThat(workflow.has("supported_task_types")).isTrue(); + assertThat(workflow.has("source_ids")).isTrue(); + } + + private String operationId(String pointer) { + return openApi.at(pointer).path("operationId").asText(); + } +} diff --git a/src/test/java/com/fowoco/server/task/domain/TaskChecklistItemTest.java b/src/test/java/com/fowoco/server/task/domain/TaskChecklistItemTest.java new file mode 100644 index 0000000..c8d1db7 --- /dev/null +++ b/src/test/java/com/fowoco/server/task/domain/TaskChecklistItemTest.java @@ -0,0 +1,84 @@ +package com.fowoco.server.task.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.task.application.error.TaskErrorCode; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class TaskChecklistItemTest { + + private static final UUID ITEM_ID = + UUID.fromString("e0000000-0000-0000-0000-000000000001"); + private static final UUID TASK_ID = + UUID.fromString("e0000000-0000-0000-0000-000000000002"); + private static final UUID COMPANY_ID = + UUID.fromString("e0000000-0000-0000-0000-000000000003"); + private static final UUID ACTOR_ID = + UUID.fromString("e0000000-0000-0000-0000-000000000004"); + private static final Instant NOW = Instant.parse("2026-07-23T01:00:00Z"); + + @Test + void recordsAndClearsCompletionMetadataTogether() { + TaskChecklistItem item = item(0); + + assertThat(item.updateCompletion(true, 0, ACTOR_ID, NOW.plusSeconds(1))).isTrue(); + assertThat(item.completed()).isTrue(); + assertThat(item.completedBy()).isEqualTo(ACTOR_ID); + assertThat(item.completedAt()).isEqualTo(NOW.plusSeconds(1)); + + assertThat(item.updateCompletion(false, 0, ACTOR_ID, NOW.plusSeconds(2))).isTrue(); + assertThat(item.completed()).isFalse(); + assertThat(item.completedBy()).isNull(); + assertThat(item.completedAt()).isNull(); + } + + @Test + void rejectsStaleChecklistVersion() { + TaskChecklistItem item = item(2); + + assertThatThrownBy(() -> + item.updateCompletion(true, 1, ACTOR_ID, NOW.plusSeconds(1))) + .isInstanceOfSatisfying(ApiException.class, exception -> + assertThat(exception.errorCode()) + .isEqualTo(TaskErrorCode.CONCURRENT_MODIFICATION)); + } + + @Test + void rejectsPartiallyPersistedCompletionMetadata() { + assertThatThrownBy(() -> new TaskChecklistItem( + ITEM_ID, + TASK_ID, + COMPANY_ID, + "REQUIRED_ITEM", + "필수 확인", + true, + false, + ACTOR_ID, + null, + NOW, + NOW, + 0 + )).isInstanceOf(IllegalArgumentException.class); + } + + private TaskChecklistItem item(long version) { + return new TaskChecklistItem( + ITEM_ID, + TASK_ID, + COMPANY_ID, + "REQUIRED_ITEM", + "필수 확인", + true, + false, + null, + null, + NOW, + NOW, + version + ); + } +} diff --git a/src/test/java/com/fowoco/server/task/domain/TaskTest.java b/src/test/java/com/fowoco/server/task/domain/TaskTest.java index 1725fea..30d6522 100644 --- a/src/test/java/com/fowoco/server/task/domain/TaskTest.java +++ b/src/test/java/com/fowoco/server/task/domain/TaskTest.java @@ -9,6 +9,8 @@ import java.time.LocalDate; import java.util.UUID; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; class TaskTest { @@ -66,6 +68,7 @@ void invalidatesApprovalWhenCriticalDataChanges() { "{\"wage\":3100000}", FINGERPRINT_B, LocalDate.of(2026, 8, 1), + true, 0, ACTOR_ID, NOW.plusSeconds(1) @@ -74,7 +77,7 @@ void invalidatesApprovalWhenCriticalDataChanges() { assertThat(outcome.criticalChanged()).isTrue(); assertThat(outcome.approvalInvalidated()).isTrue(); assertThat(task.contentRevision()).isEqualTo(1); - assertThat(task.status()).isEqualTo(TaskStatus.READY_FOR_REVIEW); + assertThat(task.status()).isEqualTo(TaskStatus.DRAFT); } @Test @@ -96,6 +99,63 @@ void terminalTaskCannotBeCancelledAgain() { .isEqualTo(TaskErrorCode.TASK_TRANSITION_NOT_ALLOWED)); } + @ParameterizedTest + @EnumSource( + value = TaskStatus.class, + names = { + "DRAFT", + "NEEDS_INFO", + "READY_FOR_REVIEW", + "APPROVED", + "WAITING_WORKER", + "WAITING_EXTERNAL" + } + ) + void everyNonTerminalTaskCanBeCancelled(TaskStatus status) { + Task task = task(status); + + TaskStatus previous = task.cancel(0, ACTOR_ID, NOW.plusSeconds(1)); + + assertThat(previous).isEqualTo(status); + assertThat(task.status()).isEqualTo(TaskStatus.CANCELLED); + } + + @ParameterizedTest + @EnumSource(value = TaskStatus.class, names = {"COMPLETED", "CANCELLED"}) + void terminalTasksRejectCancellation(TaskStatus status) { + Task task = task(status); + + assertThatThrownBy(() -> task.cancel(0, ACTOR_ID, NOW.plusSeconds(1))) + .isInstanceOfSatisfying(ApiException.class, exception -> + assertThat(exception.errorCode()) + .isEqualTo(TaskErrorCode.TASK_TRANSITION_NOT_ALLOWED)); + } + + @ParameterizedTest + @EnumSource( + value = TaskStatus.class, + names = { + "READY_FOR_REVIEW", + "APPROVED", + "WAITING_WORKER", + "WAITING_EXTERNAL" + } + ) + void missingRequiredChecklistInvalidatesReviewOrApproval(TaskStatus status) { + Task task = task(status); + + Task.RequirementsOutcome outcome = task.reassessRequirements( + false, + 0, + ACTOR_ID, + NOW.plusSeconds(1) + ); + + assertThat(outcome.approvalInvalidated()).isTrue(); + assertThat(task.status()).isEqualTo(TaskStatus.NEEDS_INFO); + assertThat(task.contentRevision()).isEqualTo(1); + } + private Task task(TaskStatus status) { return new Task( UUID.fromString("20000000-0000-0000-0000-000000000001"),