diff --git a/README.md b/README.md index 7f2cd811..b13d2a86 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ AI 실행, 승인, 근로자 링크, 알림과 장애 복구까지 하나의 Pos | 핵심 업무 API | Auth·Worker·Document·Task·Approval·Worker Link·Case·Dashboard·Notification 구현 | | AI 연동 | PLAN에서 대표 Intent·Workflow를 한 번 결정하고, 허용 Slot을 보충한 뒤 같은 결정을 ANALYZE에 재사용하는 AiRun·SSE 흐름 구현 | | 문서 처리 | 파일 저장·다운로드, HWP/HWPX 검증·생성 결과 연계, OCR 실행·HR 검토 구현 | -| 근로자 협업 | 만료형 보안 링크 발급, 모바일 안내·응답·서류 제출, HR 공식 서류 채택과 Task 재개 구현 | +| 근로자 협업 | 만료형 보안 링크 발급, 모바일 안내·응답·서류 제출, HR 공식 서류 채택과 Task 재개, 퇴사 근로자 안전 보관 구현 | | 알림 | 업무 Domain Event와 Outbox를 이용한 알림 생성, 읽음 상태, 마감 임박 배치 구현 | | 운영 기반 | Flyway, PostgreSQL 16, RLS, Transactional Outbox, 감사로그, Micrometer·Prometheus, Docker·Kubernetes·HTTPS 배포와 제품 E2E 검증 | @@ -65,6 +65,7 @@ AI 실행, 승인, 근로자 링크, 알림과 장애 복구까지 하나의 Pos - 사업장 사용자 인증과 `ADMIN`·`HR`·`VIEWER` 권한 - `company_id`를 기준으로 한 사업장 데이터 격리 - 근로자 기본정보와 서류 메타데이터 관리 +- 체류 만료 경과 확인과 퇴사 근로자의 삭제 없는 안전 보관·업무 차단 - CSV/XLSX 근로자 명단 가져오기와 OCR 검토 - 업무카드·체크리스트·상태 전이 관리 - HR 승인·반려·외부 제출·증빙·완료와 감사로그 @@ -102,6 +103,7 @@ HR 로그인 → 승인된 OCR Context로 기존 Task 재개·문서 초안 생성 → 외부 제출·처리결과 기록 → 완료·감사로그 +→ 퇴사·업무 종료 확인 후 운영 목록에서 안전 보관 ``` 대표 입력: @@ -274,6 +276,7 @@ src/main/java/com/fowoco/server/ | 중요한 설계 결정 | [ADR 목록](docs/adr/README.md) | 저장소 경계, API·보안, Task·AiRun, RLS 결정 원본 | | Server ↔ AI 계약 | [AI Runtime 계약](docs/ai-runtime-contract.md) | Server가 AI에 보내고 받을 수 있는 값과 검증 기준 | | 근로자 명단 가져오기 | [Worker Import 가이드](docs/worker-import.md) | CSV/XLSX 업로드부터 검증·수정·등록까지의 API 순서 | +| 퇴사 근로자 보관 | [근로자 안전 보관 가이드](docs/worker-archive.md) | 삭제 없이 운영 대상에서 분리하는 조건·API·감사 기준 | | Agent DB 정보 보충 | [Slot 조회·재호출](docs/ai-slot-resolution.md) | canonical key allow-list, tenant 조회와 ANALYZE 재호출 기준 | | AI 단계별 성능 측정 | [AI 파이프라인 관측·Prometheus 가이드](docs/ai-pipeline-observability.md) | PLAN·Slot·ANALYZE·Renewal 구간의 정량 평가와 로컬 Prometheus 확인 기준 | | 이벤트 유실·재처리 | [Outbox 운영 가이드](docs/reliability/transactional-outbox.md) | 이벤트 발행, lease, 재시도와 장애 복구 기준 | diff --git a/docs/worker-archive.md b/docs/worker-archive.md new file mode 100644 index 00000000..aed66f01 --- /dev/null +++ b/docs/worker-archive.md @@ -0,0 +1,55 @@ +# 근로자 안전 보관 운영 가이드 + +## 목적 + +퇴사하거나 고용이 종료된 근로자를 물리적으로 삭제하지 않고 운영 목록에서 분리합니다. +과거 Task·문서·근로자 응답·감사로그의 참조는 그대로 보존합니다. + +체류기간이 지났다는 사실만으로 퇴사나 불법체류를 판단하거나 자동 보관하지 않습니다. +체류 만료 경과 확인은 `stay_verification_case`에서 먼저 처리하고, 실제 근무상태와 +남은 업무가 정리된 뒤 HR 또는 ADMIN이 사유를 입력해 보관합니다. + +## API 흐름 + +```text +GET /api/v1/workers/{workerId}/archive-eligibility +→ 보관 차단 사유 확인 +→ HR이 남은 Task·승인·Worker Link 정리 +→ POST /api/v1/workers/{workerId}/archive +→ worker_archive와 WORKER_ARCHIVED 감사로그 저장 +→ 기본 목록·AI 대상 탐색·신규 Task 대상에서 제외 +``` + +보관 요청 예시: + +```json +{ + "reason": "퇴사 및 진행 업무 종료 확인", + "expected_version": 3 +} +``` + +## 보관 차단 조건 + +| 코드 | 의미 | 담당자 행동 | +| --- | --- | --- | +| `ACTIVE_EMPLOYMENT_STATUS` | `ACTIVE` 또는 `ON_LEAVE` 상태 | 실제 근무상태를 먼저 확인 | +| `OPEN_TASK` | 완료·취소되지 않은 Task 존재 | 업무를 완료하거나 취소 | +| `PENDING_APPROVAL` | 결정을 기다리는 승인 존재 | 승인 또는 반려 처리 | +| `ACTIVE_WORKER_LINK` | 아직 유효한 근로자 링크 존재 | 응답을 마치거나 링크 만료·폐기 처리 | +| `ALREADY_ARCHIVED` | 이미 보관됨 | 중복 처리하지 않고 기존 기록 확인 | + +## 보존과 차단 범위 + +- 기본 `GET /workers` 목록과 검색에서는 보관 근로자를 제외합니다. +- 자연어 분석의 근로자 탐색과 신규 Task 대상 확인에서도 제외합니다. +- `GET /workers/{workerId}` 상세와 기존 Task·문서·응답·감사 기록은 삭제하지 않습니다. +- 보관은 복구할 수 없는 삭제가 아니며, 원문 개인정보 자동 삭제 정책을 의미하지 않습니다. +- `expected_version`으로 동시 변경을 막고 보관 시각·처리자·사유를 감사 가능하게 남깁니다. + +## DB와 RLS + +- `V56__create_worker_archive.sql`: 보관 메타데이터와 복합 FK 생성 +- `V57__prepare_worker_archive_rls.sql`: 사업장 격리 정책 준비 + +RLS 활성화는 공통 RLS Migration PR에서 기존 테이블과 함께 수행합니다. 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 cb23ee7e..c630ba07 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -46,5 +46,6 @@ public enum AuditAction { DOCUMENT_OCR_APPROVED, DOCUMENT_OCR_REJECTED, STAY_VERIFICATION_CASE_CREATED, - STAY_VERIFICATION_STATUS_UPDATED + STAY_VERIFICATION_STATUS_UPDATED, + WORKER_ARCHIVED } diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java index 46c92d2d..3819db96 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java @@ -15,5 +15,6 @@ public enum AuditTargetType { WORKER_IMPORT, COMPANY_SETTINGS, DOCUMENT_OCR_RUN, - STAY_VERIFICATION + STAY_VERIFICATION, + WORKER } diff --git a/src/main/java/com/fowoco/server/worker/archive/api/WorkerArchiveController.java b/src/main/java/com/fowoco/server/worker/archive/api/WorkerArchiveController.java new file mode 100644 index 00000000..aa1d5a1d --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/api/WorkerArchiveController.java @@ -0,0 +1,65 @@ +package com.fowoco.server.worker.archive.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.worker.archive.application.WorkerArchiveCommand; +import com.fowoco.server.worker.archive.application.WorkerArchiveService; +import io.swagger.v3.oas.annotations.Operation; +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 java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Worker Archive", description = "퇴사 근로자의 안전 보관 가능 여부 확인·처리") +@RestController +@RequestMapping("/api/v1/workers/{workerId}") +@SecurityRequirement(name = "bearerAuth") +public class WorkerArchiveController { + + private final WorkerArchiveService archiveService; + private final ActorContextProvider actorContextProvider; + + public WorkerArchiveController( + WorkerArchiveService archiveService, + ActorContextProvider actorContextProvider + ) { + this.archiveService = archiveService; + this.actorContextProvider = actorContextProvider; + } + + @Operation(summary = "근로자 보관 가능 여부 확인") + @GetMapping("/archive-eligibility") + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public WorkerArchiveEligibilityResponse checkEligibility(@PathVariable UUID workerId) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return WorkerArchiveEligibilityResponse.from(archiveService.checkEligibility(workerId, actor)); + } + + @Operation(summary = "퇴사 근로자 안전 보관") + @PostMapping("/archive") + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public WorkerArchiveResponse archive( + @PathVariable UUID workerId, + @Valid @RequestBody WorkerArchiveRequest request, + HttpServletRequest servletRequest + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return WorkerArchiveResponse.from(archiveService.archive( + new WorkerArchiveCommand(workerId, request.reason(), request.expectedVersion()), + actor, + RequestMetadata.from(servletRequest) + )); + } +} diff --git a/src/main/java/com/fowoco/server/worker/archive/api/WorkerArchiveEligibilityResponse.java b/src/main/java/com/fowoco/server/worker/archive/api/WorkerArchiveEligibilityResponse.java new file mode 100644 index 00000000..232a3916 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/api/WorkerArchiveEligibilityResponse.java @@ -0,0 +1,23 @@ +package com.fowoco.server.worker.archive.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.worker.archive.application.WorkerArchiveBlocker; +import com.fowoco.server.worker.archive.application.WorkerArchiveEligibility; +import java.util.List; +import java.util.UUID; + +public record WorkerArchiveEligibilityResponse( + @JsonProperty("worker_id") UUID workerId, + boolean archivable, + List blockers, + @JsonProperty("worker_version") long workerVersion +) { + public static WorkerArchiveEligibilityResponse from(WorkerArchiveEligibility eligibility) { + return new WorkerArchiveEligibilityResponse( + eligibility.workerId(), + eligibility.archivable(), + eligibility.blockers(), + eligibility.workerVersion() + ); + } +} diff --git a/src/main/java/com/fowoco/server/worker/archive/api/WorkerArchiveRequest.java b/src/main/java/com/fowoco/server/worker/archive/api/WorkerArchiveRequest.java new file mode 100644 index 00000000..307e3782 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/api/WorkerArchiveRequest.java @@ -0,0 +1,13 @@ +package com.fowoco.server.worker.archive.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public record WorkerArchiveRequest( + @NotBlank @Size(max = 500) String reason, + @JsonProperty("expected_version") @NotNull @PositiveOrZero Long expectedVersion +) { +} diff --git a/src/main/java/com/fowoco/server/worker/archive/api/WorkerArchiveResponse.java b/src/main/java/com/fowoco/server/worker/archive/api/WorkerArchiveResponse.java new file mode 100644 index 00000000..69441b56 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/api/WorkerArchiveResponse.java @@ -0,0 +1,24 @@ +package com.fowoco.server.worker.archive.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.worker.archive.domain.WorkerArchive; +import java.time.Instant; +import java.util.UUID; + +public record WorkerArchiveResponse( + @JsonProperty("worker_id") UUID workerId, + @JsonProperty("archived_at") Instant archivedAt, + @JsonProperty("archived_by") UUID archivedBy, + @JsonProperty("archive_reason") String archiveReason, + @JsonProperty("worker_version") long workerVersion +) { + public static WorkerArchiveResponse from(WorkerArchive archive) { + return new WorkerArchiveResponse( + archive.workerId(), + archive.archivedAt(), + archive.archivedBy(), + archive.archiveReason(), + archive.workerVersion() + ); + } +} diff --git a/src/main/java/com/fowoco/server/worker/archive/application/WorkerArchiveBlocker.java b/src/main/java/com/fowoco/server/worker/archive/application/WorkerArchiveBlocker.java new file mode 100644 index 00000000..2b8b7e61 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/application/WorkerArchiveBlocker.java @@ -0,0 +1,9 @@ +package com.fowoco.server.worker.archive.application; + +public enum WorkerArchiveBlocker { + ACTIVE_EMPLOYMENT_STATUS, + OPEN_TASK, + PENDING_APPROVAL, + ACTIVE_WORKER_LINK, + ALREADY_ARCHIVED +} diff --git a/src/main/java/com/fowoco/server/worker/archive/application/WorkerArchiveCommand.java b/src/main/java/com/fowoco/server/worker/archive/application/WorkerArchiveCommand.java new file mode 100644 index 00000000..80011810 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/application/WorkerArchiveCommand.java @@ -0,0 +1,10 @@ +package com.fowoco.server.worker.archive.application; + +import java.util.UUID; + +public record WorkerArchiveCommand( + UUID workerId, + String reason, + long expectedVersion +) { +} diff --git a/src/main/java/com/fowoco/server/worker/archive/application/WorkerArchiveEligibility.java b/src/main/java/com/fowoco/server/worker/archive/application/WorkerArchiveEligibility.java new file mode 100644 index 00000000..1280bb2a --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/application/WorkerArchiveEligibility.java @@ -0,0 +1,15 @@ +package com.fowoco.server.worker.archive.application; + +import java.util.List; +import java.util.UUID; + +public record WorkerArchiveEligibility( + UUID workerId, + boolean archivable, + List blockers, + long workerVersion +) { + public WorkerArchiveEligibility { + blockers = List.copyOf(blockers); + } +} diff --git a/src/main/java/com/fowoco/server/worker/archive/application/WorkerArchiveService.java b/src/main/java/com/fowoco/server/worker/archive/application/WorkerArchiveService.java new file mode 100644 index 00000000..82650bc1 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/application/WorkerArchiveService.java @@ -0,0 +1,180 @@ +package com.fowoco.server.worker.archive.application; + +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.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.security.TenantDatabaseContext; +import com.fowoco.server.common.time.DatabaseTimestamp; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.worker.application.error.WorkerErrorCode; +import com.fowoco.server.worker.application.port.WorkerRepository; +import com.fowoco.server.worker.archive.application.error.WorkerArchiveErrorCode; +import com.fowoco.server.worker.archive.application.port.WorkerArchiveRepository; +import com.fowoco.server.worker.archive.domain.WorkerArchive; +import com.fowoco.server.worker.domain.Worker; +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkerArchiveService { + + private static final String AUDIT_EVENT_VERSION = "1.0"; + private static final int MAX_REASON_LENGTH = 500; + + private final WorkerRepository workerRepository; + private final WorkerArchiveRepository archiveRepository; + private final AuditEventRepository auditRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public WorkerArchiveService( + WorkerRepository workerRepository, + WorkerArchiveRepository archiveRepository, + AuditEventRepository auditRepository, + TenantDatabaseContext tenantDatabaseContext, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.workerRepository = workerRepository; + this.archiveRepository = archiveRepository; + this.auditRepository = auditRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional(readOnly = true) + public WorkerArchiveEligibility checkEligibility(UUID workerId, ActorContext actor) { + bindTenant(actor); + Worker worker = requireWorker(workerId, actor.companyId()); + return eligibility(worker, DatabaseTimestamp.now(clock)); + } + + @Transactional + public WorkerArchive archive( + WorkerArchiveCommand command, + ActorContext actor, + RequestMetadata metadata + ) { + bindTenant(actor); + String reason = normalizeReason(command.reason()); + if (!archiveRepository.lockWorker(command.workerId(), actor.companyId())) { + throw new ApiException(WorkerErrorCode.WORKER_NOT_FOUND); + } + Worker worker = requireWorker(command.workerId(), actor.companyId()); + WorkerArchiveEligibility eligibility = eligibility(worker, DatabaseTimestamp.now(clock)); + + if (eligibility.blockers().contains(WorkerArchiveBlocker.ALREADY_ARCHIVED)) { + throw new ApiException(WorkerArchiveErrorCode.WORKER_ALREADY_ARCHIVED); + } + if (!eligibility.archivable()) { + throw new ApiException(WorkerArchiveErrorCode.WORKER_ARCHIVE_NOT_ALLOWED); + } + if (worker.version() != command.expectedVersion()) { + throw new ApiException(WorkerArchiveErrorCode.WORKER_ARCHIVE_VERSION_CONFLICT); + } + + Instant now = DatabaseTimestamp.nowNotBefore(clock, worker.createdAt()); + if (!archiveRepository.reserveWorkerVersion( + worker.workerId(), worker.companyId(), command.expectedVersion(), now)) { + throw new ApiException(WorkerArchiveErrorCode.WORKER_ARCHIVE_VERSION_CONFLICT); + } + + WorkerArchive archive = new WorkerArchive( + worker.workerId(), + worker.companyId(), + now, + actor.actorId(), + reason, + command.expectedVersion() + 1 + ); + archiveRepository.insert(archive); + appendAudit(archive, actor, metadata); + return archive; + } + + private WorkerArchiveEligibility eligibility(Worker worker, Instant now) { + List blockers = new ArrayList<>(); + if (worker.isCurrentlyEmployed()) { + blockers.add(WorkerArchiveBlocker.ACTIVE_EMPLOYMENT_STATUS); + } + if (archiveRepository.find(worker.workerId(), worker.companyId()).isPresent()) { + blockers.add(WorkerArchiveBlocker.ALREADY_ARCHIVED); + } + blockers.addAll(archiveRepository.findOperationalBlockers( + worker.workerId(), worker.companyId(), now)); + List distinct = blockers.stream().distinct().toList(); + return new WorkerArchiveEligibility( + worker.workerId(), distinct.isEmpty(), distinct, worker.version()); + } + + private Worker requireWorker(UUID workerId, UUID companyId) { + return workerRepository.findByWorkerIdAndCompanyId(workerId, companyId) + .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND)); + } + + private String normalizeReason(String reason) { + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("archive reason must not be blank"); + } + String normalized = reason.strip(); + if (normalized.length() > MAX_REASON_LENGTH) { + throw new IllegalArgumentException( + "archive reason must not exceed " + MAX_REASON_LENGTH + " characters"); + } + return normalized; + } + + private void appendAudit( + WorkerArchive archive, + ActorContext actor, + RequestMetadata metadata + ) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + archive.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + AuditAction.WORKER_ARCHIVED, + AuditTargetType.WORKER, + archive.workerId(), + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + "근로자를 운영 목록에서 안전 보관 처리함", + archive.archivedAt() + )); + } + + 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; + }; + } + + private void bindTenant(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + } +} diff --git a/src/main/java/com/fowoco/server/worker/archive/application/error/WorkerArchiveErrorCode.java b/src/main/java/com/fowoco/server/worker/archive/application/error/WorkerArchiveErrorCode.java new file mode 100644 index 00000000..b90f9a3a --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/application/error/WorkerArchiveErrorCode.java @@ -0,0 +1,33 @@ +package com.fowoco.server.worker.archive.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum WorkerArchiveErrorCode implements ApiErrorCode { + WORKER_ARCHIVE_NOT_ALLOWED(HttpStatus.CONFLICT, "진행 중인 업무 또는 현재 근무상태 때문에 보관할 수 없습니다."), + WORKER_ALREADY_ARCHIVED(HttpStatus.CONFLICT, "이미 보관 처리된 근로자입니다."), + WORKER_ARCHIVE_VERSION_CONFLICT(HttpStatus.CONFLICT, "근로자 정보가 변경되었습니다. 새로고침 후 다시 시도해 주세요."); + + private final HttpStatus status; + private final String defaultMessage; + + WorkerArchiveErrorCode(HttpStatus status, String defaultMessage) { + this.status = status; + this.defaultMessage = defaultMessage; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String defaultMessage() { + return defaultMessage; + } +} diff --git a/src/main/java/com/fowoco/server/worker/archive/application/port/WorkerArchiveRepository.java b/src/main/java/com/fowoco/server/worker/archive/application/port/WorkerArchiveRepository.java new file mode 100644 index 00000000..b2436fb7 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/application/port/WorkerArchiveRepository.java @@ -0,0 +1,21 @@ +package com.fowoco.server.worker.archive.application.port; + +import com.fowoco.server.worker.archive.application.WorkerArchiveBlocker; +import com.fowoco.server.worker.archive.domain.WorkerArchive; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface WorkerArchiveRepository { + + boolean lockWorker(UUID workerId, UUID companyId); + + Optional find(UUID workerId, UUID companyId); + + List findOperationalBlockers(UUID workerId, UUID companyId, Instant now); + + boolean reserveWorkerVersion(UUID workerId, UUID companyId, long expectedVersion, Instant now); + + void insert(WorkerArchive archive); +} diff --git a/src/main/java/com/fowoco/server/worker/archive/domain/WorkerArchive.java b/src/main/java/com/fowoco/server/worker/archive/domain/WorkerArchive.java new file mode 100644 index 00000000..ce947b93 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/domain/WorkerArchive.java @@ -0,0 +1,14 @@ +package com.fowoco.server.worker.archive.domain; + +import java.time.Instant; +import java.util.UUID; + +public record WorkerArchive( + UUID workerId, + UUID companyId, + Instant archivedAt, + UUID archivedBy, + String archiveReason, + long workerVersion +) { +} diff --git a/src/main/java/com/fowoco/server/worker/archive/infrastructure/persistence/JdbcWorkerArchiveRepository.java b/src/main/java/com/fowoco/server/worker/archive/infrastructure/persistence/JdbcWorkerArchiveRepository.java new file mode 100644 index 00000000..f4c33a1e --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/infrastructure/persistence/JdbcWorkerArchiveRepository.java @@ -0,0 +1,158 @@ +package com.fowoco.server.worker.archive.infrastructure.persistence; + +import com.fowoco.server.worker.archive.application.WorkerArchiveBlocker; +import com.fowoco.server.worker.archive.application.port.WorkerArchiveRepository; +import com.fowoco.server.worker.archive.domain.WorkerArchive; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +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 JdbcWorkerArchiveRepository implements WorkerArchiveRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcWorkerArchiveRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public boolean lockWorker(UUID workerId, UUID companyId) { + return !jdbcTemplate.query( + """ + SELECT worker_id + FROM worker + WHERE worker_id = ? AND company_id = ? + FOR UPDATE + """, + (resultSet, rowNumber) -> resultSet.getObject("worker_id", UUID.class), + workerId, + companyId + ).isEmpty(); + } + + @Override + public Optional find(UUID workerId, UUID companyId) { + return jdbcTemplate.query( + """ + SELECT worker_id, company_id, archived_at, archived_by, + archive_reason, worker_version + FROM worker_archive + WHERE worker_id = ? AND company_id = ? + """, + this::map, + workerId, + companyId + ) + .stream() + .findFirst(); + } + + @Override + public List findOperationalBlockers( + UUID workerId, + UUID companyId, + Instant now + ) { + List blockers = new ArrayList<>(); + if (count( + """ + SELECT COUNT(*) FROM task + WHERE worker_id = ? AND company_id = ? + AND status NOT IN ('COMPLETED', 'CANCELLED') + """, + workerId, + companyId + ) > 0) { + blockers.add(WorkerArchiveBlocker.OPEN_TASK); + } + if (count( + """ + SELECT COUNT(*) + FROM approval_request approval + JOIN task ON task.task_id = approval.task_id AND task.company_id = approval.company_id + WHERE task.worker_id = ? AND task.company_id = ? AND approval.status = 'PENDING' + """, + workerId, + companyId + ) > 0) { + blockers.add(WorkerArchiveBlocker.PENDING_APPROVAL); + } + if (count( + """ + SELECT COUNT(*) + FROM worker_link link + JOIN task ON task.task_id = link.task_id AND task.company_id = link.company_id + WHERE task.worker_id = ? AND task.company_id = ? + AND link.status = 'ACTIVE' AND link.expires_at > ? + """, + workerId, + companyId, + Timestamp.from(now) + ) > 0) { + blockers.add(WorkerArchiveBlocker.ACTIVE_WORKER_LINK); + } + return List.copyOf(blockers); + } + + @Override + public boolean reserveWorkerVersion( + UUID workerId, + UUID companyId, + long expectedVersion, + Instant now + ) { + return jdbcTemplate.update( + """ + UPDATE worker + SET updated_at = ?, version = version + 1 + WHERE worker_id = ? AND company_id = ? AND version = ? + """, + Timestamp.from(now), + workerId, + companyId, + expectedVersion + ) == 1; + } + + @Override + public void insert(WorkerArchive archive) { + jdbcTemplate.update( + """ + INSERT INTO worker_archive ( + worker_id, company_id, archived_at, archived_by, + archive_reason, worker_version + ) VALUES (?, ?, ?, ?, ?, ?) + """, + archive.workerId(), + archive.companyId(), + Timestamp.from(archive.archivedAt()), + archive.archivedBy(), + archive.archiveReason(), + archive.workerVersion() + ); + } + + private long count(String sql, Object... parameters) { + Long value = jdbcTemplate.queryForObject(sql, Long.class, parameters); + return value == null ? 0 : value; + } + + private WorkerArchive map(ResultSet resultSet, int rowNumber) throws SQLException { + return new WorkerArchive( + resultSet.getObject("worker_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + resultSet.getTimestamp("archived_at").toInstant(), + resultSet.getObject("archived_by", UUID.class), + resultSet.getString("archive_reason"), + resultSet.getLong("worker_version") + ); + } +} diff --git a/src/main/java/com/fowoco/server/worker/archive/infrastructure/persistence/WorkerArchiveJpaEntity.java b/src/main/java/com/fowoco/server/worker/archive/infrastructure/persistence/WorkerArchiveJpaEntity.java new file mode 100644 index 00000000..c8cee090 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/archive/infrastructure/persistence/WorkerArchiveJpaEntity.java @@ -0,0 +1,26 @@ +package com.fowoco.server.worker.archive.infrastructure.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "worker_archive") +public class WorkerArchiveJpaEntity { + + @Id + @Column(name = "worker_id", nullable = false, updatable = false) + private UUID workerId; + + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Column(name = "archived_at", nullable = false, updatable = false) + private Instant archivedAt; + + protected WorkerArchiveJpaEntity() { + } +} diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/JdbcWorkerTaskContextReader.java b/src/main/java/com/fowoco/server/worker/infrastructure/JdbcWorkerTaskContextReader.java index 4e5e2beb..05e5bd80 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/JdbcWorkerTaskContextReader.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/JdbcWorkerTaskContextReader.java @@ -20,6 +20,22 @@ public JdbcWorkerTaskContextReader(JdbcTemplate jdbcTemplate) { @Override public Optional findByIdAndCompanyId(UUID workerId, UUID companyId) { + List lockedWorkerIds = jdbcTemplate.query( + """ + SELECT worker_id + FROM worker + WHERE worker_id = ? + AND company_id = ? + FOR UPDATE + """, + (resultSet, rowNumber) -> resultSet.getObject("worker_id", UUID.class), + workerId, + companyId + ); + if (lockedWorkerIds.isEmpty()) { + return Optional.empty(); + } + List rows = jdbcTemplate.query( """ SELECT worker_id, work_status, stay_expiry_date, @@ -27,6 +43,12 @@ public Optional findByIdAndCompanyId(UUID workerId, UUID comp FROM worker WHERE worker_id = ? AND company_id = ? + AND NOT EXISTS ( + SELECT 1 + FROM worker_archive archive + WHERE archive.worker_id = worker.worker_id + AND archive.company_id = worker.company_id + ) """, (resultSet, rowNumber) -> new WorkerTaskContext( resultSet.getObject("worker_id", UUID.class), diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java index 9acfbdb0..179ba029 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java @@ -44,6 +44,12 @@ public List findByDisplayName(UUID companyId, String di from WorkerJpaEntity worker where worker.companyId = :companyId and worker.displayName = :displayName + and not exists ( + select archive.workerId + from WorkerArchiveJpaEntity archive + where archive.workerId = worker.workerId + and archive.companyId = worker.companyId + ) order by worker.workerId """, WorkerJpaEntity.class @@ -69,6 +75,12 @@ public List findByDisplayName(UUID companyId, String di select worker.workerId, worker.displayName from WorkerJpaEntity worker where worker.companyId = :companyId + and not exists ( + select archive.workerId + from WorkerArchiveJpaEntity archive + where archive.workerId = worker.workerId + and archive.companyId = worker.companyId + ) order by worker.workerId """, Object[].class @@ -90,6 +102,12 @@ public List findByDisplayName(UUID companyId, String di from WorkerJpaEntity worker where worker.companyId = :companyId and worker.workerId in :workerIds + and not exists ( + select archive.workerId + from WorkerArchiveJpaEntity archive + where archive.workerId = worker.workerId + and archive.companyId = worker.companyId + ) order by worker.workerId """, WorkerJpaEntity.class diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java index 34fb20d3..24f45a32 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java @@ -102,6 +102,12 @@ public List findAllByWorkerIdsAndCompanyId(Set workerIds, UUID com from WorkerJpaEntity worker where worker.workerId in :workerIds and worker.companyId = :companyId + and not exists ( + select archive.workerId + from WorkerArchiveJpaEntity archive + where archive.workerId = worker.workerId + and archive.companyId = worker.companyId + ) """, WorkerJpaEntity.class ) @@ -114,7 +120,15 @@ public List findAllByWorkerIdsAndCompanyId(Set workerIds, UUID com } private String buildWhereClause(WorkerSearchQuery query) { - StringBuilder where = new StringBuilder(" where worker.companyId = :companyId"); + StringBuilder where = new StringBuilder(""" + where worker.companyId = :companyId + and not exists ( + select archive.workerId + from WorkerArchiveJpaEntity archive + where archive.workerId = worker.workerId + and archive.companyId = worker.companyId + ) + """); if (query.status() != null) { where.append(" and worker.workStatus = :status"); } diff --git a/src/main/resources/db/migration-postgresql/V57__prepare_worker_archive_rls.sql b/src/main/resources/db/migration-postgresql/V57__prepare_worker_archive_rls.sql new file mode 100644 index 00000000..15432973 --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V57__prepare_worker_archive_rls.sql @@ -0,0 +1,12 @@ +CREATE POLICY pl_worker_archive_tenant_isolation + ON public.worker_archive + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); diff --git a/src/main/resources/db/migration/V56__create_worker_archive.sql b/src/main/resources/db/migration/V56__create_worker_archive.sql new file mode 100644 index 00000000..ba9725d5 --- /dev/null +++ b/src/main/resources/db/migration/V56__create_worker_archive.sql @@ -0,0 +1,22 @@ +CREATE TABLE worker_archive ( + worker_id UUID NOT NULL, + company_id UUID NOT NULL, + archived_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + archived_by UUID NOT NULL, + archive_reason VARCHAR(500) NOT NULL, + worker_version BIGINT NOT NULL, + CONSTRAINT pk_worker_archive PRIMARY KEY (worker_id), + CONSTRAINT uq_worker_archive_worker_company UNIQUE (worker_id, company_id), + CONSTRAINT fk_worker_archive_worker_company + FOREIGN KEY (worker_id, company_id) + REFERENCES worker (worker_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_archive_actor_company + FOREIGN KEY (archived_by, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT, + CONSTRAINT ck_worker_archive_reason_not_blank + CHECK (CHAR_LENGTH(TRIM(archive_reason)) > 0), + CONSTRAINT ck_worker_archive_worker_version CHECK (worker_version > 0) +); + +CREATE INDEX idx_worker_archive_company_time + ON worker_archive (company_id, archived_at DESC, worker_id); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 06a94c63..0eea7033 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -110,7 +110,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "worker_import_row", "worker_import_commit_idempotency", "document_ocr_run", - "stay_verification_case" + "stay_verification_case", + "worker_archive" ); assertThat(columnSpecs(connection, "company")) @@ -353,6 +354,13 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("corrected_fields_ciphertext", new ColumnSpec("text", true)) .containsEntry("corrected_fields_key_version", new ColumnSpec("varchar", true)) .containsEntry("version", new ColumnSpec("int8", false)); + assertThat(columnSpecs(connection, "worker_archive")) + .containsEntry("worker_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("archived_at", new ColumnSpec("timestamptz", false)) + .containsEntry("archived_by", new ColumnSpec("uuid", false)) + .containsEntry("archive_reason", new ColumnSpec("varchar", false)) + .containsEntry("worker_version", new ColumnSpec("int8", false)); assertThat(constraintNames(connection)) .contains( @@ -461,7 +469,13 @@ private void assertSchemaContract(Connection connection) throws SQLException { "fk_document_ocr_run_file_company", "fk_document_ocr_run_requester_company", "fk_worker_document_archived_by_company", - "ck_worker_document_archive_metadata" + "ck_worker_document_archive_metadata", + "pk_worker_archive", + "uq_worker_archive_worker_company", + "fk_worker_archive_worker_company", + "fk_worker_archive_actor_company", + "ck_worker_archive_reason_not_blank", + "ck_worker_archive_worker_version" ); assertThat(indexNames(connection)) .contains( @@ -501,7 +515,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "idx_worker_import_job_company_updated", "idx_worker_import_row_job_status", "idx_document_ocr_run_document_created", - "idx_document_ocr_run_company_status" + "idx_document_ocr_run_company_status", + "idx_worker_archive_company_time" ); assertThat(policyNames(connection)) .containsExactlyInAnyOrder( @@ -543,7 +558,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_worker_import_commit_idempotency_tenant_isolation", "pl_document_ocr_run_tenant_isolation", "pl_notification_tenant_isolation", - "pl_stay_verification_tenant_isolation" + "pl_stay_verification_tenant_isolation", + "pl_worker_archive_tenant_isolation" ); assertThat(rlsEnabledTables(connection)).isEmpty(); assertThat(securityDefinerFunctionNames(connection)) diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpEnvironment.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpEnvironment.java index b3f583c2..3dd60c97 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpEnvironment.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpEnvironment.java @@ -26,6 +26,7 @@ final class PostgreSqlRestrictedRoleHttpEnvironment implements AutoCloseable { Map.entry("user_account", "SELECT"), Map.entry("refresh_token", "SELECT, INSERT, UPDATE"), Map.entry("worker", "SELECT, INSERT, UPDATE"), + Map.entry("worker_archive", "SELECT, INSERT"), Map.entry("task", "SELECT"), Map.entry("worker_link", "SELECT"), Map.entry("worker_response", "SELECT, INSERT"), diff --git a/src/test/java/com/fowoco/server/worker/WorkerArchiveIntegrationTest.java b/src/test/java/com/fowoco/server/worker/WorkerArchiveIntegrationTest.java new file mode 100644 index 00000000..cbb7826b --- /dev/null +++ b/src/test/java/com/fowoco/server/worker/WorkerArchiveIntegrationTest.java @@ -0,0 +1,363 @@ +package com.fowoco.server.worker; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.worker.application.port.WorkerAiContextReader; +import com.fowoco.server.worker.application.port.WorkerTaskContextReader; +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class WorkerArchiveIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("68000000-0000-0000-0000-000000000001"); + private static final UUID HR_A = UUID.fromString("68100000-0000-0000-0000-000000000001"); + private static final UUID VIEWER_A = UUID.fromString("68100000-0000-0000-0000-000000000002"); + private static final String HR_EMAIL = "hr.archive@example.com"; + private static final String VIEWER_EMAIL = "viewer.archive@example.com"; + private static final String PASSWORD = "Test-password-1!"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private WorkerAiContextReader aiContextReader; + + @Autowired + private WorkerTaskContextReader taskContextReader; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeAll + void seedActor() { + cleanupData(); + jdbcTemplate.update( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, '보관 테스트 사업장', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + COMPANY_A + ); + insertUser(HR_A, HR_EMAIL, "HR"); + insertUser(VIEWER_A, VIEWER_EMAIL, "VIEWER"); + } + + @BeforeEach + void resetWorkers() { + cleanupOperationalData(); + } + + @AfterEach + void removeArchivesForOtherTestContexts() { + cleanupOperationalData(); + } + + @Test + void activeWorkerCannotBeArchived() throws Exception { + String token = accessToken(login(HR_EMAIL)); + String workerId = registerWorker(token, "재직 중 근로자"); + + HttpResponse eligibility = get( + "/api/v1/workers/" + workerId + "/archive-eligibility", token); + HttpResponse archive = post( + "/api/v1/workers/" + workerId + "/archive", + "{\"reason\":\"정리\",\"expected_version\":0}", + token + ); + + assertThat(eligibility.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(eligibility.body(), "$.archivable")).isFalse(); + assertThat(JsonPath.>read(eligibility.body(), "$.blockers")) + .contains("ACTIVE_EMPLOYMENT_STATUS"); + assertThat(archive.statusCode()).isEqualTo(409); + assertThat(JsonPath.read(archive.body(), "$.code")) + .isEqualTo("WORKER_ARCHIVE_NOT_ALLOWED"); + } + + @Test + void resignedWorkerIsArchivedAndRemovedFromOperationalContexts() throws Exception { + String token = accessToken(login(HR_EMAIL)); + String workerId = registerWorker(token, "보관 대상 근로자"); + assertThat(patch( + "/api/v1/workers/" + workerId, + "{\"work_status\":\"RESIGNED\",\"expected_version\":0}", + token + ).statusCode()).isEqualTo(200); + + HttpResponse archive = post( + "/api/v1/workers/" + workerId + "/archive", + "{\"reason\":\"퇴사 후 행정업무 종료\",\"expected_version\":1}", + token + ); + + assertThat(archive.statusCode()).isEqualTo(201); + assertThat(JsonPath.read(archive.body(), "$.worker_version").longValue()) + .isEqualTo(2); + assertThat(get("/api/v1/workers", token).body()).doesNotContain("보관 대상 근로자"); + assertThat(get("/api/v1/workers/" + workerId, token).statusCode()).isEqualTo(200); + assertThat(aiContextReader.findByDisplayName(COMPANY_A, "보관 대상 근로자")).isEmpty(); + assertThat(taskContextReader.findByIdAndCompanyId(UUID.fromString(workerId), COMPANY_A)) + .isEmpty(); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE target_id = ? AND action = 'WORKER_ARCHIVED'", + Integer.class, + UUID.fromString(workerId) + )).isEqualTo(1); + } + + @Test + void openTaskBlocksArchiveUntilOperationalWorkIsClosed() throws Exception { + String token = accessToken(login(HR_EMAIL)); + String workerId = registerWorker(token, "업무 남은 근로자"); + patch( + "/api/v1/workers/" + workerId, + "{\"work_status\":\"TERMINATED\",\"expected_version\":0}", + token + ); + insertOpenTask(UUID.fromString(workerId)); + + HttpResponse response = get( + "/api/v1/workers/" + workerId + "/archive-eligibility", token); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.blockers")) + .contains("OPEN_TASK"); + } + + @Test + void pendingApprovalAndActiveWorkerLinkAreReportedAsBlockers() throws Exception { + String token = accessToken(login(HR_EMAIL)); + String workerId = registerWorker(token, "승인·링크 남은 근로자"); + patch( + "/api/v1/workers/" + workerId, + "{\"work_status\":\"RESIGNED\",\"expected_version\":0}", + token + ); + UUID taskId = insertOpenTask(UUID.fromString(workerId)); + insertPendingApproval(taskId); + insertActiveWorkerLink(taskId); + + HttpResponse response = get( + "/api/v1/workers/" + workerId + "/archive-eligibility", token); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.blockers")) + .contains("OPEN_TASK", "PENDING_APPROVAL", "ACTIVE_WORKER_LINK"); + } + + @Test + void staleVersionAndViewerWriteAreRejected() throws Exception { + String hrToken = accessToken(login(HR_EMAIL)); + String viewerToken = accessToken(login(VIEWER_EMAIL)); + String workerId = registerWorker(hrToken, "권한 테스트 근로자"); + patch( + "/api/v1/workers/" + workerId, + "{\"work_status\":\"RESIGNED\",\"expected_version\":0}", + hrToken + ); + + HttpResponse stale = post( + "/api/v1/workers/" + workerId + "/archive", + "{\"reason\":\"퇴사\",\"expected_version\":0}", + hrToken + ); + HttpResponse forbidden = post( + "/api/v1/workers/" + workerId + "/archive", + "{\"reason\":\"퇴사\",\"expected_version\":1}", + viewerToken + ); + + assertThat(stale.statusCode()).isEqualTo(409); + assertThat(JsonPath.read(stale.body(), "$.code")) + .isEqualTo("WORKER_ARCHIVE_VERSION_CONFLICT"); + assertThat(forbidden.statusCode()).isEqualTo(403); + } + + private String registerWorker(String token, String displayName) throws Exception { + HttpResponse response = post( + "/api/v1/workers", + "{\"display_name\":\"%s\"}".formatted(displayName), + token + ); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_id"); + } + + private UUID insertOpenTask(UUID workerId) { + UUID taskId = UUID.randomUUID(); + UUID caseId = UUID.randomUUID(); + jdbcTemplate.update( + """ + INSERT INTO task ( + task_id, company_id, worker_id, case_id, task_type, + workflow_id, workflow_catalog_version, title, description, + business_data_json, critical_fingerprint, content_revision, + source, status, created_by, updated_by, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, 'DOCUMENT_REQUEST', + 'WF-DOC-001', '0.3.1', '남은 업무', '완료 전 업무', + '{}', ?, 0, 'MANUAL', 'DRAFT', ?, ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + taskId, + COMPANY_A, + workerId, + caseId, + "f".repeat(64), + HR_A, + HR_A + ); + return taskId; + } + + private void insertPendingApproval(UUID taskId) { + jdbcTemplate.update( + """ + INSERT INTO approval_request ( + approval_request_id, task_id, company_id, target_task_version, + target_content_revision, target_fingerprint, status, + hr_snapshot_json, changed_fields_json, source_versions_json, + requested_by, requested_at, created_at, updated_at, version + ) VALUES (?, ?, ?, 0, 0, ?, 'PENDING', '{}', '[]', '{}', ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + UUID.randomUUID(), + taskId, + COMPANY_A, + "f".repeat(64), + HR_A + ); + } + + private void insertActiveWorkerLink(UUID taskId) { + jdbcTemplate.update( + """ + INSERT INTO worker_link ( + worker_link_id, task_id, company_id, token_hash, expires_at, + status, conversation_status, issued_by, idempotency_key, + created_at, updated_at, version + ) VALUES (?, ?, ?, ?, DATEADD('DAY', 1, CURRENT_TIMESTAMP), 'ACTIVE', + 'WAITING_WORKER', ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + UUID.randomUUID(), + taskId, + COMPANY_A, + "a".repeat(64), + HR_A, + "archive-test-" + UUID.randomUUID() + ); + } + + private void insertUser(UUID userId, String email, 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, + COMPANY_A, + email, + email, + passwordEncoder.encode(PASSWORD), + role + ); + } + + private void cleanupOperationalData() { + jdbcTemplate.update("DELETE FROM worker_archive"); + jdbcTemplate.update("DELETE FROM audit_event WHERE company_id = ?", COMPANY_A); + jdbcTemplate.update("DELETE FROM approval_request WHERE company_id = ?", COMPANY_A); + jdbcTemplate.update("DELETE FROM worker_link WHERE company_id = ?", COMPANY_A); + jdbcTemplate.update("DELETE FROM task WHERE company_id = ?", COMPANY_A); + jdbcTemplate.update("DELETE FROM worker_document WHERE company_id = ?", COMPANY_A); + jdbcTemplate.update("DELETE FROM worker WHERE company_id = ?", COMPANY_A); + } + + private void cleanupData() { + jdbcTemplate.update("DELETE FROM worker_archive"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM task_evidence"); + jdbcTemplate.update("DELETE FROM external_submission"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task_transition_history"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM worker_link"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM worker_document"); + jdbcTemplate.update("DELETE FROM worker"); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + } + + private HttpResponse login(String email) throws Exception { + return post( + "/api/v1/auth/login", + "{\"email\":\"%s\",\"password\":\"%s\"}".formatted(email, PASSWORD), + null + ); + } + + private String accessToken(HttpResponse response) { + assertThat(response.statusCode()).isEqualTo(200); + return JsonPath.read(response.body(), "$.access_token"); + } + + private HttpResponse get(String path, String token) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .GET() + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse post(String path, String body, String token) throws Exception { + return send(path, body, token, "POST"); + } + + private HttpResponse patch(String path, String body, String token) throws Exception { + return send(path, body, token, "PATCH"); + } + + private HttpResponse send(String path, String body, String token, String method) + throws Exception { + HttpRequest.Builder request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(body)); + if (token != null) { + request.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + return httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString()); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } +} diff --git a/src/test/java/com/fowoco/server/worker/infrastructure/persistence/PostgreSqlWorkerRestrictedRuntimeFixture.java b/src/test/java/com/fowoco/server/worker/infrastructure/persistence/PostgreSqlWorkerRestrictedRuntimeFixture.java index 4a9410ed..8f04737c 100644 --- a/src/test/java/com/fowoco/server/worker/infrastructure/persistence/PostgreSqlWorkerRestrictedRuntimeFixture.java +++ b/src/test/java/com/fowoco/server/worker/infrastructure/persistence/PostgreSqlWorkerRestrictedRuntimeFixture.java @@ -186,6 +186,7 @@ private void createRestrictedRuntimeRole() throws SQLException { + "ON TABLE public.worker TO " + quotedRole ); + statement.execute("GRANT SELECT ON TABLE public.worker_archive TO " + quotedRole); } }