diff --git a/.gitignore b/.gitignore index 344ef7c..0607470 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ out/ .env .env.* !.env.example + +### Local file storage ### +/data/ +*.log 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 7ccfcb1..b9001d0 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -11,5 +11,8 @@ public enum AuditAction { APPROVAL_INVALIDATED, EXTERNAL_SUBMISSION_RECORDED, EVIDENCE_RECORDED, - TASK_COMPLETED + TASK_COMPLETED, + FILE_UPLOADED, + WORKER_DOCUMENT_FILE_LINKED, + DOCUMENT_REQUEST_DRAFT_SAVED } 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 b8ad1db..bd21915 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java @@ -4,5 +4,8 @@ public enum AuditTargetType { TASK, APPROVAL_REQUEST, EXTERNAL_SUBMISSION, - EVIDENCE + EVIDENCE, + FILE, + WORKER_DOCUMENT, + DOCUMENT_REQUEST_DRAFT } diff --git a/src/main/java/com/fowoco/server/document/api/DocumentController.java b/src/main/java/com/fowoco/server/document/api/DocumentController.java new file mode 100644 index 0000000..fd308ee --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentController.java @@ -0,0 +1,96 @@ +package com.fowoco.server.document.api; + +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.document.application.DocumentPageResult; +import com.fowoco.server.document.application.DocumentService; +import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Document", description = "사업장 통합 문서함 조회") +@RestController +@RequestMapping("/api/v1/documents") +@SecurityRequirement(name = "bearerAuth") +@Validated +public class DocumentController { + + private final DocumentService documentService; + private final ActorContextProvider actorContextProvider; + + public DocumentController( + DocumentService documentService, + ActorContextProvider actorContextProvider + ) { + this.documentService = documentService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "listDocuments", + summary = "통합 문서함 조회", + description = "근로자·업무·문서 유형·상태로 사업장 전체 서류를 검색합니다. " + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DocumentPageResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden") + }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public DocumentPageResponse list( + @Parameter(description = "근로자 ID 필터") @RequestParam(required = false) UUID workerId, + @Parameter(description = "서류 유형 필터") @RequestParam(required = false) DocumentType documentType, + @Parameter(description = "제출 상태 필터") @RequestParam(required = false) SubmissionStatus status, + @Parameter(description = "이 날짜 이전 만료 필터") @RequestParam(required = false) LocalDate expiryBefore, + @Parameter(description = "페이지 번호 (0부터 시작)") + @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "페이지당 항목 수 (1~100)") + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + WorkerDocumentSearchQuery query = new WorkerDocumentSearchQuery( + workerId, + documentType, + status, + expiryBefore, + page, + size + ); + DocumentPageResult result = documentService.findPage(companyId, query); + List items = result.items().stream() + .map(document -> DocumentItemResponse.from( + document, + result.workerDisplayNames().get(document.workerId()) + )) + .toList(); + return new DocumentPageResponse(items, result.page(), result.size(), result.totalElements()); + } +} diff --git a/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java new file mode 100644 index 0000000..c8cb486 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java @@ -0,0 +1,103 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import com.fowoco.server.worker.domain.WorkerDocument; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.UUID; + +@Schema(name = "DocumentItemResponse", description = "통합 문서함의 서류 항목 (근로자 표시 정보 포함)") +public final class DocumentItemResponse { + + @JsonProperty("worker_document_id") + @Schema(name = "worker_document_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID workerDocumentId; + + @JsonProperty("worker_id") + @Schema(name = "worker_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID workerId; + + @JsonProperty("display_name") + @Schema( + name = "display_name", + description = "근로자 화면 표시 이름. 근로자가 조회 시점에 삭제된 경우 null.", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private final String displayName; + + @JsonProperty("document_type") + @Schema(name = "document_type", requiredMode = Schema.RequiredMode.REQUIRED) + private final DocumentType documentType; + + @JsonProperty("submission_status") + @Schema(name = "submission_status", requiredMode = Schema.RequiredMode.REQUIRED) + private final SubmissionStatus submissionStatus; + + @JsonProperty("expiry_date") + @Schema(name = "expiry_date", format = "date") + private final LocalDate expiryDate; + + @JsonProperty("file_id") + @Schema(name = "file_id", format = "uuid") + private final UUID fileId; + + private DocumentItemResponse( + UUID workerDocumentId, + UUID workerId, + String displayName, + DocumentType documentType, + SubmissionStatus submissionStatus, + LocalDate expiryDate, + UUID fileId + ) { + this.workerDocumentId = workerDocumentId; + this.workerId = workerId; + this.displayName = displayName; + this.documentType = documentType; + this.submissionStatus = submissionStatus; + this.expiryDate = expiryDate; + this.fileId = fileId; + } + + public static DocumentItemResponse from(WorkerDocument document, String displayName) { + return new DocumentItemResponse( + document.workerDocumentId(), + document.workerId(), + displayName, + document.documentType(), + document.submissionStatus(), + document.expiryDate(), + document.fileId() + ); + } + + public UUID getWorkerDocumentId() { + return workerDocumentId; + } + + public UUID getWorkerId() { + return workerId; + } + + public String getWorkerDisplayName() { + return displayName; + } + + public DocumentType getDocumentType() { + return documentType; + } + + public SubmissionStatus getSubmissionStatus() { + return submissionStatus; + } + + public LocalDate getExpiryDate() { + return expiryDate; + } + + public UUID getFileId() { + return fileId; + } +} diff --git a/src/main/java/com/fowoco/server/document/api/DocumentPageResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentPageResponse.java new file mode 100644 index 0000000..f70e00a --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentPageResponse.java @@ -0,0 +1,48 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(name = "DocumentPageResponse", description = "사업장 통합 문서함 목록 페이지 응답") +public final class DocumentPageResponse { + + @JsonProperty("items") + @Schema(description = "이번 페이지의 서류 목록", requiredMode = Schema.RequiredMode.REQUIRED) + private final List items; + + @JsonProperty("page") + @Schema(description = "현재 페이지 번호 (0부터 시작)", example = "0", requiredMode = Schema.RequiredMode.REQUIRED) + private final int page; + + @JsonProperty("size") + @Schema(description = "페이지당 항목 수", example = "20", requiredMode = Schema.RequiredMode.REQUIRED) + private final int size; + + @JsonProperty("total_elements") + @Schema(name = "total_elements", description = "전체 서류 수", example = "42", requiredMode = Schema.RequiredMode.REQUIRED) + private final long totalElements; + + public DocumentPageResponse(List items, int page, int size, long totalElements) { + this.items = items; + this.page = page; + this.size = size; + this.totalElements = totalElements; + } + + public List getItems() { + return items; + } + + public int getPage() { + return page; + } + + public int getSize() { + return size; + } + + public long getTotalElements() { + return totalElements; + } +} diff --git a/src/main/java/com/fowoco/server/document/api/DocumentReadinessController.java b/src/main/java/com/fowoco/server/document/api/DocumentReadinessController.java new file mode 100644 index 0000000..718b125 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentReadinessController.java @@ -0,0 +1,73 @@ +package com.fowoco.server.document.api; + +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.document.application.DocumentReadinessResult; +import com.fowoco.server.document.application.DocumentReadinessService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Document", description = "사업장 통합 문서함 조회") +@RestController +@RequestMapping("/api/v1/tasks/{taskId}/document-readiness") +@SecurityRequirement(name = "bearerAuth") +public class DocumentReadinessController { + + private final DocumentReadinessService documentReadinessService; + private final ActorContextProvider actorContextProvider; + + public DocumentReadinessController( + DocumentReadinessService documentReadinessService, + ActorContextProvider actorContextProvider + ) { + this.documentReadinessService = documentReadinessService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "getDocumentReadiness", + summary = "업무별 서류 준비도 확인", + description = "Task 생성 시 고정된 체크리스트 snapshot을 기준으로 현재 업무에 필요한 " + + "서류가 준비됐는지 확인합니다. Workflow Catalog를 실시간으로 다시 읽지 않습니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DocumentReadinessResponse.class) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound") + }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public DocumentReadinessResponse get( + @Parameter(description = "업무 ID") @PathVariable UUID taskId + ) { + UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + DocumentReadinessResult result = documentReadinessService.calculate(taskId, companyId); + return new DocumentReadinessResponse( + result.required(), + result.available(), + result.missing(), + result.expired(), + result.completionBlocked() + ); + } +} diff --git a/src/main/java/com/fowoco/server/document/api/DocumentReadinessResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentReadinessResponse.java new file mode 100644 index 0000000..a3ce3a1 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentReadinessResponse.java @@ -0,0 +1,68 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.worker.domain.DocumentType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(name = "DocumentReadinessResponse", description = "업무별 서류 준비도") +public final class DocumentReadinessResponse { + + @JsonProperty("required") + @Schema(description = "이 업무에 필요한 전체 서류 종류", requiredMode = Schema.RequiredMode.REQUIRED) + private final List required; + + @JsonProperty("available") + @Schema(description = "보유 중이고 유효한 서류 종류", requiredMode = Schema.RequiredMode.REQUIRED) + private final List available; + + @JsonProperty("missing") + @Schema(description = "아예 등록되지 않은 서류 종류", requiredMode = Schema.RequiredMode.REQUIRED) + private final List missing; + + @JsonProperty("expired") + @Schema(description = "등록됐지만 유효기간이 지난 서류 종류", requiredMode = Schema.RequiredMode.REQUIRED) + private final List expired; + + @JsonProperty("completion_blocked") + @Schema( + name = "completion_blocked", + description = "missing 또는 expired가 하나라도 있으면 true", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private final boolean completionBlocked; + + public DocumentReadinessResponse( + List required, + List available, + List missing, + List expired, + boolean completionBlocked + ) { + this.required = required; + this.available = available; + this.missing = missing; + this.expired = expired; + this.completionBlocked = completionBlocked; + } + + public List getRequired() { + return required; + } + + public List getAvailable() { + return available; + } + + public List getMissing() { + return missing; + } + + public List getExpired() { + return expired; + } + + public boolean isCompletionBlocked() { + return completionBlocked; + } +} diff --git a/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java new file mode 100644 index 0000000..ae89743 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java @@ -0,0 +1,91 @@ +package com.fowoco.server.document.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.document.application.DocumentRequestDraftCommand; +import com.fowoco.server.document.application.DocumentRequestDraftService; +import com.fowoco.server.document.domain.DocumentRequestDraft; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Document", description = "사업장 통합 문서함 조회") +@RestController +@RequestMapping("/api/v1/tasks/{taskId}/document-request-draft") +@SecurityRequirement(name = "bearerAuth") +public class DocumentRequestDraftController { + + private final DocumentRequestDraftService documentRequestDraftService; + private final ActorContextProvider actorContextProvider; + + public DocumentRequestDraftController( + DocumentRequestDraftService documentRequestDraftService, + ActorContextProvider actorContextProvider + ) { + this.documentRequestDraftService = documentRequestDraftService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "upsertDocumentRequestDraft", + summary = "문서 요청 초안 저장", + description = "누락 문서를 근로자에게 요청할 안내문 초안을 만들거나 갱신합니다. " + + "이 저장만으로는 Worker Link 생성이나 메시지 발송이 되지 않습니다. " + + "최초 생성 시 expected_version은 0이어야 합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "저장 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DocumentRequestDraftResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"), + @ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict") + }) + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public DocumentRequestDraftResponse upsert( + @Parameter(description = "업무 ID") @PathVariable UUID taskId, + @Valid @RequestBody DocumentRequestUpsertRequest request, + HttpServletRequest servletRequest + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + UUID companyId = actor.companyId(); + DocumentRequestDraftCommand command = new DocumentRequestDraftCommand( + taskId, + companyId, + request.getLanguage(), + request.getDocumentTypes(), + request.getMessage(), + request.getExpectedVersion() + ); + DocumentRequestDraft draft = documentRequestDraftService.upsert( + command, + actor, + RequestMetadata.from(servletRequest) + ); + return DocumentRequestDraftResponse.from(draft); + } +} diff --git a/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftResponse.java new file mode 100644 index 0000000..ee98c8e --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftResponse.java @@ -0,0 +1,45 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.document.domain.DocumentRequestDraft; +import com.fowoco.server.document.domain.DocumentRequestReviewStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; + +@Schema(name = "DocumentRequestDraftResponse", description = "업무별 문서 요청 초안 저장 결과") +public final class DocumentRequestDraftResponse { + + @JsonProperty("draft_id") + @Schema(name = "draft_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID draftId; + + @JsonProperty("version") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final long version; + + @JsonProperty("review_status") + @Schema(name = "review_status", requiredMode = Schema.RequiredMode.REQUIRED) + private final DocumentRequestReviewStatus reviewStatus; + + private DocumentRequestDraftResponse(UUID draftId, long version, DocumentRequestReviewStatus reviewStatus) { + this.draftId = draftId; + this.version = version; + this.reviewStatus = reviewStatus; + } + + public static DocumentRequestDraftResponse from(DocumentRequestDraft draft) { + return new DocumentRequestDraftResponse(draft.draftId(), draft.version(), draft.reviewStatus()); + } + + public UUID getDraftId() { + return draftId; + } + + public long getVersion() { + return version; + } + + public DocumentRequestReviewStatus getReviewStatus() { + return reviewStatus; + } +} diff --git a/src/main/java/com/fowoco/server/document/api/DocumentRequestUpsertRequest.java b/src/main/java/com/fowoco/server/document/api/DocumentRequestUpsertRequest.java new file mode 100644 index 0000000..ba263cc --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentRequestUpsertRequest.java @@ -0,0 +1,63 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.worker.domain.DocumentType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.List; + +@Schema(name = "DocumentRequestUpsertRequest", description = "업무별 문서 요청 초안 저장·갱신 요청") +public final class DocumentRequestUpsertRequest { + + @Schema(description = "안내 언어", example = "vi", maxLength = 20, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "language를 입력해 주세요.") + @Size(max = 20, message = "language는 20자 이하여야 합니다.") + private final String language; + + @Schema(name = "document_types", description = "요청할 서류 종류 목록", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "document_types는 최소 1개 이상이어야 합니다.") + private final List documentTypes; + + @Schema(description = "근로자에게 보여줄 안내 메시지", maxLength = 1000) + @Size(max = 1000, message = "message는 1000자 이하여야 합니다.") + private final String message; + + @Schema( + name = "expected_version", + description = "최초 생성 시에는 0을 보냅니다.", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private final Long expectedVersion; + + @JsonCreator + public DocumentRequestUpsertRequest( + @JsonProperty("language") String language, + @JsonProperty("document_types") List documentTypes, + @JsonProperty("message") String message, + @JsonProperty("expected_version") Long expectedVersion + ) { + this.language = language; + this.documentTypes = documentTypes; + this.message = message; + this.expectedVersion = expectedVersion; + } + + public String getLanguage() { + return language; + } + + public List getDocumentTypes() { + return documentTypes; + } + + public String getMessage() { + return message; + } + + public Long getExpectedVersion() { + return expectedVersion; + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentPageResult.java b/src/main/java/com/fowoco/server/document/application/DocumentPageResult.java new file mode 100644 index 0000000..124319b --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentPageResult.java @@ -0,0 +1,15 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.worker.domain.WorkerDocument; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record DocumentPageResult( + List items, + Map workerDisplayNames, + int page, + int size, + long totalElements +) { +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentReadinessResult.java b/src/main/java/com/fowoco/server/document/application/DocumentReadinessResult.java new file mode 100644 index 0000000..e96a077 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentReadinessResult.java @@ -0,0 +1,13 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.worker.domain.DocumentType; +import java.util.List; + +public record DocumentReadinessResult( + List required, + List available, + List missing, + List expired, + boolean completionBlocked +) { +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java b/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java new file mode 100644 index 0000000..84c7830 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java @@ -0,0 +1,98 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.document.domain.ChecklistItemDocumentMapper; +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.domain.Task; +import com.fowoco.server.task.domain.TaskChecklistItem; +import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.WorkerDocument; +import java.time.Clock; +import java.time.LocalDate; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DocumentReadinessService { + + private final TaskRepository taskRepository; + private final TaskChecklistRepository taskChecklistRepository; + private final WorkerDocumentRepository workerDocumentRepository; + private final Clock clock; + + public DocumentReadinessService( + TaskRepository taskRepository, + TaskChecklistRepository taskChecklistRepository, + WorkerDocumentRepository workerDocumentRepository, + Clock clock + ) { + this.taskRepository = taskRepository; + this.taskChecklistRepository = taskChecklistRepository; + this.workerDocumentRepository = workerDocumentRepository; + this.clock = clock; + } + + @Transactional(readOnly = true) + public DocumentReadinessResult calculate(UUID taskId, UUID companyId) { + Task task = taskRepository.findByIdAndCompanyId(taskId, companyId) + .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); + + Set requiredTypes = requiredDocumentTypes(taskId, companyId); + + LocalDate today = LocalDate.now(clock); + WorkerDocumentSearchQuery allDocumentsQuery = new WorkerDocumentSearchQuery( + task.workerId(), null, null, null, 0, 100 + ); + List workerDocuments = workerDocumentRepository.findPage(companyId, allDocumentsQuery); + + Set available = EnumSet.noneOf(DocumentType.class); + Set expired = EnumSet.noneOf(DocumentType.class); + for (WorkerDocument document : workerDocuments) { + if (!requiredTypes.contains(document.documentType())) { + continue; + } + boolean isExpired = document.expiryDate() != null && document.expiryDate().isBefore(today); + if (isExpired) { + expired.add(document.documentType()); + } else { + available.add(document.documentType()); + } + } + + Set missing = EnumSet.copyOf(requiredTypes); + missing.removeAll(available); + missing.removeAll(expired); + + boolean completionBlocked = !missing.isEmpty() || !expired.isEmpty(); + + return new DocumentReadinessResult( + List.copyOf(requiredTypes), + List.copyOf(available), + List.copyOf(missing), + List.copyOf(expired), + completionBlocked + ); + } + + private Set requiredDocumentTypes(UUID taskId, UUID companyId) { + List checklistItems = + taskChecklistRepository.findAllByTaskIdAndCompanyId(taskId, companyId); + Set requiredTypes = EnumSet.noneOf(DocumentType.class); + for (TaskChecklistItem item : checklistItems) { + if (!item.required()) { + continue; + } + ChecklistItemDocumentMapper.toDocumentType(item.itemCode()) + .ifPresent(requiredTypes::add); + } + return requiredTypes; + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftCommand.java b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftCommand.java new file mode 100644 index 0000000..d46a5b6 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftCommand.java @@ -0,0 +1,55 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.worker.domain.DocumentType; +import java.util.List; +import java.util.UUID; + +public final class DocumentRequestDraftCommand { + + private final UUID taskId; + private final UUID companyId; + private final String language; + private final List documentTypes; + private final String message; + private final long expectedVersion; + + public DocumentRequestDraftCommand( + UUID taskId, + UUID companyId, + String language, + List documentTypes, + String message, + long expectedVersion + ) { + this.taskId = taskId; + this.companyId = companyId; + this.language = language; + this.documentTypes = documentTypes; + this.message = message; + this.expectedVersion = expectedVersion; + } + + public UUID taskId() { + return taskId; + } + + public UUID companyId() { + return companyId; + } + + public String language() { + return language; + } + + public List documentTypes() { + return documentTypes; + } + + public String message() { + return message; + } + + public long expectedVersion() { + return expectedVersion; + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java new file mode 100644 index 0000000..dcc6fc5 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java @@ -0,0 +1,143 @@ +package com.fowoco.server.document.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.web.RequestMetadata; +import com.fowoco.server.document.application.error.DocumentErrorCode; +import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; +import com.fowoco.server.document.domain.DocumentRequestDraft; +import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.task.application.port.TaskRepository; +import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; +import java.util.Optional; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DocumentRequestDraftService { + + private static final String AUDIT_EVENT_VERSION = "1"; + + private final TaskRepository taskRepository; + private final DocumentRequestDraftRepository documentRequestDraftRepository; + private final AuditEventRepository auditRepository; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public DocumentRequestDraftService( + TaskRepository taskRepository, + DocumentRequestDraftRepository documentRequestDraftRepository, + AuditEventRepository auditRepository, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.taskRepository = taskRepository; + this.documentRequestDraftRepository = documentRequestDraftRepository; + this.auditRepository = auditRepository; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public DocumentRequestDraft upsert( + DocumentRequestDraftCommand command, + ActorContext actor, + RequestMetadata metadata + ) { + taskRepository.findByIdAndCompanyId(command.taskId(), command.companyId()) + .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); + + Optional existing = documentRequestDraftRepository + .findByTaskIdAndCompanyId(command.taskId(), command.companyId()); + + Instant now = clock.instant(); + DocumentRequestDraft saved; + + if (existing.isEmpty()) { + DocumentRequestDraft draft = DocumentRequestDraft.create( + uuidGenerator.generate(), + command.taskId(), + command.companyId(), + command.language(), + command.documentTypes(), + command.message(), + now + ); + documentRequestDraftRepository.insert(draft); + saved = draft; + } else { + DocumentRequestDraft current = existing.get(); + if (current.version() != command.expectedVersion()) { + throw new ApiException(DocumentErrorCode.DOCUMENT_REQUEST_DRAFT_VERSION_CONFLICT); + } + DocumentRequestDraft updated = current.withUpdatedContent( + command.language(), + command.documentTypes(), + command.message(), + now + ); + saved = documentRequestDraftRepository.update(updated); + } + + appendAudit( + actor, + AuditAction.DOCUMENT_REQUEST_DRAFT_SAVED, + AuditTargetType.DOCUMENT_REQUEST_DRAFT, + saved.draftId(), + "문서 요청 초안 저장: task_id=" + command.taskId(), + metadata, + now + ); + + return saved; + } + + private void appendAudit( + ActorContext actor, + AuditAction action, + AuditTargetType targetType, + java.util.UUID targetId, + String summary, + RequestMetadata metadata, + Instant now + ) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + actor.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + action, + targetType, + targetId, + 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/document/application/DocumentService.java b/src/main/java/com/fowoco/server/document/application/DocumentService.java new file mode 100644 index 0000000..9d592c9 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentService.java @@ -0,0 +1,46 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; +import com.fowoco.server.worker.domain.Worker; +import com.fowoco.server.worker.domain.WorkerDocument; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DocumentService { + + private final WorkerDocumentRepository workerDocumentRepository; + private final WorkerRepository workerRepository; + + public DocumentService( + WorkerDocumentRepository workerDocumentRepository, + WorkerRepository workerRepository + ) { + this.workerDocumentRepository = workerDocumentRepository; + this.workerRepository = workerRepository; + } + + @Transactional(readOnly = true) + public DocumentPageResult findPage(UUID companyId, WorkerDocumentSearchQuery query) { + List items = workerDocumentRepository.findPage(companyId, query); + long totalElements = workerDocumentRepository.countPage(companyId, query); + + Set workerIds = items.stream() + .map(WorkerDocument::workerId) + .collect(Collectors.toSet()); + Map workerDisplayNames = workerRepository + .findAllByWorkerIdsAndCompanyId(workerIds, companyId) + .stream() + .collect(Collectors.toMap(Worker::workerId, Worker::displayName)); + + return new DocumentPageResult(items, workerDisplayNames, query.page(), query.size(), totalElements); + } +} diff --git a/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java b/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java new file mode 100644 index 0000000..312f1c8 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java @@ -0,0 +1,34 @@ +package com.fowoco.server.document.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum DocumentErrorCode implements ApiErrorCode { + DOCUMENT_REQUEST_DRAFT_VERSION_CONFLICT( + HttpStatus.CONFLICT, + "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요." + ); + + private final HttpStatus status; + private final String defaultMessage; + + DocumentErrorCode(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/document/application/port/DocumentRequestDraftRepository.java b/src/main/java/com/fowoco/server/document/application/port/DocumentRequestDraftRepository.java new file mode 100644 index 0000000..e8eb96e --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/port/DocumentRequestDraftRepository.java @@ -0,0 +1,14 @@ +package com.fowoco.server.document.application.port; + +import com.fowoco.server.document.domain.DocumentRequestDraft; +import java.util.Optional; +import java.util.UUID; + +public interface DocumentRequestDraftRepository { + + void insert(DocumentRequestDraft draft); + + Optional findByTaskIdAndCompanyId(UUID taskId, UUID companyId); + + DocumentRequestDraft update(DocumentRequestDraft draft); +} diff --git a/src/main/java/com/fowoco/server/document/domain/ChecklistItemDocumentMapper.java b/src/main/java/com/fowoco/server/document/domain/ChecklistItemDocumentMapper.java new file mode 100644 index 0000000..16421e1 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/domain/ChecklistItemDocumentMapper.java @@ -0,0 +1,23 @@ +package com.fowoco.server.document.domain; + +import com.fowoco.server.worker.domain.DocumentType; +import java.util.Map; +import java.util.Optional; + +public final class ChecklistItemDocumentMapper { + + private static final Map ITEM_CODE_TO_DOCUMENT_TYPE = Map.of( + "PASSPORT_COPY_VERIFY_CURRENT", DocumentType.PASSPORT_COPY, + "ALIEN_REGISTRATION_CARD_VERIFY_CURRENT", DocumentType.ARC, + "EMPLOYMENT_CONTRACT_VERIFY_PERIOD", DocumentType.CONTRACT, + "EMPLOYMENT_CONTRACT_USE_CURRENT_STANDARD_FORM", DocumentType.CONTRACT, + "EMPLOYMENT_PERMIT_VERIFY_PERIOD", DocumentType.PERMIT + ); + + private ChecklistItemDocumentMapper() { + } + + public static Optional toDocumentType(String itemCode) { + return Optional.ofNullable(ITEM_CODE_TO_DOCUMENT_TYPE.get(itemCode)); + } +} diff --git a/src/main/java/com/fowoco/server/document/domain/DocumentRequestDraft.java b/src/main/java/com/fowoco/server/document/domain/DocumentRequestDraft.java new file mode 100644 index 0000000..5e086b1 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/domain/DocumentRequestDraft.java @@ -0,0 +1,151 @@ +package com.fowoco.server.document.domain; + +import com.fowoco.server.worker.domain.DocumentType; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public final class DocumentRequestDraft { + + private static final int MAX_LANGUAGE_LENGTH = 20; + private static final int MAX_MESSAGE_LENGTH = 1000; + + private final UUID draftId; + private final UUID taskId; + private final UUID companyId; + private final String language; + private final List documentTypes; + private final String message; + private final DocumentRequestReviewStatus reviewStatus; + private final Instant createdAt; + private final Instant updatedAt; + private final long version; + + public DocumentRequestDraft( + UUID draftId, + UUID taskId, + UUID companyId, + String language, + List documentTypes, + String message, + DocumentRequestReviewStatus reviewStatus, + Instant createdAt, + Instant updatedAt, + long version + ) { + this.draftId = Objects.requireNonNull(draftId, "draftId must not be null"); + this.taskId = Objects.requireNonNull(taskId, "taskId must not be null"); + this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); + this.language = requireBounded(language, MAX_LANGUAGE_LENGTH, "language"); + if (documentTypes == null || documentTypes.isEmpty()) { + throw new IllegalArgumentException("documentTypes must not be empty"); + } + this.documentTypes = List.copyOf(documentTypes); + this.message = message == null ? null : requireBounded(message, MAX_MESSAGE_LENGTH, "message"); + this.reviewStatus = Objects.requireNonNull(reviewStatus, "reviewStatus must not be null"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null"); + this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt must not be null"); + if (updatedAt.isBefore(createdAt)) { + throw new IllegalArgumentException("updatedAt must not be before createdAt"); + } + if (version < 0) { + throw new IllegalArgumentException("version must not be negative"); + } + this.version = version; + } + + public static DocumentRequestDraft create( + UUID draftId, + UUID taskId, + UUID companyId, + String language, + List documentTypes, + String message, + Instant now + ) { + return new DocumentRequestDraft( + draftId, + taskId, + companyId, + language, + documentTypes, + message, + DocumentRequestReviewStatus.DRAFT, + now, + now, + 0L + ); + } + + public DocumentRequestDraft withUpdatedContent( + String language, + List documentTypes, + String message, + Instant now + ) { + return new DocumentRequestDraft( + draftId, + taskId, + companyId, + language, + documentTypes, + message, + reviewStatus, + createdAt, + now, + version + ); + } + + private static String requireBounded(String value, int maxLength, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + String normalized = value.strip(); + if (normalized.length() > maxLength) { + throw new IllegalArgumentException(fieldName + " must not exceed " + maxLength + " characters"); + } + return normalized; + } + + public UUID draftId() { + return draftId; + } + + public UUID taskId() { + return taskId; + } + + public UUID companyId() { + return companyId; + } + + public String language() { + return language; + } + + public List documentTypes() { + return documentTypes; + } + + public String message() { + return message; + } + + public DocumentRequestReviewStatus reviewStatus() { + return reviewStatus; + } + + public Instant createdAt() { + return createdAt; + } + + public Instant updatedAt() { + return updatedAt; + } + + public long version() { + return version; + } +} diff --git a/src/main/java/com/fowoco/server/document/domain/DocumentRequestReviewStatus.java b/src/main/java/com/fowoco/server/document/domain/DocumentRequestReviewStatus.java new file mode 100644 index 0000000..95ef927 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/domain/DocumentRequestReviewStatus.java @@ -0,0 +1,5 @@ +package com.fowoco.server.document.domain; + +public enum DocumentRequestReviewStatus { + DRAFT +} diff --git a/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentRequestDraftJpaEntity.java b/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentRequestDraftJpaEntity.java new file mode 100644 index 0000000..657964d --- /dev/null +++ b/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentRequestDraftJpaEntity.java @@ -0,0 +1,139 @@ +package com.fowoco.server.document.infrastructure.persistence; + +import com.fowoco.server.document.domain.DocumentRequestDraft; +import com.fowoco.server.document.domain.DocumentRequestReviewStatus; +import com.fowoco.server.worker.domain.DocumentType; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.Version; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "document_request_draft") +public class DocumentRequestDraftJpaEntity { + + @Id + @Column(name = "draft_id", nullable = false, updatable = false) + private UUID draftId; + + @Column(name = "task_id", nullable = false, updatable = false) + private UUID taskId; + + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Column(name = "language", nullable = false, length = 20) + private String language; + + @ElementCollection(fetch = jakarta.persistence.FetchType.EAGER) + @CollectionTable( + name = "document_request_draft_type", + joinColumns = @JoinColumn(name = "draft_id") + ) + @Enumerated(EnumType.STRING) + @Column(name = "document_type", nullable = false, length = 40) + private List documentTypes; + + @Column(name = "message", length = 1000) + private String message; + + @Enumerated(EnumType.STRING) + @Column(name = "review_status", nullable = false, length = 20) + private DocumentRequestReviewStatus reviewStatus; + + @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 DocumentRequestDraftJpaEntity() { + } + + private DocumentRequestDraftJpaEntity( + UUID draftId, + UUID taskId, + UUID companyId, + String language, + List documentTypes, + String message, + DocumentRequestReviewStatus reviewStatus, + Instant createdAt, + Instant updatedAt, + long version + ) { + this.draftId = draftId; + this.taskId = taskId; + this.companyId = companyId; + this.language = language; + this.documentTypes = documentTypes; + this.message = message; + this.reviewStatus = reviewStatus; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.version = version; + } + + public static DocumentRequestDraftJpaEntity fromDomain(DocumentRequestDraft draft) { + Objects.requireNonNull(draft, "draft must not be null"); + return new DocumentRequestDraftJpaEntity( + draft.draftId(), + draft.taskId(), + draft.companyId(), + draft.language(), + draft.documentTypes(), + draft.message(), + draft.reviewStatus(), + draft.createdAt(), + draft.updatedAt(), + draft.version() + ); + } + + public DocumentRequestDraft toDomain() { + return new DocumentRequestDraft( + draftId, + taskId, + companyId, + language, + documentTypes, + message, + reviewStatus, + createdAt, + updatedAt, + version + ); + } + + public void applyState(DocumentRequestDraft draft) { + Objects.requireNonNull(draft, "draft must not be null"); + if (!draftId.equals(draft.draftId()) + || !taskId.equals(draft.taskId()) + || !companyId.equals(draft.companyId()) + || !createdAt.equals(draft.createdAt())) { + throw new IllegalArgumentException("immutable document request draft fields must not change"); + } + if (version != draft.version()) { + throw new IllegalArgumentException("document request draft version does not match"); + } + this.language = draft.language(); + this.documentTypes = draft.documentTypes(); + this.message = draft.message(); + this.reviewStatus = draft.reviewStatus(); + this.updatedAt = draft.updatedAt(); + } +} diff --git a/src/main/java/com/fowoco/server/document/infrastructure/persistence/JpaDocumentRequestDraftRepository.java b/src/main/java/com/fowoco/server/document/infrastructure/persistence/JpaDocumentRequestDraftRepository.java new file mode 100644 index 0000000..aed1833 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/infrastructure/persistence/JpaDocumentRequestDraftRepository.java @@ -0,0 +1,61 @@ +package com.fowoco.server.document.infrastructure.persistence; + +import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; +import com.fowoco.server.document.domain.DocumentRequestDraft; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaDocumentRequestDraftRepository implements DocumentRequestDraftRepository { + + private final EntityManager entityManager; + + public JpaDocumentRequestDraftRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public void insert(DocumentRequestDraft draft) { + Objects.requireNonNull(draft, "draft must not be null"); + entityManager.persist(DocumentRequestDraftJpaEntity.fromDomain(draft)); + entityManager.flush(); + } + + @Override + public Optional findByTaskIdAndCompanyId(UUID taskId, UUID companyId) { + Objects.requireNonNull(taskId, "taskId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + return entityManager.createQuery( + """ + select draft + from DocumentRequestDraftJpaEntity draft + where draft.taskId = :taskId + and draft.companyId = :companyId + """, + DocumentRequestDraftJpaEntity.class + ) + .setParameter("taskId", taskId) + .setParameter("companyId", companyId) + .getResultStream() + .findFirst() + .map(DocumentRequestDraftJpaEntity::toDomain); + } + + @Override + public DocumentRequestDraft update(DocumentRequestDraft draft) { + Objects.requireNonNull(draft, "draft must not be null"); + DocumentRequestDraftJpaEntity entity = entityManager.find( + DocumentRequestDraftJpaEntity.class, + draft.draftId() + ); + if (entity == null) { + throw new IllegalStateException("document request draft to update was not found"); + } + entity.applyState(draft); + entityManager.flush(); + return entity.toDomain(); + } +} diff --git a/src/main/java/com/fowoco/server/file/api/FileController.java b/src/main/java/com/fowoco/server/file/api/FileController.java new file mode 100644 index 0000000..e755a53 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/api/FileController.java @@ -0,0 +1,105 @@ +package com.fowoco.server.file.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.error.ErrorCode; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.file.application.FileCreateCommand; +import com.fowoco.server.file.application.FileService; +import com.fowoco.server.file.domain.StoredFile; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "File", description = "공통 파일 업로드") +@RestController +@RequestMapping("/api/v1/files") +@SecurityRequirement(name = "bearerAuth") +public class FileController { + + private final FileService fileService; + private final ActorContextProvider actorContextProvider; + + public FileController(FileService fileService, ActorContextProvider actorContextProvider) { + this.fileService = fileService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "uploadFile", + summary = "파일 업로드", + description = "분석·증빙·근로자 제출에 사용할 파일을 안전하게 저장하고 fileId를 발급합니다. " + + "악성파일 검사 인프라는 아직 없어 scan_status는 항상 NOT_SCANNED로 반환합니다. " + + "허용 크기·형식은 TODO — 확정 기준 없어 상식적인 기본값(20MB, image/jpeg·png·webp, application/pdf) 사용 중." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "업로드 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = FileUploadResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse( + responseCode = "413", + description = "파일 크기 초과. OpenApiConfig에 공통 413 응답이 아직 없어 인라인으로 정의함" + ), + @ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType"), + @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity") + }) + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public ResponseEntity upload( + @Parameter(description = "업로드할 파일") @RequestParam("file") MultipartFile file, + @Parameter(description = "파일 용도") @RequestParam("purpose") String purpose, + @Parameter(description = "연결할 업무 ID") @RequestParam(value = "taskId", required = false) UUID taskId, + @Parameter(description = "연결할 근로자 ID") @RequestParam(value = "workerId", required = false) UUID workerId, + HttpServletRequest servletRequest + ) { + if (file.isEmpty() || file.getOriginalFilename() == null || file.getOriginalFilename().isBlank()) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "업로드할 파일과 파일명이 필요합니다."); + } + + ActorContext actor = actorContextProvider.requireCurrentActor(); + UUID companyId = actor.companyId(); + try { + FileCreateCommand command = new FileCreateCommand( + companyId, + file.getOriginalFilename(), + file.getContentType(), + file.getSize(), + purpose, + taskId, + workerId, + file.getInputStream() + ); + StoredFile storedFile = fileService.upload(command, actor, RequestMetadata.from(servletRequest)); + return ResponseEntity.status(HttpStatus.CREATED).body(FileUploadResponse.from(storedFile)); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read uploaded file", exception); + } + } +} diff --git a/src/main/java/com/fowoco/server/file/api/FileUploadResponse.java b/src/main/java/com/fowoco/server/file/api/FileUploadResponse.java new file mode 100644 index 0000000..bdf89b7 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/api/FileUploadResponse.java @@ -0,0 +1,73 @@ +package com.fowoco.server.file.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.file.domain.ScanStatus; +import com.fowoco.server.file.domain.StoredFile; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; + +@Schema(name = "FileUploadResponse", description = "파일 업로드 응답") +public final class FileUploadResponse { + + @JsonProperty("file_id") + @Schema(name = "file_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID fileId; + + @JsonProperty("name") + @Schema(description = "원본 파일명", requiredMode = Schema.RequiredMode.REQUIRED) + private final String name; + + @JsonProperty("mime_type") + @Schema(name = "mime_type", requiredMode = Schema.RequiredMode.REQUIRED) + private final String mimeType; + + @JsonProperty("size") + @Schema(description = "파일 크기(byte)", requiredMode = Schema.RequiredMode.REQUIRED) + private final long size; + + @JsonProperty("scan_status") + @Schema( + name = "scan_status", + description = "악성파일 검사 상태. 현재는 검사 인프라 미도입으로 항상 NOT_SCANNED.", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private final ScanStatus scanStatus; + + private FileUploadResponse(UUID fileId, String name, String mimeType, long size, ScanStatus scanStatus) { + this.fileId = fileId; + this.name = name; + this.mimeType = mimeType; + this.size = size; + this.scanStatus = scanStatus; + } + + public static FileUploadResponse from(StoredFile storedFile) { + return new FileUploadResponse( + storedFile.storedFileId(), + storedFile.name(), + storedFile.mimeType(), + storedFile.size(), + storedFile.scanStatus() + ); + } + + public UUID getFileId() { + return fileId; + } + + public String getName() { + return name; + } + + public String getMimeType() { + return mimeType; + } + + public long getSize() { + return size; + } + + public ScanStatus getScanStatus() { + return scanStatus; + } +} diff --git a/src/main/java/com/fowoco/server/file/application/FileCreateCommand.java b/src/main/java/com/fowoco/server/file/application/FileCreateCommand.java new file mode 100644 index 0000000..57b5754 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/application/FileCreateCommand.java @@ -0,0 +1,68 @@ +package com.fowoco.server.file.application; + +import java.io.InputStream; +import java.util.UUID; + +public final class FileCreateCommand { + + private final UUID companyId; + private final String name; + private final String mimeType; + private final long size; + private final String purpose; + private final UUID taskId; + private final UUID workerId; + private final InputStream content; + + public FileCreateCommand( + UUID companyId, + String name, + String mimeType, + long size, + String purpose, + UUID taskId, + UUID workerId, + InputStream content + ) { + this.companyId = companyId; + this.name = name; + this.mimeType = mimeType; + this.size = size; + this.purpose = purpose; + this.taskId = taskId; + this.workerId = workerId; + this.content = content; + } + + public UUID companyId() { + return companyId; + } + + public String name() { + return name; + } + + public String mimeType() { + return mimeType; + } + + public long size() { + return size; + } + + public String purpose() { + return purpose; + } + + public UUID taskId() { + return taskId; + } + + public UUID workerId() { + return workerId; + } + + public InputStream content() { + return content; + } +} diff --git a/src/main/java/com/fowoco/server/file/application/FileService.java b/src/main/java/com/fowoco/server/file/application/FileService.java new file mode 100644 index 0000000..61fdfd4 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/application/FileService.java @@ -0,0 +1,161 @@ +package com.fowoco.server.file.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.web.RequestMetadata; +import com.fowoco.server.file.application.error.FileErrorCode; +import com.fowoco.server.file.application.port.FileStorage; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.StoredFile; +import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.worker.application.error.WorkerErrorCode; +import com.fowoco.server.worker.application.port.WorkerRepository; +import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class FileService { + + private static final String AUDIT_EVENT_VERSION = "1"; + + /** + * 확정된 기준 없음. 20으로 시작하고, + * 실제 사용 파일(신분증 사진, 계약서 PDF 등) 확인되면 조정 + */ + private static final long MAX_FILE_SIZE_BYTES = 20L * 1024 * 1024; + private static final Set ALLOWED_MIME_TYPES = Set.of( + "image/jpeg", + "image/png", + "image/webp", + "application/pdf" + ); + + private final StoredFileRepository storedFileRepository; + private final FileStorage fileStorage; + private final TaskRepository taskRepository; + private final WorkerRepository workerRepository; + private final AuditEventRepository auditRepository; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public FileService( + StoredFileRepository storedFileRepository, + FileStorage fileStorage, + TaskRepository taskRepository, + WorkerRepository workerRepository, + AuditEventRepository auditRepository, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.storedFileRepository = storedFileRepository; + this.fileStorage = fileStorage; + this.taskRepository = taskRepository; + this.workerRepository = workerRepository; + this.auditRepository = auditRepository; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public StoredFile upload(FileCreateCommand command, ActorContext actor, RequestMetadata metadata) { + if (command.size() > MAX_FILE_SIZE_BYTES) { + throw new ApiException(FileErrorCode.FILE_TOO_LARGE); + } + if (!ALLOWED_MIME_TYPES.contains(command.mimeType())) { + throw new ApiException(FileErrorCode.UNSUPPORTED_FILE_TYPE); + } + if (command.taskId() != null) { + taskRepository.findByIdAndCompanyId(command.taskId(), command.companyId()) + .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); + } + if (command.workerId() != null) { + workerRepository.findByWorkerIdAndCompanyId(command.workerId(), command.companyId()) + .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND)); + } + + UUID storedFileId = uuidGenerator.generate(); + String storageKey = storedFileId.toString(); + Instant now = clock.instant(); + + StoredFile storedFile = StoredFile.create( + storedFileId, + command.companyId(), + command.name(), + command.mimeType(), + command.size(), + command.purpose(), + command.taskId(), + command.workerId(), + storageKey, + now + ); + + fileStorage.store(storageKey, command.content(), command.size(), command.mimeType()); + storedFileRepository.insert(storedFile); + + appendAudit( + actor, + AuditAction.FILE_UPLOADED, + AuditTargetType.FILE, + storedFileId, + "파일 업로드: " + command.purpose(), + metadata, + now + ); + + return storedFile; + } + + private void appendAudit( + ActorContext actor, + AuditAction action, + AuditTargetType targetType, + UUID targetId, + String summary, + RequestMetadata metadata, + Instant now + ) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + actor.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + action, + targetType, + targetId, + 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/file/application/error/FileErrorCode.java b/src/main/java/com/fowoco/server/file/application/error/FileErrorCode.java new file mode 100644 index 0000000..ff7696e --- /dev/null +++ b/src/main/java/com/fowoco/server/file/application/error/FileErrorCode.java @@ -0,0 +1,33 @@ +package com.fowoco.server.file.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum FileErrorCode implements ApiErrorCode { + FILE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기가 허용 범위를 초과했습니다."), + UNSUPPORTED_FILE_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 파일 형식입니다."), + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "파일을 찾을 수 없습니다."); + + private final HttpStatus status; + private final String defaultMessage; + + FileErrorCode(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/file/application/port/FileStorage.java b/src/main/java/com/fowoco/server/file/application/port/FileStorage.java new file mode 100644 index 0000000..a0348ff --- /dev/null +++ b/src/main/java/com/fowoco/server/file/application/port/FileStorage.java @@ -0,0 +1,8 @@ +package com.fowoco.server.file.application.port; + +import java.io.InputStream; + +public interface FileStorage { + + void store(String storageKey, InputStream content, long size, String mimeType); +} diff --git a/src/main/java/com/fowoco/server/file/application/port/StoredFileRepository.java b/src/main/java/com/fowoco/server/file/application/port/StoredFileRepository.java new file mode 100644 index 0000000..e739091 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/application/port/StoredFileRepository.java @@ -0,0 +1,12 @@ +package com.fowoco.server.file.application.port; + +import com.fowoco.server.file.domain.StoredFile; +import java.util.Optional; +import java.util.UUID; + +public interface StoredFileRepository { + + void insert(StoredFile storedFile); + + Optional findByIdAndCompanyId(UUID storedFileId, UUID companyId); +} diff --git a/src/main/java/com/fowoco/server/file/domain/ScanStatus.java b/src/main/java/com/fowoco/server/file/domain/ScanStatus.java new file mode 100644 index 0000000..e5edd9c --- /dev/null +++ b/src/main/java/com/fowoco/server/file/domain/ScanStatus.java @@ -0,0 +1,10 @@ +package com.fowoco.server.file.domain; + +/** + * 악성파일 검사 상태. + * 지금은 NOT_SCANNED로 고정하고 파일 연결은 허용한다. 실제 검증은 후속 이슈에서 + * 검사 인프라가 구축된 뒤 CLEAN/INFECTED 등의 실제 판정값을 채우게 될 것이다. + */ +public enum ScanStatus { + NOT_SCANNED +} diff --git a/src/main/java/com/fowoco/server/file/domain/StoredFile.java b/src/main/java/com/fowoco/server/file/domain/StoredFile.java new file mode 100644 index 0000000..325829d --- /dev/null +++ b/src/main/java/com/fowoco/server/file/domain/StoredFile.java @@ -0,0 +1,149 @@ +package com.fowoco.server.file.domain; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public final class StoredFile { + + private static final int MAX_NAME_LENGTH = 255; + private static final int MAX_MIME_TYPE_LENGTH = 127; + private static final int MAX_PURPOSE_LENGTH = 60; + + private final UUID storedFileId; + private final UUID companyId; + private final String name; + private final String mimeType; + private final long size; + private final String purpose; + private final UUID taskId; + private final UUID workerId; + private final String storageKey; + private final ScanStatus scanStatus; + private final boolean verified; + private final Instant createdAt; + + public StoredFile( + UUID storedFileId, + UUID companyId, + String name, + String mimeType, + long size, + String purpose, + UUID taskId, + UUID workerId, + String storageKey, + ScanStatus scanStatus, + boolean verified, + Instant createdAt + ) { + this.storedFileId = Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); + this.name = requireBounded(name, MAX_NAME_LENGTH, "name"); + this.mimeType = requireBounded(mimeType, MAX_MIME_TYPE_LENGTH, "mimeType"); + if (size <= 0) { + throw new IllegalArgumentException("size must be positive"); + } + this.size = size; + this.purpose = requireBounded(purpose, MAX_PURPOSE_LENGTH, "purpose"); + this.taskId = taskId; + this.workerId = workerId; + this.storageKey = Objects.requireNonNull(storageKey, "storageKey must not be null"); + this.scanStatus = Objects.requireNonNull(scanStatus, "scanStatus must not be null"); + this.verified = verified; + this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null"); + } + + /** + * 파일 업로드 등록. storageKey는 원본 파일명과 무관하게 서버가 생성한 값을 넘겨받는다 + * scanStatus는 검사 인프라가 없는 지금은 항상 NOT_SCANNED로 고정한다. + * verified는 #7(Worker Link)의 "격리 저장 → 검증 → 검증된 ID만 연결" 흐름을 위한 + * 필드로, #13(HR 내부 업로드)은 검증 절차가 없어 항상 false로 시작한다. + */ + public static StoredFile create( + UUID storedFileId, + UUID companyId, + String name, + String mimeType, + long size, + String purpose, + UUID taskId, + UUID workerId, + String storageKey, + Instant now + ) { + return new StoredFile( + storedFileId, + companyId, + name, + mimeType, + size, + purpose, + taskId, + workerId, + storageKey, + ScanStatus.NOT_SCANNED, + false, + now + ); + } + + private static String requireBounded(String value, int maxLength, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + String normalized = value.strip(); + if (normalized.length() > maxLength) { + throw new IllegalArgumentException(fieldName + " must not exceed " + maxLength + " characters"); + } + return normalized; + } + + public UUID storedFileId() { + return storedFileId; + } + + public UUID companyId() { + return companyId; + } + + public String name() { + return name; + } + + public String mimeType() { + return mimeType; + } + + public long size() { + return size; + } + + public String purpose() { + return purpose; + } + + public UUID taskId() { + return taskId; + } + + public UUID workerId() { + return workerId; + } + + public String storageKey() { + return storageKey; + } + + public ScanStatus scanStatus() { + return scanStatus; + } + + public boolean verified() { + return verified; + } + + public Instant createdAt() { + return createdAt; + } +} diff --git a/src/main/java/com/fowoco/server/file/infrastructure/LocalFileStorage.java b/src/main/java/com/fowoco/server/file/infrastructure/LocalFileStorage.java new file mode 100644 index 0000000..a485393 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/infrastructure/LocalFileStorage.java @@ -0,0 +1,34 @@ +package com.fowoco.server.file.infrastructure; + +import com.fowoco.server.file.application.port.FileStorage; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class LocalFileStorage implements FileStorage { + + private final Path rootDirectory; + + public LocalFileStorage(@Value("${app.file-storage.local-path}") String localPath) { + this.rootDirectory = Path.of(localPath).normalize(); + } + + @Override + public void store(String storageKey, InputStream content, long size, String mimeType) { + try { + Files.createDirectories(rootDirectory); + Path target = rootDirectory.resolve(storageKey).normalize(); + if (!target.startsWith(rootDirectory)) { + throw new IllegalArgumentException("storageKey must not escape the storage root"); + } + Files.copy(content, target); + } catch (IOException exception) { + throw new UncheckedIOException("failed to store file: " + storageKey, exception); + } + } +} diff --git a/src/main/java/com/fowoco/server/file/infrastructure/persistence/JpaStoredFileRepository.java b/src/main/java/com/fowoco/server/file/infrastructure/persistence/JpaStoredFileRepository.java new file mode 100644 index 0000000..c53f566 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/infrastructure/persistence/JpaStoredFileRepository.java @@ -0,0 +1,46 @@ +package com.fowoco.server.file.infrastructure.persistence; + +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.StoredFile; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaStoredFileRepository implements StoredFileRepository { + + private final EntityManager entityManager; + + public JpaStoredFileRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public void insert(StoredFile storedFile) { + Objects.requireNonNull(storedFile, "storedFile must not be null"); + entityManager.persist(StoredFileJpaEntity.fromDomain(storedFile)); + entityManager.flush(); + } + + @Override + public Optional findByIdAndCompanyId(UUID storedFileId, UUID companyId) { + Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + return entityManager.createQuery( + """ + select storedFile + from StoredFileJpaEntity storedFile + where storedFile.storedFileId = :storedFileId + and storedFile.companyId = :companyId + """, + StoredFileJpaEntity.class + ) + .setParameter("storedFileId", storedFileId) + .setParameter("companyId", companyId) + .getResultStream() + .findFirst() + .map(StoredFileJpaEntity::toDomain); + } +} diff --git a/src/main/java/com/fowoco/server/file/infrastructure/persistence/StoredFileJpaEntity.java b/src/main/java/com/fowoco/server/file/infrastructure/persistence/StoredFileJpaEntity.java new file mode 100644 index 0000000..7385595 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/infrastructure/persistence/StoredFileJpaEntity.java @@ -0,0 +1,122 @@ +package com.fowoco.server.file.infrastructure.persistence; + +import com.fowoco.server.file.domain.ScanStatus; +import com.fowoco.server.file.domain.StoredFile; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "stored_file") +public class StoredFileJpaEntity { + + @Id + @Column(name = "stored_file_id", nullable = false, updatable = false) + private UUID storedFileId; + + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Column(name = "name", nullable = false, length = 255) + private String name; + + @Column(name = "mime_type", nullable = false, length = 127) + private String mimeType; + + @Column(name = "size", nullable = false) + private long size; + + @Column(name = "purpose", nullable = false, length = 60) + private String purpose; + + @Column(name = "task_id") + private UUID taskId; + + @Column(name = "worker_id") + private UUID workerId; + + @Column(name = "storage_key", nullable = false, updatable = false, length = 255) + private String storageKey; + + @Enumerated(EnumType.STRING) + @Column(name = "scan_status", nullable = false, length = 20) + private ScanStatus scanStatus; + + @Column(name = "verified", nullable = false) + private boolean verified; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected StoredFileJpaEntity() { + } + + private StoredFileJpaEntity( + UUID storedFileId, + UUID companyId, + String name, + String mimeType, + long size, + String purpose, + UUID taskId, + UUID workerId, + String storageKey, + ScanStatus scanStatus, + boolean verified, + Instant createdAt + ) { + this.storedFileId = storedFileId; + this.companyId = companyId; + this.name = name; + this.mimeType = mimeType; + this.size = size; + this.purpose = purpose; + this.taskId = taskId; + this.workerId = workerId; + this.storageKey = storageKey; + this.scanStatus = scanStatus; + this.verified = verified; + this.createdAt = createdAt; + } + + public static StoredFileJpaEntity fromDomain(StoredFile storedFile) { + Objects.requireNonNull(storedFile, "storedFile must not be null"); + return new StoredFileJpaEntity( + storedFile.storedFileId(), + storedFile.companyId(), + storedFile.name(), + storedFile.mimeType(), + storedFile.size(), + storedFile.purpose(), + storedFile.taskId(), + storedFile.workerId(), + storedFile.storageKey(), + storedFile.scanStatus(), + storedFile.verified(), + storedFile.createdAt() + ); + } + + public StoredFile toDomain() { + return new StoredFile( + storedFileId, + companyId, + name, + mimeType, + size, + purpose, + taskId, + workerId, + storageKey, + scanStatus, + verified, + createdAt + ); + } +} diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java index 1932b38..006dca6 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java @@ -1,6 +1,8 @@ package com.fowoco.server.worker.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.application.WorkerDocumentCreateCommand; import com.fowoco.server.worker.application.WorkerDocumentPatchCommand; import com.fowoco.server.worker.application.WorkerDocumentService; @@ -13,6 +15,7 @@ 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 java.util.UUID; import org.springframework.http.HttpStatus; @@ -85,8 +88,8 @@ public ResponseEntity register( @Operation( operationId = "patchWorkerDocument", summary = "서류 상태 수정", - description = "서류 제출·검증·만료 상태와 유효기간을 수정합니다. " - + "expected_version이 현재 값과 다르면 409로 응답합니다." + description = "서류 제출·검증·만료 상태와 유효기간을 수정하고, 업로드된 파일을 연결합니다. " + + "expected_version이 현재 값과 다르면 409, file_id가 존재하지 않으면 404로 응답합니다." ) @ApiResponses({ @ApiResponse( @@ -120,9 +123,11 @@ public ResponseEntity register( public WorkerDocumentResponse patch( @Parameter(description = "근로자 ID") @PathVariable UUID workerId, @Parameter(description = "서류 ID") @PathVariable UUID documentId, - @Valid @RequestBody WorkerDocumentPatchRequest request + @Valid @RequestBody WorkerDocumentPatchRequest request, + HttpServletRequest servletRequest ) { - UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + ActorContext actor = actorContextProvider.requireCurrentActor(); + UUID companyId = actor.companyId(); WorkerDocumentPatchCommand command = new WorkerDocumentPatchCommand( documentId, workerId, @@ -132,9 +137,14 @@ public WorkerDocumentResponse patch( request.getExpiryDate(), request.getDestination(), request.getNote(), + request.getFileId(), request.getExpectedVersion() ); - WorkerDocument document = workerDocumentService.patch(command); + WorkerDocument document = workerDocumentService.patch( + command, + actor, + RequestMetadata.from(servletRequest) + ); return WorkerDocumentResponse.from(document); } } diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentPatchRequest.java b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentPatchRequest.java index 5e6fffd..36778e9 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentPatchRequest.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentPatchRequest.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.LocalDate; +import java.util.UUID; @Schema( name = "WorkerDocumentPatchRequest", @@ -32,6 +33,14 @@ public final class WorkerDocumentPatchRequest { @Size(max = 500, message = "note는 500자 이하여야 합니다.") private final String note; + @Schema( + name = "file_id", + description = "연결할 파일 ID (#13 POST /files 응답의 file_id). " + + "생략 시 변경하지 않습니다. 존재하지 않거나 다른 사업장 소속이면 404로 거부합니다.", + format = "uuid" + ) + private final UUID fileId; + @Schema( name = "expected_version", description = "낙관적 잠금 버전. 마지막으로 조회한 WorkerDocumentResponse.version을 그대로 보내야 합니다.", @@ -47,6 +56,7 @@ public WorkerDocumentPatchRequest( @JsonProperty("expiry_date") LocalDate expiryDate, @JsonProperty("destination") String destination, @JsonProperty("note") String note, + @JsonProperty("file_id") UUID fileId, @JsonProperty("expected_version") Long expectedVersion ) { this.documentType = documentType; @@ -54,6 +64,7 @@ public WorkerDocumentPatchRequest( this.expiryDate = expiryDate; this.destination = destination; this.note = note; + this.fileId = fileId; this.expectedVersion = expectedVersion; } @@ -77,6 +88,10 @@ public String getNote() { return note; } + public UUID getFileId() { + return fileId; + } + public Long getExpectedVersion() { return expectedVersion; } diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentPatchCommand.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentPatchCommand.java index 0d6680f..e66b361 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentPatchCommand.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentPatchCommand.java @@ -15,6 +15,7 @@ public final class WorkerDocumentPatchCommand { private final LocalDate expiryDate; private final String destination; private final String note; + private final UUID fileId; private final long expectedVersion; public WorkerDocumentPatchCommand( @@ -26,6 +27,7 @@ public WorkerDocumentPatchCommand( LocalDate expiryDate, String destination, String note, + UUID fileId, long expectedVersion ) { this.workerDocumentId = workerDocumentId; @@ -36,6 +38,7 @@ public WorkerDocumentPatchCommand( this.expiryDate = expiryDate; this.destination = destination; this.note = note; + this.fileId = fileId; this.expectedVersion = expectedVersion; } @@ -71,6 +74,10 @@ public String note() { return note; } + public UUID fileId() { + return fileId; + } + public long expectedVersion() { return expectedVersion; } diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentSearchQuery.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentSearchQuery.java new file mode 100644 index 0000000..887fb4e --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentSearchQuery.java @@ -0,0 +1,65 @@ +package com.fowoco.server.worker.application; + +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import java.time.LocalDate; +import java.util.UUID; + +public final class WorkerDocumentSearchQuery { + + private static final int MIN_SIZE = 1; + private static final int MAX_SIZE = 100; + + private final UUID workerId; + private final DocumentType documentType; + private final SubmissionStatus status; + private final LocalDate expiryBefore; + private final int page; + private final int size; + + public WorkerDocumentSearchQuery( + UUID workerId, + DocumentType documentType, + SubmissionStatus status, + LocalDate expiryBefore, + Integer page, + Integer size + ) { + this.workerId = workerId; + this.documentType = documentType; + this.status = status; + this.expiryBefore = expiryBefore; + this.page = page == null ? 0 : page; + this.size = size == null ? 20 : size; + if (this.page < 0) { + throw new IllegalArgumentException("page must not be negative"); + } + if (this.size < MIN_SIZE || this.size > MAX_SIZE) { + throw new IllegalArgumentException("size must be between " + MIN_SIZE + " and " + MAX_SIZE); + } + } + + public UUID workerId() { + return workerId; + } + + public DocumentType documentType() { + return documentType; + } + + public SubmissionStatus status() { + return status; + } + + public LocalDate expiryBefore() { + return expiryBefore; + } + + public int page() { + return page; + } + + public int size() { + return size; + } +} diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java index f716ae5..d7e60ed 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java @@ -1,12 +1,25 @@ package com.fowoco.server.worker.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.time.DatabaseTimestamp; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.file.application.port.StoredFileRepository; import com.fowoco.server.worker.application.error.WorkerErrorCode; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.WorkerDocument; import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; +import java.util.Objects; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,22 +27,36 @@ @Service public class WorkerDocumentService { + private static final String AUDIT_EVENT_VERSION = "1"; + private final WorkerDocumentRepository workerDocumentRepository; + private final WorkerRepository workerRepository; + private final StoredFileRepository storedFileRepository; + private final AuditEventRepository auditRepository; private final UuidGenerator uuidGenerator; private final Clock clock; public WorkerDocumentService( WorkerDocumentRepository workerDocumentRepository, + WorkerRepository workerRepository, + StoredFileRepository storedFileRepository, + AuditEventRepository auditRepository, UuidGenerator uuidGenerator, Clock clock ) { this.workerDocumentRepository = workerDocumentRepository; + this.workerRepository = workerRepository; + this.storedFileRepository = storedFileRepository; + this.auditRepository = auditRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; } @Transactional public WorkerDocument register(WorkerDocumentCreateCommand command) { + workerRepository.findByWorkerIdAndCompanyId(command.workerId(), command.companyId()) + .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND)); + WorkerDocument document = WorkerDocument.create( uuidGenerator.generate(), command.workerId(), @@ -52,7 +79,7 @@ public WorkerDocument findDetail(UUID workerDocumentId, UUID workerId, UUID comp } @Transactional - public WorkerDocument patch(WorkerDocumentPatchCommand command) { + public WorkerDocument patch(WorkerDocumentPatchCommand command, ActorContext actor, RequestMetadata metadata) { WorkerDocument existing = findDetail( command.workerDocumentId(), command.workerId(), @@ -62,6 +89,10 @@ public WorkerDocument patch(WorkerDocumentPatchCommand command) { throw new ApiException(WorkerErrorCode.WORKER_DOCUMENT_VERSION_CONFLICT); } + UUID resolvedFileId = resolveFileId(command.fileId(), command.companyId(), existing.fileId()); + boolean fileNewlyLinked = command.fileId() != null && !command.fileId().equals(existing.fileId()); + + Instant now = DatabaseTimestamp.nowNotBefore(clock, existing.createdAt()); WorkerDocument updated = new WorkerDocument( existing.workerDocumentId(), existing.workerId(), @@ -71,13 +102,76 @@ public WorkerDocument patch(WorkerDocumentPatchCommand command) { orElseKeep(command.expiryDate(), existing.expiryDate()), orElseKeep(command.destination(), existing.destination()), orElseKeep(command.note(), existing.note()), - existing.fileId(), + resolvedFileId, existing.createdAt(), - DatabaseTimestamp.nowNotBefore(clock, existing.createdAt()), + now, existing.version() ); - return workerDocumentRepository.update(updated); + WorkerDocument saved = workerDocumentRepository.update(updated); + + if (fileNewlyLinked) { + appendAudit( + actor, + AuditAction.WORKER_DOCUMENT_FILE_LINKED, + AuditTargetType.WORKER_DOCUMENT, + saved.workerDocumentId(), + "서류에 파일 연결: file_id=" + resolvedFileId, + metadata, + now + ); + } + + return saved; + } + + private UUID resolveFileId(UUID requestedFileId, UUID companyId, UUID existingFileId) { + if (requestedFileId == null) { + return existingFileId; + } + storedFileRepository.findByIdAndCompanyId(requestedFileId, companyId) + .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_DOCUMENT_FILE_NOT_FOUND)); + return requestedFileId; + } + + private void appendAudit( + ActorContext actor, + AuditAction action, + AuditTargetType targetType, + UUID targetId, + String summary, + RequestMetadata metadata, + Instant now + ) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + actor.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + action, + targetType, + targetId, + 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; + }; } private static T orElseKeep(T newValue, T existingValue) { diff --git a/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java b/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java index 832f2b8..d1ca6fe 100644 --- a/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java +++ b/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java @@ -7,7 +7,8 @@ public enum WorkerErrorCode implements ApiErrorCode { WORKER_NOT_FOUND(HttpStatus.NOT_FOUND, "근로자를 찾을 수 없습니다."), WORKER_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."), WORKER_DOCUMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "서류를 찾을 수 없습니다."), - WORKER_DOCUMENT_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."); + WORKER_DOCUMENT_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."), + WORKER_DOCUMENT_FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "연결할 파일을 찾을 수 없습니다."); private final HttpStatus status; private final String defaultMessage; diff --git a/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentRepository.java b/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentRepository.java index adbe24a..7991d9f 100644 --- a/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentRepository.java +++ b/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentRepository.java @@ -1,6 +1,8 @@ package com.fowoco.server.worker.application.port; +import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; import com.fowoco.server.worker.domain.WorkerDocument; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -15,4 +17,8 @@ Optional findByIdAndWorkerIdAndCompanyId( ); WorkerDocument update(WorkerDocument document); + + List findPage(UUID companyId, WorkerDocumentSearchQuery query); + + long countPage(UUID companyId, WorkerDocumentSearchQuery query); } diff --git a/src/main/java/com/fowoco/server/worker/application/port/WorkerRepository.java b/src/main/java/com/fowoco/server/worker/application/port/WorkerRepository.java index 993de6d..8615920 100644 --- a/src/main/java/com/fowoco/server/worker/application/port/WorkerRepository.java +++ b/src/main/java/com/fowoco/server/worker/application/port/WorkerRepository.java @@ -4,6 +4,7 @@ import com.fowoco.server.worker.domain.Worker; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; public interface WorkerRepository { @@ -17,4 +18,6 @@ public interface WorkerRepository { List findPage(UUID companyId, WorkerSearchQuery query); long countPage(UUID companyId, WorkerSearchQuery query); + + List findAllByWorkerIdsAndCompanyId(Set workerIds, UUID companyId); } diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerDocumentRepository.java b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerDocumentRepository.java index 9cda775..6f80fc6 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerDocumentRepository.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerDocumentRepository.java @@ -1,8 +1,12 @@ package com.fowoco.server.worker.infrastructure.persistence; +import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.domain.WorkerDocument; import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import jakarta.persistence.TypedQuery; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -65,4 +69,65 @@ public WorkerDocument update(WorkerDocument document) { entityManager.flush(); return entity.toDomain(); } + + @Override + public List findPage(UUID companyId, WorkerDocumentSearchQuery query) { + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(query, "query must not be null"); + String jpql = "select document from WorkerDocumentJpaEntity document" + + buildWhereClause(query) + + " order by document.createdAt desc"; + TypedQuery jpaQuery = entityManager.createQuery(jpql, WorkerDocumentJpaEntity.class); + bindParameters(jpaQuery, companyId, query); + return jpaQuery + .setFirstResult(query.page() * query.size()) + .setMaxResults(query.size()) + .getResultList() + .stream() + .map(WorkerDocumentJpaEntity::toDomain) + .toList(); + } + + @Override + public long countPage(UUID companyId, WorkerDocumentSearchQuery query) { + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(query, "query must not be null"); + String jpql = "select count(document) from WorkerDocumentJpaEntity document" + buildWhereClause(query); + TypedQuery jpaQuery = entityManager.createQuery(jpql, Long.class); + bindParameters(jpaQuery, companyId, query); + return jpaQuery.getSingleResult(); + } + + private String buildWhereClause(WorkerDocumentSearchQuery query) { + StringBuilder where = new StringBuilder(" where document.companyId = :companyId"); + if (query.workerId() != null) { + where.append(" and document.workerId = :workerId"); + } + if (query.documentType() != null) { + where.append(" and document.documentType = :documentType"); + } + if (query.status() != null) { + where.append(" and document.submissionStatus = :status"); + } + if (query.expiryBefore() != null) { + where.append(" and document.expiryDate < :expiryBefore"); + } + return where.toString(); + } + + private void bindParameters(Query jpaQuery, UUID companyId, WorkerDocumentSearchQuery query) { + jpaQuery.setParameter("companyId", companyId); + if (query.workerId() != null) { + jpaQuery.setParameter("workerId", query.workerId()); + } + if (query.documentType() != null) { + jpaQuery.setParameter("documentType", query.documentType()); + } + if (query.status() != null) { + jpaQuery.setParameter("status", query.status()); + } + if (query.expiryBefore() != null) { + jpaQuery.setParameter("expiryBefore", query.expiryBefore()); + } + } } 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 6f4b5a4..624cffa 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 @@ -4,10 +4,12 @@ import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.Worker; import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; import jakarta.persistence.TypedQuery; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Repository; @@ -63,10 +65,10 @@ public Worker update(Worker worker) { public List findPage(UUID companyId, WorkerSearchQuery query) { Objects.requireNonNull(companyId, "companyId must not be null"); Objects.requireNonNull(query, "query must not be null"); - TypedQuery jpaQuery = entityManager.createQuery( - buildWhereClause(query) + " order by worker.createdAt desc", - WorkerJpaEntity.class - ); + String jpql = "select worker from WorkerJpaEntity worker" + + buildWhereClause(query) + + " order by worker.createdAt desc"; + TypedQuery jpaQuery = entityManager.createQuery(jpql, WorkerJpaEntity.class); bindParameters(jpaQuery, companyId, query); return jpaQuery .setFirstResult(query.page() * query.size()) @@ -81,33 +83,51 @@ public List findPage(UUID companyId, WorkerSearchQuery query) { public long countPage(UUID companyId, WorkerSearchQuery query) { Objects.requireNonNull(companyId, "companyId must not be null"); Objects.requireNonNull(query, "query must not be null"); - TypedQuery jpaQuery = entityManager.createQuery( - "select count(worker) " + buildWhereClause(query).replaceFirst("^select worker ", ""), - Long.class - ); + String jpql = "select count(worker) from WorkerJpaEntity worker" + buildWhereClause(query); + TypedQuery jpaQuery = entityManager.createQuery(jpql, Long.class); bindParameters(jpaQuery, companyId, query); return jpaQuery.getSingleResult(); } + @Override + public List findAllByWorkerIdsAndCompanyId(Set workerIds, UUID companyId) { + Objects.requireNonNull(workerIds, "workerIds must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + if (workerIds.isEmpty()) { + return List.of(); + } + return entityManager.createQuery( + """ + select worker + from WorkerJpaEntity worker + where worker.workerId in :workerIds + and worker.companyId = :companyId + """, + WorkerJpaEntity.class + ) + .setParameter("workerIds", workerIds) + .setParameter("companyId", companyId) + .getResultList() + .stream() + .map(WorkerJpaEntity::toDomain) + .toList(); + } + private String buildWhereClause(WorkerSearchQuery query) { - StringBuilder jpql = new StringBuilder("select worker from WorkerJpaEntity worker where worker.companyId = :companyId"); + StringBuilder where = new StringBuilder(" where worker.companyId = :companyId"); if (query.status() != null) { - jpql.append(" and worker.workStatus = :status"); + where.append(" and worker.workStatus = :status"); } if (query.language() != null) { - jpql.append(" and worker.preferredLanguage = :language"); + where.append(" and worker.preferredLanguage = :language"); } if (query.expiryBefore() != null) { - jpql.append(" and worker.stayExpiryDate < :expiryBefore"); + where.append(" and worker.stayExpiryDate < :expiryBefore"); } - return jpql.toString(); + return where.toString(); } - private void bindParameters( - jakarta.persistence.Query jpaQuery, - UUID companyId, - WorkerSearchQuery query - ) { + private void bindParameters(Query jpaQuery, UUID companyId, WorkerSearchQuery query) { jpaQuery.setParameter("companyId", companyId); if (query.status() != null) { jpaQuery.setParameter("status", query.status()); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index fa70e39..ab58411 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -15,6 +15,10 @@ spring: enabled: true locations: classpath:db/migration clean-disabled: true + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB jackson: property-naming-strategy: SNAKE_CASE time-zone: UTC @@ -50,6 +54,8 @@ app: catalog: location: ${WORKFLOW_CATALOG_LOCATION:classpath:workflow/catalog-projection.local.json} allow-unreleased: ${WORKFLOW_CATALOG_ALLOW_UNRELEASED:true} + file-storage: + local-path: ${FILE_STORAGE_LOCAL_PATH:./data/files} cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173} demo-seed: diff --git a/src/main/resources/db/migration/V8__create_stored_file.sql b/src/main/resources/db/migration/V8__create_stored_file.sql new file mode 100644 index 0000000..4165333 --- /dev/null +++ b/src/main/resources/db/migration/V8__create_stored_file.sql @@ -0,0 +1,34 @@ +CREATE TABLE stored_file ( + stored_file_id UUID NOT NULL, + company_id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + mime_type VARCHAR(127) NOT NULL, + size BIGINT NOT NULL, + purpose VARCHAR(60) NOT NULL, + task_id UUID, + worker_id UUID, + storage_key VARCHAR(255) NOT NULL, + scan_status VARCHAR(20) NOT NULL, + verified BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_stored_file PRIMARY KEY (stored_file_id), + CONSTRAINT uq_stored_file_storage_key UNIQUE (storage_key), + CONSTRAINT fk_stored_file_company + FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, + CONSTRAINT fk_stored_file_task_company + FOREIGN KEY (task_id, company_id) + REFERENCES task (task_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_stored_file_worker_company + FOREIGN KEY (worker_id, company_id) + REFERENCES worker (worker_id, company_id) ON DELETE RESTRICT, + CONSTRAINT ck_stored_file_name_not_blank CHECK (CHAR_LENGTH(TRIM(name)) > 0), + CONSTRAINT ck_stored_file_mime_type_not_blank CHECK (CHAR_LENGTH(TRIM(mime_type)) > 0), + CONSTRAINT ck_stored_file_purpose_not_blank CHECK (CHAR_LENGTH(TRIM(purpose)) > 0), + CONSTRAINT ck_stored_file_size_positive CHECK (size > 0), + CONSTRAINT ck_stored_file_scan_status + CHECK (scan_status IN ('NOT_SCANNED')) +); + +CREATE INDEX idx_stored_file_company ON stored_file (company_id); +CREATE INDEX idx_stored_file_company_task ON stored_file (company_id, task_id); +CREATE INDEX idx_stored_file_company_worker ON stored_file (company_id, worker_id); diff --git a/src/main/resources/db/migration/V9__create_document_request_draft.sql b/src/main/resources/db/migration/V9__create_document_request_draft.sql new file mode 100644 index 0000000..9f928fd --- /dev/null +++ b/src/main/resources/db/migration/V9__create_document_request_draft.sql @@ -0,0 +1,36 @@ +CREATE TABLE document_request_draft ( + draft_id UUID NOT NULL, + task_id UUID NOT NULL, + company_id UUID NOT NULL, + language VARCHAR(20) NOT NULL, + message VARCHAR(1000), + review_status VARCHAR(20) NOT NULL, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT pk_document_request_draft PRIMARY KEY (draft_id), + CONSTRAINT uq_document_request_draft_task UNIQUE (task_id, company_id), + CONSTRAINT fk_document_request_draft_company + FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, + CONSTRAINT fk_document_request_draft_task_company + FOREIGN KEY (task_id, company_id) + REFERENCES task (task_id, company_id) ON DELETE RESTRICT, + CONSTRAINT ck_document_request_draft_language_not_blank + CHECK (CHAR_LENGTH(TRIM(language)) > 0), + CONSTRAINT ck_document_request_draft_review_status + CHECK (review_status IN ('DRAFT')), + CONSTRAINT ck_document_request_draft_version CHECK (version >= 0), + CONSTRAINT ck_document_request_draft_updated_at CHECK (updated_at >= created_at) +); + +CREATE TABLE document_request_draft_type ( + draft_id UUID NOT NULL, + document_type VARCHAR(40) NOT NULL, + CONSTRAINT fk_document_request_draft_type_draft + FOREIGN KEY (draft_id) REFERENCES document_request_draft (draft_id) ON DELETE CASCADE, + CONSTRAINT ck_document_request_draft_type_value + CHECK (document_type IN ('PASSPORT_COPY', 'ARC', 'CONTRACT', 'PERMIT')), + CONSTRAINT uq_document_request_draft_type UNIQUE (draft_id, document_type) +); + +CREATE INDEX idx_document_request_draft_company ON document_request_draft (company_id); diff --git a/src/test/java/com/fowoco/server/document/DocumentSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/document/DocumentSecurityIntegrationTest.java new file mode 100644 index 0000000..a57fdf2 --- /dev/null +++ b/src/test/java/com/fowoco/server/document/DocumentSecurityIntegrationTest.java @@ -0,0 +1,344 @@ +package com.fowoco.server.document; + +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.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DocumentSecurityIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("80000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("90000000-0000-0000-0000-000000000002"); + private static final UUID HR_A = UUID.fromString("81000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("91000000-0000-0000-0000-000000000002"); + private static final String HR_A_EMAIL = "hr.doc.a@example.com"; + private static final String HR_B_EMAIL = "hr.doc.b@example.com"; + private static final String PASSWORD = "Test-password-1!"; + private static final String BOUNDARY = "FowocoDocTestBoundary1234"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeAll + void seedCompaniesAndUsers() { + cleanupAll(); + insertCompany(COMPANY_A, "사업장 A"); + insertCompany(COMPANY_B, "사업장 B"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash); + insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash); + } + + @BeforeEach + void resetState() { + jdbcTemplate.update("DELETE FROM document_request_draft_type"); + jdbcTemplate.update("DELETE FROM document_request_draft"); + jdbcTemplate.update("DELETE FROM stored_file"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + 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"); + } + + private void cleanupAll() { + jdbcTemplate.update("DELETE FROM document_request_draft_type"); + jdbcTemplate.update("DELETE FROM document_request_draft"); + jdbcTemplate.update("DELETE FROM stored_file"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + 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"); + } + + @Test + void readinessReflectsMissingThenAvailableAfterDocumentLinked() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(token, "준비도테스트근로자"); + String taskId = createTask(token, workerId); + + HttpResponse beforeResponse = getJson("/api/v1/tasks/" + taskId + "/document-readiness", token); + assertThat(beforeResponse.statusCode()).isEqualTo(200); + List missingBefore = JsonPath.read(beforeResponse.body(), "$.missing"); + assertThat(missingBefore).containsExactlyInAnyOrder("CONTRACT", "PERMIT"); + assertThat(JsonPath.read(beforeResponse.body(), "$.completion_blocked")).isTrue(); + + String fileId = uploadFile(token, "contract.pdf", "application/pdf", "contract content".getBytes(StandardCharsets.UTF_8)); + String documentId = registerWorkerDocument(token, workerId, "CONTRACT"); + linkFileToDocument(token, workerId, documentId, fileId, 0); + + String fileId2 = uploadFile(token, "permit.pdf", "application/pdf", "permit content".getBytes(StandardCharsets.UTF_8)); + String documentId2 = registerWorkerDocument(token, workerId, "PERMIT"); + linkFileToDocument(token, workerId, documentId2, fileId2, 0); + + HttpResponse afterResponse = getJson("/api/v1/tasks/" + taskId + "/document-readiness", token); + List availableAfter = JsonPath.read(afterResponse.body(), "$.available"); + assertThat(availableAfter).containsExactlyInAnyOrder("CONTRACT", "PERMIT"); + assertThat(JsonPath.read(afterResponse.body(), "$.completion_blocked")).isFalse(); + } + + @Test + void readinessReturnsNotFoundForOtherCompanyTask() throws Exception { + String tokenA = accessToken(login(HR_A_EMAIL)); + String tokenB = accessToken(login(HR_B_EMAIL)); + String workerId = registerWorker(tokenA, "격리테스트근로자"); + String taskId = createTask(tokenA, workerId); + + HttpResponse response = getJson("/api/v1/tasks/" + taskId + "/document-readiness", tokenB); + + assertThat(response.statusCode()).isEqualTo(404); + } + + @Test + void listDocumentsIncludesWorkerDisplayNameAndFiltersByWorker() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(token, "목록조회테스트"); + registerWorkerDocument(token, workerId, "CONTRACT"); + + HttpResponse response = getJson("/api/v1/documents?worker_id=" + workerId, token); + + assertThat(response.statusCode()).isEqualTo(200); + List items = JsonPath.read(response.body(), "$.items"); + assertThat(items).hasSize(1); + assertThat(JsonPath.read(response.body(), "$.items[0].display_name")) + .isEqualTo("목록조회테스트"); + } + + @Test + void documentRequestDraftCreatesThenUpdatesThenRejectsStaleVersion() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(token, "초안테스트근로자"); + String taskId = createTask(token, workerId); + String path = "/api/v1/tasks/" + taskId + "/document-request-draft"; + + String createBody = """ + {"language":"vi","document_types":["CONTRACT"],"message":"계약서를 제출해 주세요.","expected_version":0} + """; + HttpResponse createResponse = putJson(path, createBody, token); + assertThat(createResponse.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(createResponse.body(), "$.version").longValue()).isZero(); + assertThat(JsonPath.read(createResponse.body(), "$.review_status")).isEqualTo("DRAFT"); + + String updateBody = """ + {"language":"ko","document_types":["CONTRACT","PERMIT"],"message":"수정된 안내","expected_version":0} + """; + HttpResponse updateResponse = putJson(path, updateBody, token); + assertThat(updateResponse.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(updateResponse.body(), "$.version").longValue()).isEqualTo(1); + + HttpResponse staleResponse = putJson(path, updateBody, token); + assertThat(staleResponse.statusCode()).isEqualTo(409); + } + + @Test + void documentRequestDraftReturnsNotFoundForNonExistentTask() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + String path = "/api/v1/tasks/" + UUID.randomUUID() + "/document-request-draft"; + + String body = """ + {"language":"vi","document_types":["CONTRACT"],"expected_version":0} + """; + HttpResponse response = putJson(path, body, token); + + assertThat(response.statusCode()).isEqualTo(404); + } + + private String registerWorker(String token, String displayName) throws Exception { + String body = """ + {"display_name":"%s"} + """.formatted(displayName); + HttpResponse response = postJson("/api/v1/workers", body, token); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_id"); + } + + private String createTask(String token, String workerId) throws Exception { + String body = """ + { + "worker_id":"%s", + "task_type":"RECONTRACT", + "workflow_id":"WF-CON-001", + "title":"재계약 준비", + "description":"기존 조건 확인", + "due_date":"2026-08-20", + "business_data":{"monthly_wage":2500000} + } + """.formatted(workerId); + HttpResponse response = postJson("/api/v1/tasks", body, token); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.task_id"); + } + + private String registerWorkerDocument(String token, String workerId, String documentType) throws Exception { + String body = """ + {"document_type":"%s","submission_status":"SUBMITTED"} + """.formatted(documentType); + HttpResponse response = postJson("/api/v1/workers/" + workerId + "/documents", body, token); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_document_id"); + } + + private void linkFileToDocument(String token, String workerId, String documentId, String fileId, long expectedVersion) + throws Exception { + String body = """ + {"file_id":"%s","expected_version":%d} + """.formatted(fileId, expectedVersion); + HttpResponse response = patchJson( + "/api/v1/workers/" + workerId + "/documents/" + documentId, body, token + ); + assertThat(response.statusCode()).isEqualTo(200); + } + + private String uploadFile(String token, String filename, String mimeType, byte[] content) throws Exception { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + writePart(out, "file", filename, mimeType, content); + writeFieldPart(out, "purpose", "WORKER_DOCUMENT"); + out.write(("--" + BOUNDARY + "--\r\n").getBytes(StandardCharsets.UTF_8)); + + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/files")) + .header(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=" + BOUNDARY) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray())) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.file_id"); + } + + private void writePart( + java.io.ByteArrayOutputStream out, String name, String filename, String mimeType, byte[] content + ) throws java.io.IOException { + out.write(("--" + BOUNDARY + "\r\n").getBytes(StandardCharsets.UTF_8)); + out.write( + ("Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + filename + "\"\r\n") + .getBytes(StandardCharsets.UTF_8) + ); + out.write(("Content-Type: " + mimeType + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(content); + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + + private void writeFieldPart(java.io.ByteArrayOutputStream out, String name, String value) throws java.io.IOException { + out.write(("--" + BOUNDARY + "\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(value.getBytes(StandardCharsets.UTF_8)); + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + + private void insertCompany(UUID companyId, String name) { + jdbcTemplate.update( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + companyId, + name + ); + } + + private void insertUser(UUID userId, UUID companyId, String email, String passwordHash) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + userId, + companyId, + email, + email, + passwordHash + ); + } + + private HttpResponse login(String email) throws Exception { + String body = """ + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD); + return postJson("/api/v1/auth/login", body, null); + } + + private String accessToken(HttpResponse loginResponse) { + assertThat(loginResponse.statusCode()).isEqualTo(200); + return JsonPath.read(loginResponse.body(), "$.access_token"); + } + + private HttpResponse getJson(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 postJson(String path, String body, String token) throws Exception { + return sendJson(path, body, token, "POST"); + } + + private HttpResponse patchJson(String path, String body, String token) throws Exception { + return sendJson(path, body, token, "PATCH"); + } + + private HttpResponse putJson(String path, String body, String token) throws Exception { + return sendJson(path, body, token, "PUT"); + } + + private HttpResponse sendJson(String path, String body, String token, String method) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(body)); + if (token != null) { + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + return httpClient.send(builder.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/file/FileSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java new file mode 100644 index 0000000..5c10efb --- /dev/null +++ b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java @@ -0,0 +1,208 @@ +package com.fowoco.server.file; + +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.nio.charset.StandardCharsets; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FileSecurityIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("70000000-0000-0000-0000-000000000001"); + private static final UUID HR_A = UUID.fromString("71000000-0000-0000-0000-000000000001"); + private static final String HR_A_EMAIL = "hr.file.a@example.com"; + private static final String PASSWORD = "Test-password-1!"; + private static final String BOUNDARY = "FowocoTestBoundary1234"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeAll + void seedCompanyAndUser() { + jdbcTemplate.update("DELETE FROM document_request_draft_type"); + jdbcTemplate.update("DELETE FROM document_request_draft"); + jdbcTemplate.update("DELETE FROM stored_file"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + 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( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + COMPANY_A, + "사업장 A" + ); + String passwordHash = passwordEncoder.encode(PASSWORD); + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + HR_A, + COMPANY_A, + HR_A_EMAIL, + HR_A_EMAIL, + passwordHash + ); + } + + @BeforeEach + void resetFileState() { + jdbcTemplate.update("DELETE FROM stored_file"); + } + + @Test + void uploadSucceedsAndAppendsAuditEvent() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = uploadFile(token, "note.pdf", "application/pdf", "test file content".getBytes(StandardCharsets.UTF_8), "GENERAL"); + + assertThat(response.statusCode()).isEqualTo(201); + String fileId = JsonPath.read(response.body(), "$.file_id"); + assertThat(JsonPath.read(response.body(), "$.name")).isEqualTo("note.pdf"); + assertThat(JsonPath.read(response.body(), "$.scan_status")).isEqualTo("NOT_SCANNED"); + + java.util.List> allAuditRows = jdbcTemplate.queryForList("SELECT * FROM audit_event"); + System.out.println("DEBUG audit_event rows: " + allAuditRows); + System.out.println("DEBUG looking for fileId: " + fileId); + Integer auditCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE target_id = ? AND action = 'FILE_UPLOADED'", + Integer.class, + UUID.fromString(fileId) + ); + assertThat(auditCount).isEqualTo(1); + } + + @Test + void uploadRejectsUnsupportedMimeType() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = uploadFile( + token, "script.exe", "application/x-msdownload", "malicious".getBytes(StandardCharsets.UTF_8), "GENERAL" + ); + + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(415); + } + + @Test + void uploadRejectsNonExistentTaskId() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + UUID nonExistentTaskId = UUID.randomUUID(); + + HttpResponse response = uploadFileWithTask( + token, "note.pdf", "application/pdf", "content".getBytes(StandardCharsets.UTF_8), nonExistentTaskId + ); + + assertThat(response.statusCode()).isEqualTo(404); + } + + private HttpResponse uploadFile( + String token, String filename, String mimeType, byte[] content, String purpose + ) throws Exception { + return uploadFile(token, filename, mimeType, content, purpose, null); + } + + private HttpResponse uploadFile( + String token, String filename, String mimeType, byte[] content, String purpose, UUID taskId + ) throws Exception { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + + writePart(out, "file", filename, mimeType, content); + writeFieldPart(out, "purpose", purpose); + if (taskId != null) { + writeFieldPart(out, "taskId", taskId.toString()); + } + out.write(("--" + BOUNDARY + "--\r\n").getBytes(StandardCharsets.UTF_8)); + + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/files")) + .header(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=" + BOUNDARY) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray())) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse uploadFileWithTask( + String token, String filename, String mimeType, byte[] content, UUID taskId + ) throws Exception { + return uploadFile(token, filename, mimeType, content, "TASK_EVIDENCE", taskId); + } + + + private void writePart( + java.io.ByteArrayOutputStream out, String name, String filename, String mimeType, byte[] content + ) throws java.io.IOException { + out.write(("--" + BOUNDARY + "\r\n").getBytes(StandardCharsets.UTF_8)); + out.write( + ("Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + filename + "\"\r\n") + .getBytes(StandardCharsets.UTF_8) + ); + out.write(("Content-Type: " + mimeType + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(content); + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + + private void writeFieldPart(java.io.ByteArrayOutputStream out, String name, String value) throws java.io.IOException { + out.write(("--" + BOUNDARY + "\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(value.getBytes(StandardCharsets.UTF_8)); + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + + private HttpResponse login(String email) throws Exception { + String body = """ + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD); + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/auth/login")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private String accessToken(HttpResponse loginResponse) { + assertThat(loginResponse.statusCode()).isEqualTo(200); + return JsonPath.read(loginResponse.body(), "$.access_token"); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } +} diff --git a/src/test/java/com/fowoco/server/file/support/FakeFileStorage.java b/src/test/java/com/fowoco/server/file/support/FakeFileStorage.java new file mode 100644 index 0000000..4606452 --- /dev/null +++ b/src/test/java/com/fowoco/server/file/support/FakeFileStorage.java @@ -0,0 +1,34 @@ +package com.fowoco.server.file.support; +import com.fowoco.server.file.application.port.FileStorage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 테스트 전용 FileStorage. 실제 디스크에 쓰지 않고 메모리에만 기록. + */ + +public class FakeFileStorage implements FileStorage { + private final Map storedContents = new ConcurrentHashMap<>(); + @Override + public void store(String storageKey, InputStream content, long size, String mimeType) { + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + content.transferTo(buffer); + storedContents.put(storageKey, buffer.toByteArray()); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } + public boolean contains(String storageKey) { + return storedContents.containsKey(storageKey); + } + public byte[] contentOf(String storageKey) { + return storedContents.get(storageKey); + } + public void clear() { + storedContents.clear(); + } +}