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 b9001d0..f15081a 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -14,5 +14,6 @@ public enum AuditAction { TASK_COMPLETED, FILE_UPLOADED, WORKER_DOCUMENT_FILE_LINKED, - DOCUMENT_REQUEST_DRAFT_SAVED + DOCUMENT_REQUEST_DRAFT_SAVED, + WORKER_LINK_RESPONSE_SUBMITTED } diff --git a/src/main/java/com/fowoco/server/common/config/SecurityConfig.java b/src/main/java/com/fowoco/server/common/config/SecurityConfig.java index ed23ab4..7659b0e 100644 --- a/src/main/java/com/fowoco/server/common/config/SecurityConfig.java +++ b/src/main/java/com/fowoco/server/common/config/SecurityConfig.java @@ -91,6 +91,8 @@ public SecurityFilterChain applicationSecurityFilterChain( "/api/v1/auth/logout" ).permitAll() .requestMatchers("/error").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/public/worker-links/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/public/worker-links/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/**") .hasAnyRole("ADMIN", "HR", "VIEWER") .requestMatchers(HttpMethod.HEAD, "/api/v1/**") diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java new file mode 100644 index 0000000..7b53839 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java @@ -0,0 +1,88 @@ +package com.fowoco.server.workerlink.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.workerlink.application.WorkerLinkIssueCommand; +import com.fowoco.server.workerlink.application.WorkerLinkIssueResult; +import com.fowoco.server.workerlink.application.WorkerLinkService; +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.Valid; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Worker Link", description = "근로자 보안 링크 발급") +@RestController +@RequestMapping("/api/v1/tasks/{taskId}/worker-link") +@SecurityRequirement(name = "bearerAuth") +public class WorkerLinkController { + + private final WorkerLinkService workerLinkService; + private final ActorContextProvider actorContextProvider; + + public WorkerLinkController( + WorkerLinkService workerLinkService, + ActorContextProvider actorContextProvider + ) { + this.workerLinkService = workerLinkService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "issueWorkerLink", + summary = "근로자 보안 링크 발급", + description = "승인된 현재 업무카드 version에서만 발급 가능합니다. " + + "rotate_existing=true면 기존 활성 링크를 즉시 폐기하고 재발급합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "발급 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = WorkerLinkIssueResponse.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", description = "이미 유효한 링크가 있어 rotate_existing 필요"), + @ApiResponse(responseCode = "422", description = "승인되지 않은 업무카드") + }) + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public ResponseEntity issue( + @Parameter(description = "업무 ID") @PathVariable UUID taskId, + @Valid @RequestBody WorkerLinkIssueRequest request, + //: 나중에 확인 필요 + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + WorkerLinkIssueCommand command = new WorkerLinkIssueCommand( + taskId, + actor.companyId(), + request.getExpiresInHours(), + request.isRotateExisting(), + actor.actorId(), + idempotencyKey + ); + WorkerLinkIssueResult result = workerLinkService.issue(command); + return ResponseEntity.status(HttpStatus.CREATED).body(WorkerLinkIssueResponse.from(result)); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java new file mode 100644 index 0000000..dde6990 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java @@ -0,0 +1,96 @@ +package com.fowoco.server.workerlink.api; + +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.workerlink.application.WorkerLinkDocumentService; +import com.fowoco.server.workerlink.application.WorkerLinkDocumentUploadCommand; +import com.fowoco.server.workerlink.application.WorkerLinkDocumentUploadResult; +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.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.io.UncheckedIOException; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Worker Link (Public)", description = "근로자 공개 안내·제출") +@RestController +public class WorkerLinkDocumentController { + + private final WorkerLinkDocumentService workerLinkDocumentService; + + public WorkerLinkDocumentController(WorkerLinkDocumentService workerLinkDocumentService) { + this.workerLinkDocumentService = workerLinkDocumentService; + } + + @Operation( + operationId = "uploadWorkerLinkDocument", + summary = "근로자 링크 문서 제출", + description = "만료·폐기된 token은 거절합니다. 확장자·MIME·크기·악성파일 검사 후 격리 저장합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "업로드 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = WorkerLinkDocumentUploadResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "410", description = "링크를 찾을 수 없거나 더 이상 사용할 수 없음"), + @ApiResponse(responseCode = "413", description = "파일 크기 초과"), + @ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType"), + @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity"), + @ApiResponse(responseCode = "429", description = "요청 과다") + }) + @PostMapping( + path = "/api/v1/public/worker-links/{token}/documents", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity upload( + @Parameter(description = "근로자 링크 토큰") @PathVariable String token, + @Parameter(description = "업로드할 파일") @RequestParam("file") MultipartFile file, + @Parameter(description = "문서 유형") @RequestParam(value = "documentType", required = false) String documentType, + @Parameter(description = "클라이언트 중복 방지 키") @RequestParam("clientRequestId") String clientRequestId, + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey, + HttpServletRequest servletRequest + ) { + if (file.isEmpty() || file.getOriginalFilename() == null || file.getOriginalFilename().isBlank()) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "업로드할 파일과 파일명이 필요합니다."); + } + try { + WorkerLinkDocumentUploadCommand command = new WorkerLinkDocumentUploadCommand( + token, + file.getOriginalFilename(), + file.getContentType(), + file.getSize(), + documentType, + clientRequestId, + file.getInputStream() + ); + WorkerLinkDocumentUploadResult result = workerLinkDocumentService.upload( + command, + RequestMetadata.from(servletRequest) + ); + return ResponseEntity.status(HttpStatus.CREATED) + .body(WorkerLinkDocumentUploadResponse.from(result.storedFile(), result.linkExpiresAt())); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read uploaded file", exception); + } + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentUploadResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentUploadResponse.java new file mode 100644 index 0000000..9a1caf7 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentUploadResponse.java @@ -0,0 +1,58 @@ +package com.fowoco.server.workerlink.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.file.domain.StoredFile; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.UUID; + +@Schema(name = "WorkerLinkDocumentUploadResponse", description = "근로자 링크 문서 업로드 결과") +public final class WorkerLinkDocumentUploadResponse { + + @JsonProperty("upload_id") + @Schema(name = "upload_id") + private final UUID uploadId; + + @JsonProperty("file_name") + @Schema(name = "file_name") + private final String fileName; + + @JsonProperty("size") + private final long size; + + @JsonProperty("expires_at") + @Schema(name = "expires_at") + private final Instant expiresAt; + + private WorkerLinkDocumentUploadResponse(UUID uploadId, String fileName, long size, Instant expiresAt) { + this.uploadId = uploadId; + this.fileName = fileName; + this.size = size; + this.expiresAt = expiresAt; + } + + public static WorkerLinkDocumentUploadResponse from(StoredFile storedFile, Instant linkExpiresAt) { + return new WorkerLinkDocumentUploadResponse( + storedFile.storedFileId(), + storedFile.name(), + storedFile.size(), + linkExpiresAt + ); + } + + public UUID getUploadId() { + return uploadId; + } + + public String getFileName() { + return fileName; + } + + public long getSize() { + return size; + } + + public Instant getExpiresAt() { + return expiresAt; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueRequest.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueRequest.java new file mode 100644 index 0000000..49e4dd4 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueRequest.java @@ -0,0 +1,32 @@ +package com.fowoco.server.workerlink.api; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "WorkerLinkIssueRequest", description = "근로자 보안 링크 발급 요청") +public final class WorkerLinkIssueRequest { + + @Schema(name = "expires_in_hours", description = "링크 유효 시간(시간 단위)", example = "72") + private final Long expiresInHours; + + @Schema(name = "rotate_existing", description = "기존 활성 링크를 폐기하고 재발급할지 여부") + private final boolean rotateExisting; + + @JsonCreator + public WorkerLinkIssueRequest( + @JsonProperty("expires_in_hours") Long expiresInHours, + @JsonProperty("rotate_existing") Boolean rotateExisting + ) { + this.expiresInHours = expiresInHours; + this.rotateExisting = rotateExisting != null && rotateExisting; + } + + public Long getExpiresInHours() { + return expiresInHours; + } + + public boolean isRotateExisting() { + return rotateExisting; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueResponse.java new file mode 100644 index 0000000..9397cd6 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueResponse.java @@ -0,0 +1,38 @@ +package com.fowoco.server.workerlink.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.workerlink.application.WorkerLinkIssueResult; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; + +@Schema(name = "WorkerLinkIssueResponse", description = "근로자 보안 링크 발급 결과") +public final class WorkerLinkIssueResponse { + + @JsonProperty("worker_url") + @Schema(name = "worker_url", description = "근로자에게 전달할 링크 URL") + private final String workerUrl; + + @JsonProperty("expires_at") + @Schema(name = "expires_at") + private final Instant expiresAt; + + private WorkerLinkIssueResponse(String workerUrl, Instant expiresAt) { + this.workerUrl = workerUrl; + this.expiresAt = expiresAt; + } + + public static WorkerLinkIssueResponse from(WorkerLinkIssueResult result) { + // TODO: workerUrl 조립 방식 확정 필요. + // 확인 후 실제 프론트 base URL + 경로로 조립해야 함. + // 지금은 원문 토큰만 그대로 노출한 상태(미완성). + return new WorkerLinkIssueResponse(result.rawToken(), result.expiresAt()); + } + + public String getWorkerUrl() { + return workerUrl; + } + + public Instant getExpiresAt() { + return expiresAt; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java new file mode 100644 index 0000000..6cd5331 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java @@ -0,0 +1,57 @@ +package com.fowoco.server.workerlink.api; + +import com.fowoco.server.workerlink.application.WorkerLinkViewResult; +import com.fowoco.server.workerlink.application.WorkerLinkViewService; +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.tags.Tag; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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 = "Worker Link (Public)", description = "근로자 공개 안내·제출") +@RestController +@RequestMapping("/api/v1/public/worker-links/{token}") +public class WorkerLinkViewController { + + private final WorkerLinkViewService workerLinkViewService; + + public WorkerLinkViewController(WorkerLinkViewService workerLinkViewService) { + this.workerLinkViewService = workerLinkViewService; + } + + @Operation( + operationId = "viewWorkerLink", + summary = "근로자 공개 안내 조회", + description = "로그인 없이 번역된 최소 안내와 허용 응답을 조회합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = WorkerLinkViewResponse.class) + ) + ), + @ApiResponse(responseCode = "410", description = "링크를 찾을 수 없거나 더 이상 사용할 수 없음"), + @ApiResponse(responseCode = "429", description = "요청 과다") + }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity view( + @Parameter(description = "근로자 링크 토큰") @PathVariable String token + ) { + WorkerLinkViewResult result = workerLinkViewService.view(token); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .body(WorkerLinkViewResponse.from(result)); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewResponse.java new file mode 100644 index 0000000..3d24e98 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewResponse.java @@ -0,0 +1,46 @@ +package com.fowoco.server.workerlink.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.workerlink.application.WorkerLinkViewResult; +import com.fowoco.server.workerlink.domain.WorkerResponseType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.List; + +@Schema(name = "WorkerLinkViewResponse", description = "근로자 공개 안내 조회 결과") +public final class WorkerLinkViewResponse { + + @JsonProperty("guidance") + @Schema(description = "번역된 안내 내용") + private final String guidance; + + @JsonProperty("due_date") + @Schema(name = "due_date", description = "제출 마감일") + private final LocalDate dueDate; + + @JsonProperty("allowed_responses") + @Schema(name = "allowed_responses", description = "이 링크에서 허용되는 응답 유형") + private final List allowedResponses; + + private WorkerLinkViewResponse(String guidance, LocalDate dueDate, List allowedResponses) { + this.guidance = guidance; + this.dueDate = dueDate; + this.allowedResponses = allowedResponses; + } + + public static WorkerLinkViewResponse from(WorkerLinkViewResult result) { + return new WorkerLinkViewResponse(result.guidance(), result.dueDate(), result.allowedResponses()); + } + + public String getGuidance() { + return guidance; + } + + public LocalDate getDueDate() { + return dueDate; + } + + public List getAllowedResponses() { + return allowedResponses; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseController.java new file mode 100644 index 0000000..23c05fa --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseController.java @@ -0,0 +1,78 @@ +package com.fowoco.server.workerlink.api; + +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.workerlink.application.WorkerResponseService; +import com.fowoco.server.workerlink.application.WorkerResponseSubmitCommand; +import com.fowoco.server.workerlink.application.WorkerResponseSubmitResult; +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.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Worker Link (Public)", description = "근로자 공개 안내·제출") +@RestController +public class WorkerResponseController { + + private final WorkerResponseService workerResponseService; + + public WorkerResponseController(WorkerResponseService workerResponseService) { + this.workerResponseService = workerResponseService; + } + + @Operation( + operationId = "submitWorkerResponse", + summary = "근로자 응답 제출", + description = "같은 idempotency_key로 재시도해도 중복 응답이 생기지 않습니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "제출 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = WorkerResponseSubmitResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "410", description = "링크를 찾을 수 없거나 더 이상 사용할 수 없음"), + @ApiResponse(responseCode = "413", description = "요청 크기 초과"), + @ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType"), + @ApiResponse(responseCode = "422", description = "허용되지 않은 upload_id"), + @ApiResponse(responseCode = "429", description = "요청 과다") + }) + @PostMapping( + path = "/api/v1/public/worker-links/{token}/responses", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity submit( + @Parameter(description = "근로자 링크 토큰") @PathVariable String token, + @Valid @RequestBody WorkerResponseSubmitRequest request, + HttpServletRequest servletRequest + ) { + WorkerResponseSubmitCommand command = new WorkerResponseSubmitCommand( + token, + request.getResponseType(), + request.getMessage(), + request.getUploadIds(), + request.getIdempotencyKey() + ); + WorkerResponseSubmitResult result = workerResponseService.submit( + command, + RequestMetadata.from(servletRequest) + ); + return ResponseEntity.status(HttpStatus.CREATED).body(WorkerResponseSubmitResponse.from(result)); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitRequest.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitRequest.java new file mode 100644 index 0000000..0aa4316 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitRequest.java @@ -0,0 +1,59 @@ +package com.fowoco.server.workerlink.api; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.workerlink.domain.WorkerResponseType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.UUID; + +@Schema(name = "WorkerResponseSubmitRequest", description = "근로자 응답 제출 요청") +public final class WorkerResponseSubmitRequest { + + @Schema(name = "response_type", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "response_type을 입력해 주세요.") + private final WorkerResponseType responseType; + + @Schema(description = "근로자 메시지", maxLength = 1000) + @Size(max = 1000, message = "message는 1000자 이하여야 합니다.") + private final String message; + + @Schema(name = "upload_ids", description = "함께 제출할 업로드 ID 목록") + private final List uploadIds; + + @Schema(name = "idempotency_key", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "idempotency_key를 입력해 주세요.") + private final String idempotencyKey; + + @JsonCreator + public WorkerResponseSubmitRequest( + @JsonProperty("response_type") WorkerResponseType responseType, + @JsonProperty("message") String message, + @JsonProperty("upload_ids") List uploadIds, + @JsonProperty("idempotency_key") String idempotencyKey + ) { + this.responseType = responseType; + this.message = message; + this.uploadIds = uploadIds; + this.idempotencyKey = idempotencyKey; + } + + public WorkerResponseType getResponseType() { + return responseType; + } + + public String getMessage() { + return message; + } + + public List getUploadIds() { + return uploadIds; + } + + public String getIdempotencyKey() { + return idempotencyKey; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitResponse.java new file mode 100644 index 0000000..d406699 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitResponse.java @@ -0,0 +1,36 @@ +package com.fowoco.server.workerlink.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.workerlink.application.WorkerResponseSubmitResult; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.UUID; + +@Schema(name = "WorkerResponseSubmitResponse", description = "근로자 응답 제출 결과") +public final class WorkerResponseSubmitResponse { + + @JsonProperty("response_id") + @Schema(name = "response_id") + private final UUID responseId; + + @JsonProperty("received_at") + @Schema(name = "received_at") + private final Instant receivedAt; + + private WorkerResponseSubmitResponse(UUID responseId, Instant receivedAt) { + this.responseId = responseId; + this.receivedAt = receivedAt; + } + + public static WorkerResponseSubmitResponse from(WorkerResponseSubmitResult result) { + return new WorkerResponseSubmitResponse(result.responseId(), result.receivedAt()); + } + + public UUID getResponseId() { + return responseId; + } + + public Instant getReceivedAt() { + return receivedAt; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java new file mode 100644 index 0000000..7764aeb --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java @@ -0,0 +1,139 @@ +package com.fowoco.server.workerlink.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.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +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.workerlink.application.error.WorkerLinkErrorCode; +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.infrastructure.security.WorkerLinkHasher; +import java.time.Clock; +import java.time.Instant; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkerLinkDocumentService { + + private static final String AUDIT_EVENT_VERSION = "1"; + + //FileService(#13)와 동일한 값 + 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 WorkerLinkTenantBootstrap workerLinkTenantBootstrap; + private final TenantDatabaseContext tenantDatabaseContext; + private final WorkerLinkRepository workerLinkRepository; + private final WorkerLinkHasher workerLinkHasher; + private final StoredFileRepository storedFileRepository; + private final FileStorage fileStorage; + private final AuditEventRepository auditRepository; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public WorkerLinkDocumentService( + WorkerLinkTenantBootstrap workerLinkTenantBootstrap, + TenantDatabaseContext tenantDatabaseContext, + WorkerLinkRepository workerLinkRepository, + WorkerLinkHasher workerLinkHasher, + StoredFileRepository storedFileRepository, + FileStorage fileStorage, + AuditEventRepository auditRepository, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.workerLinkTenantBootstrap = workerLinkTenantBootstrap; + this.tenantDatabaseContext = tenantDatabaseContext; + this.workerLinkRepository = workerLinkRepository; + this.workerLinkHasher = workerLinkHasher; + this.storedFileRepository = storedFileRepository; + this.fileStorage = fileStorage; + this.auditRepository = auditRepository; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public WorkerLinkDocumentUploadResult upload(WorkerLinkDocumentUploadCommand command, 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); + } + + String tokenHash = workerLinkHasher.hash(command.rawToken()); + + UUID companyId = workerLinkTenantBootstrap + .findCompanyIdByWorkerLinkTokenHash(tokenHash) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + + WorkerLink link = workerLinkRepository.findByTokenHash(tokenHash) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + + Instant now = clock.instant(); + if (!link.isUsable(now)) { + throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND); + } + + UUID storedFileId = uuidGenerator.generate(); + String storageKey = storedFileId.toString(); + String purpose = command.documentType() != null ? command.documentType() : "WORKER_LINK_SUBMISSION"; + + StoredFile storedFile = StoredFile.create( + storedFileId, + companyId, + command.fileName(), + command.mimeType(), + command.size(), + purpose, + link.taskId(), + null, + storageKey, + now + ); + + fileStorage.store(storageKey, command.content(), command.size(), command.mimeType()); + storedFileRepository.insert(storedFile); + + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + companyId, + ActorType.WORKER_LINK, + null, + null, + AuditAction.FILE_UPLOADED, + AuditTargetType.FILE, + storedFileId, + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + "근로자 링크로 파일 업로드: " + purpose, + now + )); + + // TODO: clientRequestId를 이용한 중복 제출 방지 로직 미구현 (스키마 변경 필요, 후속 처리) + + return new WorkerLinkDocumentUploadResult(storedFile, link.expiresAt()); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadCommand.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadCommand.java new file mode 100644 index 0000000..e87e232 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadCommand.java @@ -0,0 +1,60 @@ +package com.fowoco.server.workerlink.application; + +import java.io.InputStream; + +public final class WorkerLinkDocumentUploadCommand { + + private final String rawToken; + private final String fileName; + private final String mimeType; + private final long size; + private final String documentType; + private final String clientRequestId; + private final InputStream content; + + public WorkerLinkDocumentUploadCommand( + String rawToken, + String fileName, + String mimeType, + long size, + String documentType, + String clientRequestId, + InputStream content + ) { + this.rawToken = rawToken; + this.fileName = fileName; + this.mimeType = mimeType; + this.size = size; + this.documentType = documentType; + this.clientRequestId = clientRequestId; + this.content = content; + } + + public String rawToken() { + return rawToken; + } + + public String fileName() { + return fileName; + } + + public String mimeType() { + return mimeType; + } + + public long size() { + return size; + } + + public String documentType() { + return documentType; + } + + public String clientRequestId() { + return clientRequestId; + } + + public InputStream content() { + return content; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadResult.java new file mode 100644 index 0000000..635294b --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadResult.java @@ -0,0 +1,7 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.file.domain.StoredFile; +import java.time.Instant; + +public record WorkerLinkDocumentUploadResult(StoredFile storedFile, Instant linkExpiresAt) { +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java new file mode 100644 index 0000000..ba3c0f7 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java @@ -0,0 +1,53 @@ +package com.fowoco.server.workerlink.application; + +import java.util.UUID; + +public final class WorkerLinkIssueCommand { + + private final UUID taskId; + private final UUID companyId; + private final Long expiresInHours; + private final boolean rotateExisting; + private final UUID issuedBy; + private final String idempotencyKey; + + public WorkerLinkIssueCommand( + UUID taskId, + UUID companyId, + Long expiresInHours, + boolean rotateExisting, + UUID issuedBy, + String idempotencyKey + ) { + this.taskId = taskId; + this.companyId = companyId; + this.expiresInHours = expiresInHours; + this.rotateExisting = rotateExisting; + this.issuedBy = issuedBy; + this.idempotencyKey = idempotencyKey; + } + + public UUID taskId() { + return taskId; + } + + public UUID companyId() { + return companyId; + } + + public Long expiresInHours() { + return expiresInHours; + } + + public boolean rotateExisting() { + return rotateExisting; + } + + public UUID issuedBy() { + return issuedBy; + } + + public String idempotencyKey() { + return idempotencyKey; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueResult.java new file mode 100644 index 0000000..cacd34c --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueResult.java @@ -0,0 +1,6 @@ +package com.fowoco.server.workerlink.application; + +import java.time.Instant; + +public record WorkerLinkIssueResult(String rawToken, Instant expiresAt) { +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java new file mode 100644 index 0000000..e0af314 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java @@ -0,0 +1,107 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.approval.application.port.ApprovalRequestRepository; +import com.fowoco.server.approval.domain.ApprovalRequest; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; +import com.fowoco.server.workerlink.application.port.WorkerLinkGenerator; +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.infrastructure.security.WorkerLinkHasher; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkerLinkService { + + private static final long DEFAULT_EXPIRES_IN_HOURS = 72L; + + private final TaskRepository taskRepository; + private final ApprovalRequestRepository approvalRequestRepository; + private final WorkerLinkRepository workerLinkRepository; + private final WorkerLinkGenerator workerLinkGenerator; + private final WorkerLinkHasher workerLinkHasher; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public WorkerLinkService( + TaskRepository taskRepository, + ApprovalRequestRepository approvalRequestRepository, + WorkerLinkRepository workerLinkRepository, + WorkerLinkGenerator workerLinkGenerator, + WorkerLinkHasher workerLinkHasher, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.taskRepository = taskRepository; + this.approvalRequestRepository = approvalRequestRepository; + this.workerLinkRepository = workerLinkRepository; + this.workerLinkGenerator = workerLinkGenerator; + this.workerLinkHasher = workerLinkHasher; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) { + Task task = taskRepository.findByIdAndCompanyId(command.taskId(), command.companyId()) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.TASK_NOT_FOUND)); + + ApprovalRequest approval = approvalRequestRepository + .findLatestApprovedByTaskIdAndCompanyId(command.taskId(), command.companyId()) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.TASK_NOT_APPROVED)); + + if (!approval.isValidFor(task.contentRevision(), task.criticalFingerprint())) { + throw new ApiException(WorkerLinkErrorCode.TASK_NOT_APPROVED); + } + + String idempotencyKeyHash = workerLinkHasher.hash(command.idempotencyKey()); + Optional existingByIdempotency = workerLinkRepository + .findByTaskIdAndIdempotencyKey(command.taskId(), idempotencyKeyHash); + if (existingByIdempotency.isPresent()) { + WorkerLink previous = existingByIdempotency.get(); + return new WorkerLinkIssueResult(previous.tokenHash(), previous.expiresAt()); + } + + Instant now = clock.instant(); + Optional existingActive = workerLinkRepository + .findActiveByTaskIdAndCompanyId(command.taskId(), command.companyId()); + + WorkerLink previousLink = null; + if (existingActive.isPresent()) { + if (!command.rotateExisting()) { + throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_ISSUANCE_CONFLICT); + } + previousLink = existingActive.get(); + workerLinkRepository.update(previousLink.revoke(now)); + } + + long hours = command.expiresInHours() != null ? command.expiresInHours() : DEFAULT_EXPIRES_IN_HOURS; + Instant expiresAt = now.plus(Duration.ofHours(hours)); + + WorkerLinkGenerator.GeneratedWorkerLinkToken generated = workerLinkGenerator.generate(); + + WorkerLink workerLink = WorkerLink.issue( + uuidGenerator.generate(), + command.taskId(), + command.companyId(), + generated.tokenHash(), + expiresAt, + command.issuedBy(), + previousLink != null ? previousLink.workerLinkId() : null, + idempotencyKeyHash, + now + ); + workerLinkRepository.insert(workerLink); + + return new WorkerLinkIssueResult(generated.rawValue(), expiresAt); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewResult.java new file mode 100644 index 0000000..4d4fff0 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewResult.java @@ -0,0 +1,12 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.workerlink.domain.WorkerResponseType; +import java.time.LocalDate; +import java.util.List; + +public record WorkerLinkViewResult( + String guidance, + LocalDate dueDate, + List allowedResponses +) { +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java new file mode 100644 index 0000000..ee8c256 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java @@ -0,0 +1,67 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.domain.WorkerResponseType; +import com.fowoco.server.workerlink.infrastructure.security.WorkerLinkHasher; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkerLinkViewService { + + private final WorkerLinkTenantBootstrap workerLinkTenantBootstrap; + private final TenantDatabaseContext tenantDatabaseContext; + private final WorkerLinkRepository workerLinkRepository; + private final WorkerLinkHasher workerLinkHasher; + private final Clock clock; + + public WorkerLinkViewService( + WorkerLinkTenantBootstrap workerLinkTenantBootstrap, + TenantDatabaseContext tenantDatabaseContext, + WorkerLinkRepository workerLinkRepository, + WorkerLinkHasher workerLinkHasher, + Clock clock + ) { + this.workerLinkTenantBootstrap = workerLinkTenantBootstrap; + this.tenantDatabaseContext = tenantDatabaseContext; + this.workerLinkRepository = workerLinkRepository; + this.workerLinkHasher = workerLinkHasher; + this.clock = clock; + } + + @Transactional + public WorkerLinkViewResult view(String rawToken) { + String tokenHash = workerLinkHasher.hash(rawToken); + + UUID companyId = workerLinkTenantBootstrap + .findCompanyIdByWorkerLinkTokenHash(tokenHash) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + + WorkerLink link = workerLinkRepository.findByTokenHash(tokenHash) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + + Instant now = clock.instant(); + if (!link.isUsable(now)) { + throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND); + } + + // document-request-draft 연동해서 guidance/dueDate 실제 값 채우기. + // AI Agent(Language Agent) 다음에 다시 확인 + return new WorkerLinkViewResult( + "document-request-draft 연동 전", + null, + List.of(WorkerResponseType.values()) + ); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java new file mode 100644 index 0000000..158e296 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java @@ -0,0 +1,144 @@ +package com.fowoco.server.workerlink.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.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.StoredFile; +import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; +import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.domain.WorkerResponse; +import com.fowoco.server.workerlink.domain.WorkerResponseType; +import com.fowoco.server.workerlink.infrastructure.security.WorkerLinkHasher; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkerResponseService { + + private static final String AUDIT_EVENT_VERSION = "1"; + + private final WorkerLinkTenantBootstrap workerLinkTenantBootstrap; + private final TenantDatabaseContext tenantDatabaseContext; + private final WorkerLinkRepository workerLinkRepository; + private final WorkerResponseRepository workerResponseRepository; + private final WorkerLinkHasher workerLinkHasher; + private final StoredFileRepository storedFileRepository; + private final AuditEventRepository auditRepository; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public WorkerResponseService( + WorkerLinkTenantBootstrap workerLinkTenantBootstrap, + TenantDatabaseContext tenantDatabaseContext, + WorkerLinkRepository workerLinkRepository, + WorkerResponseRepository workerResponseRepository, + WorkerLinkHasher workerLinkHasher, + StoredFileRepository storedFileRepository, + AuditEventRepository auditRepository, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.workerLinkTenantBootstrap = workerLinkTenantBootstrap; + this.tenantDatabaseContext = tenantDatabaseContext; + this.workerLinkRepository = workerLinkRepository; + this.workerResponseRepository = workerResponseRepository; + this.workerLinkHasher = workerLinkHasher; + this.storedFileRepository = storedFileRepository; + this.auditRepository = auditRepository; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, RequestMetadata metadata) { + String tokenHash = workerLinkHasher.hash(command.rawToken()); + + UUID companyId = workerLinkTenantBootstrap + .findCompanyIdByWorkerLinkTokenHash(tokenHash) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + + WorkerLink link = workerLinkRepository.findByTokenHash(tokenHash) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + + Instant now = clock.instant(); + if (!link.isUsable(now)) { + throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND); + } + + Optional existing = workerResponseRepository + .findByWorkerLinkIdAndIdempotencyKey(link.workerLinkId(), command.idempotencyKey()); + if (existing.isPresent()) { + WorkerResponse previous = existing.get(); + return new WorkerResponseSubmitResult(previous.responseId(), previous.receivedAt()); + } + + List uploadIds = command.uploadIds() != null ? command.uploadIds() : List.of(); + for (UUID uploadId : uploadIds) { + StoredFile storedFile = storedFileRepository.findByIdAndCompanyId(uploadId, companyId) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE)); + if (!storedFile.verified() || !link.taskId().equals(storedFile.taskId())) { + throw new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE); + } + if (workerResponseRepository.isUploadAlreadyLinked(uploadId)) { + throw new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE); + } + } + + UUID responseId = uuidGenerator.generate(); + WorkerResponse response = WorkerResponse.create( + responseId, + link.workerLinkId(), + companyId, + command.responseType(), + command.message(), + command.idempotencyKey(), + now + ); + workerResponseRepository.insert(response); + + for (UUID uploadId : uploadIds) { + workerResponseRepository.linkUpload(responseId, uploadId); + } + + if (command.responseType() == WorkerResponseType.QUESTION + || command.responseType() == WorkerResponseType.NOT_UNDERSTOOD) { + workerLinkRepository.update(link.markNeedsFollowup(now)); + // HR 후속 업무/활동 이력 + } + + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + companyId, + ActorType.WORKER_LINK, + null, + null, + AuditAction.WORKER_LINK_RESPONSE_SUBMITTED, + AuditTargetType.TASK, + link.taskId(), + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + "근로자 응답 제출: " + command.responseType(), + now + )); + + return new WorkerResponseSubmitResult(responseId, now); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitCommand.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitCommand.java new file mode 100644 index 0000000..9464699 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitCommand.java @@ -0,0 +1,48 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.workerlink.domain.WorkerResponseType; +import java.util.List; +import java.util.UUID; + +public final class WorkerResponseSubmitCommand { + + private final String rawToken; + private final WorkerResponseType responseType; + private final String message; + private final List uploadIds; + private final String idempotencyKey; + + public WorkerResponseSubmitCommand( + String rawToken, + WorkerResponseType responseType, + String message, + List uploadIds, + String idempotencyKey + ) { + this.rawToken = rawToken; + this.responseType = responseType; + this.message = message; + this.uploadIds = uploadIds; + this.idempotencyKey = idempotencyKey; + } + + public String rawToken() { + return rawToken; + } + + public WorkerResponseType responseType() { + return responseType; + } + + public String message() { + return message; + } + + public List uploadIds() { + return uploadIds; + } + + public String idempotencyKey() { + return idempotencyKey; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitResult.java new file mode 100644 index 0000000..296c694 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitResult.java @@ -0,0 +1,7 @@ +package com.fowoco.server.workerlink.application; + +import java.time.Instant; +import java.util.UUID; + +public record WorkerResponseSubmitResult(UUID responseId, Instant receivedAt) { +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java new file mode 100644 index 0000000..7bd9eb8 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java @@ -0,0 +1,45 @@ +package com.fowoco.server.workerlink.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum WorkerLinkErrorCode implements ApiErrorCode { + TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "업무카드를 찾을 수 없습니다."), + TASK_NOT_APPROVED( + HttpStatus.UNPROCESSABLE_CONTENT, + "승인된 업무카드에서만 근로자 링크를 발급할 수 있습니다." + ), + WORKER_LINK_ISSUANCE_CONFLICT( + HttpStatus.CONFLICT, + "이미 유효한 근로자 링크가 있습니다. rotateExisting=true로 재발급해 주세요." + ), + + WORKER_LINK_NOT_FOUND(HttpStatus.GONE, "링크를 찾을 수 없거나 더 이상 사용할 수 없습니다."), + UPLOAD_NOT_AVAILABLE( + HttpStatus.UNPROCESSABLE_CONTENT, + "업로드된 파일을 찾을 수 없거나 이미 사용된 파일입니다." + ); + + private final HttpStatus status; + private final String defaultMessage; + + WorkerLinkErrorCode(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/workerlink/application/port/WorkerLinkGenerator.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkGenerator.java new file mode 100644 index 0000000..ff3bc94 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkGenerator.java @@ -0,0 +1,9 @@ +package com.fowoco.server.workerlink.application.port; + +public interface WorkerLinkGenerator { + + GeneratedWorkerLinkToken generate(); + + record GeneratedWorkerLinkToken(String rawValue, String tokenHash) { + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java new file mode 100644 index 0000000..b4103ad --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java @@ -0,0 +1,18 @@ +package com.fowoco.server.workerlink.application.port; + +import com.fowoco.server.workerlink.domain.WorkerLink; +import java.util.Optional; +import java.util.UUID; + +public interface WorkerLinkRepository { + + void insert(WorkerLink workerLink); + + WorkerLink update(WorkerLink workerLink); + + Optional findByTokenHash(String tokenHash); + + Optional findActiveByTaskIdAndCompanyId(UUID taskId, UUID companyId); + + Optional findByTaskIdAndIdempotencyKey(UUID taskId, String idempotencyKey); +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkTenantBootstrap.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkTenantBootstrap.java new file mode 100644 index 0000000..ff866aa --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkTenantBootstrap.java @@ -0,0 +1,8 @@ +package com.fowoco.server.workerlink.application.port; + +import java.util.Optional; +import java.util.UUID; + +public interface WorkerLinkTenantBootstrap { + Optional findCompanyIdByWorkerLinkTokenHash(String tokenHash); +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java new file mode 100644 index 0000000..07ca385 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java @@ -0,0 +1,16 @@ +package com.fowoco.server.workerlink.application.port; + +import com.fowoco.server.workerlink.domain.WorkerResponse; +import java.util.Optional; +import java.util.UUID; + +public interface WorkerResponseRepository { + + void insert(WorkerResponse workerResponse); + + Optional findByWorkerLinkIdAndIdempotencyKey(UUID workerLinkId, String idempotencyKey); + + void linkUpload(UUID responseId, UUID storedFileId); + + boolean isUploadAlreadyLinked(UUID storedFileId); +} diff --git a/src/main/java/com/fowoco/server/workerlink/domain/ConversationStatus.java b/src/main/java/com/fowoco/server/workerlink/domain/ConversationStatus.java new file mode 100644 index 0000000..60e2cca --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/domain/ConversationStatus.java @@ -0,0 +1,7 @@ +package com.fowoco.server.workerlink.domain; + +public enum ConversationStatus { + WAITING_WORKER, + NEEDS_FOLLOWUP, + REOPENED +} diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java new file mode 100644 index 0000000..dc3be52 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java @@ -0,0 +1,173 @@ +package com.fowoco.server.workerlink.domain; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public final class WorkerLink { + + private final UUID workerLinkId; + private final UUID taskId; + private final UUID companyId; + private final String tokenHash; + private final Instant expiresAt; + private final WorkerLinkStatus status; + private final ConversationStatus conversationStatus; + private final UUID assigneeId; + private final UUID issuedBy; + private final UUID replacesLinkId; + private final String idempotencyKey; + private final Instant createdAt; + private final Instant updatedAt; + private final long version; + + public WorkerLink( + UUID workerLinkId, + UUID taskId, + UUID companyId, + String tokenHash, + Instant expiresAt, + WorkerLinkStatus status, + ConversationStatus conversationStatus, + UUID assigneeId, + UUID issuedBy, + UUID replacesLinkId, + String idempotencyKey, + Instant createdAt, + Instant updatedAt, + long version + ) { + this.workerLinkId = Objects.requireNonNull(workerLinkId, "workerLinkId 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.tokenHash = requireText(tokenHash, "tokenHash"); + this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt must not be null"); + this.status = Objects.requireNonNull(status, "status must not be null"); + this.conversationStatus = Objects.requireNonNull(conversationStatus, "conversationStatus must not be null"); + this.assigneeId = assigneeId; + this.issuedBy = Objects.requireNonNull(issuedBy, "issuedBy must not be null"); + this.replacesLinkId = replacesLinkId; + this.idempotencyKey = requireText(idempotencyKey, "idempotencyKey"); + 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 WorkerLink issue( + UUID workerLinkId, + UUID taskId, + UUID companyId, + String tokenHash, + Instant expiresAt, + UUID issuedBy, + UUID replacesLinkId, + String idempotencyKey, + Instant now + ) { + return new WorkerLink( + workerLinkId, + taskId, + companyId, + tokenHash, + expiresAt, + WorkerLinkStatus.ACTIVE, + ConversationStatus.WAITING_WORKER, + null, + issuedBy, + replacesLinkId, + idempotencyKey, + now, + now, + 0L + ); + } + + public WorkerLink revoke(Instant now) { + return new WorkerLink( + workerLinkId, taskId, companyId, tokenHash, expiresAt, + WorkerLinkStatus.REVOKED, conversationStatus, assigneeId, issuedBy, + replacesLinkId, idempotencyKey, createdAt, now, version + ); + } + + public WorkerLink markNeedsFollowup(Instant now) { + return new WorkerLink( + workerLinkId, taskId, companyId, tokenHash, expiresAt, + status, ConversationStatus.NEEDS_FOLLOWUP, assigneeId, issuedBy, + replacesLinkId, idempotencyKey, createdAt, now, version + ); + } + + public boolean isUsable(Instant now) { + return status == WorkerLinkStatus.ACTIVE && expiresAt.isAfter(now); + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value; + } + + public UUID workerLinkId() { + return workerLinkId; + } + + public UUID taskId() { + return taskId; + } + + public UUID companyId() { + return companyId; + } + + public String tokenHash() { + return tokenHash; + } + + public Instant expiresAt() { + return expiresAt; + } + + public WorkerLinkStatus status() { + return status; + } + + public ConversationStatus conversationStatus() { + return conversationStatus; + } + + public UUID assigneeId() { + return assigneeId; + } + + public UUID issuedBy() { + return issuedBy; + } + + public UUID replacesLinkId() { + return replacesLinkId; + } + + public String idempotencyKey() { + return idempotencyKey; + } + + public Instant createdAt() { + return createdAt; + } + + public Instant updatedAt() { + return updatedAt; + } + + public long version() { + return version; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerLinkStatus.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLinkStatus.java new file mode 100644 index 0000000..3a0c548 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLinkStatus.java @@ -0,0 +1,7 @@ +package com.fowoco.server.workerlink.domain; + +public enum WorkerLinkStatus { + ACTIVE, + EXPIRED, + REVOKED +} diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponse.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponse.java new file mode 100644 index 0000000..6ba82c1 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponse.java @@ -0,0 +1,94 @@ +package com.fowoco.server.workerlink.domain; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public final class WorkerResponse { + + private static final int MAX_MESSAGE_LENGTH = 1000; + + private final UUID responseId; + private final UUID workerLinkId; + private final UUID companyId; + private final WorkerResponseType responseType; + private final String message; + private final String idempotencyKey; + private final Instant receivedAt; + + public WorkerResponse( + UUID responseId, + UUID workerLinkId, + UUID companyId, + WorkerResponseType responseType, + String message, + String idempotencyKey, + Instant receivedAt + ) { + this.responseId = Objects.requireNonNull(responseId, "responseId must not be null"); + this.workerLinkId = Objects.requireNonNull(workerLinkId, "workerLinkId must not be null"); + this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); + this.responseType = Objects.requireNonNull(responseType, "responseType must not be null"); + this.message = normalizeMessage(message); + this.idempotencyKey = requireText(idempotencyKey, "idempotencyKey"); + this.receivedAt = Objects.requireNonNull(receivedAt, "receivedAt must not be null"); + } + + public static WorkerResponse create( + UUID responseId, + UUID workerLinkId, + UUID companyId, + WorkerResponseType responseType, + String message, + String idempotencyKey, + Instant now + ) { + return new WorkerResponse(responseId, workerLinkId, companyId, responseType, message, idempotencyKey, now); + } + + private static String normalizeMessage(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.strip(); + if (normalized.length() > MAX_MESSAGE_LENGTH) { + throw new IllegalArgumentException("message must not exceed " + MAX_MESSAGE_LENGTH + " characters"); + } + return normalized; + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value; + } + + public UUID responseId() { + return responseId; + } + + public UUID workerLinkId() { + return workerLinkId; + } + + public UUID companyId() { + return companyId; + } + + public WorkerResponseType responseType() { + return responseType; + } + + public String message() { + return message; + } + + public String idempotencyKey() { + return idempotencyKey; + } + + public Instant receivedAt() { + return receivedAt; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponseType.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponseType.java new file mode 100644 index 0000000..50a8ebf --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponseType.java @@ -0,0 +1,9 @@ +package com.fowoco.server.workerlink.domain; + +public enum WorkerResponseType { + ACKNOWLEDGED, + QUESTION, + NOT_UNDERSTOOD, + DOCUMENT_SUBMITTED, + DIFFICULT +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java new file mode 100644 index 0000000..288f3cd --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java @@ -0,0 +1,98 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.domain.WorkerLinkStatus; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaWorkerLinkRepository implements WorkerLinkRepository { + + private final EntityManager entityManager; + + public JpaWorkerLinkRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public void insert(WorkerLink workerLink) { + Objects.requireNonNull(workerLink, "workerLink must not be null"); + entityManager.persist(WorkerLinkJpaEntity.fromDomain(workerLink)); + entityManager.flush(); + } + + @Override + public WorkerLink update(WorkerLink workerLink) { + Objects.requireNonNull(workerLink, "workerLink must not be null"); + WorkerLinkJpaEntity entity = entityManager.find(WorkerLinkJpaEntity.class, workerLink.workerLinkId()); + if (entity == null) { + throw new IllegalStateException("worker link to update was not found"); + } + entity.applyState(workerLink); + entityManager.flush(); + return entity.toDomain(); + } + + @Override + public Optional findByTokenHash(String tokenHash) { + Objects.requireNonNull(tokenHash, "tokenHash must not be null"); + return entityManager.createQuery( + """ + select link + from WorkerLinkJpaEntity link + where link.tokenHash = :tokenHash + """, + WorkerLinkJpaEntity.class + ) + .setParameter("tokenHash", tokenHash) + .getResultStream() + .findFirst() + .map(WorkerLinkJpaEntity::toDomain); + } + + @Override + public Optional findActiveByTaskIdAndCompanyId(UUID taskId, UUID companyId) { + Objects.requireNonNull(taskId, "taskId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + return entityManager.createQuery( + """ + select link + from WorkerLinkJpaEntity link + where link.taskId = :taskId + and link.companyId = :companyId + and link.status = :status + """, + WorkerLinkJpaEntity.class + ) + .setParameter("taskId", taskId) + .setParameter("companyId", companyId) + .setParameter("status", WorkerLinkStatus.ACTIVE) + .getResultStream() + .findFirst() + .map(WorkerLinkJpaEntity::toDomain); + } + + @Override + public Optional findByTaskIdAndIdempotencyKey(UUID taskId, String idempotencyKey) { + Objects.requireNonNull(taskId, "taskId must not be null"); + Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null"); + return entityManager.createQuery( + """ + select link + from WorkerLinkJpaEntity link + where link.taskId = :taskId + and link.idempotencyKey = :idempotencyKey + """, + WorkerLinkJpaEntity.class + ) + .setParameter("taskId", taskId) + .setParameter("idempotencyKey", idempotencyKey) + .getResultStream() + .findFirst() + .map(WorkerLinkJpaEntity::toDomain); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkTenantBootstrap.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkTenantBootstrap.java new file mode 100644 index 0000000..4e8bd2f --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkTenantBootstrap.java @@ -0,0 +1,41 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +@Repository +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "transaction-only", + matchIfMissing = true +) +public class JpaWorkerLinkTenantBootstrap implements WorkerLinkTenantBootstrap { + + private final EntityManager entityManager; + + public JpaWorkerLinkTenantBootstrap(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Optional findCompanyIdByWorkerLinkTokenHash(String tokenHash) { + Objects.requireNonNull(tokenHash, "tokenHash must not be null"); + return entityManager.createQuery( + """ + select link.companyId + from WorkerLinkJpaEntity link + where link.tokenHash = :tokenHash + """, + UUID.class + ) + .setParameter("tokenHash", tokenHash) + .setMaxResults(1) + .getResultStream() + .findFirst(); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java new file mode 100644 index 0000000..d30252c --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java @@ -0,0 +1,70 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; +import com.fowoco.server.workerlink.domain.WorkerResponse; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaWorkerResponseRepository implements WorkerResponseRepository { + + private final EntityManager entityManager; + + public JpaWorkerResponseRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public void insert(WorkerResponse workerResponse) { + Objects.requireNonNull(workerResponse, "workerResponse must not be null"); + entityManager.persist(WorkerResponseJpaEntity.fromDomain(workerResponse)); + entityManager.flush(); + } + + @Override + public Optional findByWorkerLinkIdAndIdempotencyKey(UUID workerLinkId, String idempotencyKey) { + Objects.requireNonNull(workerLinkId, "workerLinkId must not be null"); + Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null"); + return entityManager.createQuery( + """ + select response + from WorkerResponseJpaEntity response + where response.workerLinkId = :workerLinkId + and response.idempotencyKey = :idempotencyKey + """, + WorkerResponseJpaEntity.class + ) + .setParameter("workerLinkId", workerLinkId) + .setParameter("idempotencyKey", idempotencyKey) + .getResultStream() + .findFirst() + .map(WorkerResponseJpaEntity::toDomain); + } + + @Override + public void linkUpload(UUID responseId, UUID storedFileId) { + Objects.requireNonNull(responseId, "responseId must not be null"); + Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + Query query = entityManager.createNativeQuery( + "INSERT INTO worker_response_upload (response_id, stored_file_id) VALUES (?1, ?2)" + ); + query.setParameter(1, responseId); + query.setParameter(2, storedFileId); + query.executeUpdate(); + } + + @Override + public boolean isUploadAlreadyLinked(UUID storedFileId) { + Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + Long count = (Long) entityManager.createNativeQuery( + "SELECT COUNT(*) FROM worker_response_upload WHERE stored_file_id = ?1" + ) + .setParameter(1, storedFileId) + .getSingleResult(); + return count != null && count > 0; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/PostgreSqlWorkerLinkTenantBootstrap.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/PostgreSqlWorkerLinkTenantBootstrap.java new file mode 100644 index 0000000..b0deaa0 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/PostgreSqlWorkerLinkTenantBootstrap.java @@ -0,0 +1,42 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +@Repository +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "postgresql" +) +public class PostgreSqlWorkerLinkTenantBootstrap implements WorkerLinkTenantBootstrap { + + private static final String WORKER_LINK_BOOTSTRAP_SQL = """ + SELECT public.bootstrap_company_id_by_worker_link_token_hash(?1) + """; + + private final EntityManager entityManager; + + public PostgreSqlWorkerLinkTenantBootstrap(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Optional findCompanyIdByWorkerLinkTokenHash(String tokenHash) { + Objects.requireNonNull(tokenHash, "tokenHash must not be null"); + Object result = entityManager.createNativeQuery(WORKER_LINK_BOOTSTRAP_SQL) + .setParameter(1, tokenHash) + .getSingleResult(); + if (result == null) { + return Optional.empty(); + } + if (result instanceof UUID companyId) { + return Optional.of(companyId); + } + return Optional.of(UUID.fromString(result.toString())); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java new file mode 100644 index 0000000..1c95803 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java @@ -0,0 +1,158 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import com.fowoco.server.workerlink.domain.ConversationStatus; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.domain.WorkerLinkStatus; +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 jakarta.persistence.Version; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "worker_link") +public class WorkerLinkJpaEntity { + + @Id + @Column(name = "worker_link_id", nullable = false, updatable = false) + private UUID workerLinkId; + + @Column(name = "task_id", nullable = false, updatable = false) + private UUID taskId; + + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Column(name = "token_hash", nullable = false, updatable = false, length = 64) + private String tokenHash; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private WorkerLinkStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "conversation_status", nullable = false, length = 20) + private ConversationStatus conversationStatus; + + @Column(name = "assignee_id") + private UUID assigneeId; + + @Column(name = "issued_by", nullable = false, updatable = false) + private UUID issuedBy; + + @Column(name = "replaces_link_id", updatable = false) + private UUID replacesLinkId; + + @Column(name = "idempotency_key", nullable = false, updatable = false, length = 100) + private String idempotencyKey; + + @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 WorkerLinkJpaEntity() { + } + + private WorkerLinkJpaEntity( + UUID workerLinkId, + UUID taskId, + UUID companyId, + String tokenHash, + Instant expiresAt, + WorkerLinkStatus status, + ConversationStatus conversationStatus, + UUID assigneeId, + UUID issuedBy, + UUID replacesLinkId, + String idempotencyKey, + Instant createdAt, + Instant updatedAt, + long version + ) { + this.workerLinkId = workerLinkId; + this.taskId = taskId; + this.companyId = companyId; + this.tokenHash = tokenHash; + this.expiresAt = expiresAt; + this.status = status; + this.conversationStatus = conversationStatus; + this.assigneeId = assigneeId; + this.issuedBy = issuedBy; + this.replacesLinkId = replacesLinkId; + this.idempotencyKey = idempotencyKey; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.version = version; + } + + public static WorkerLinkJpaEntity fromDomain(WorkerLink workerLink) { + Objects.requireNonNull(workerLink, "workerLink must not be null"); + return new WorkerLinkJpaEntity( + workerLink.workerLinkId(), + workerLink.taskId(), + workerLink.companyId(), + workerLink.tokenHash(), + workerLink.expiresAt(), + workerLink.status(), + workerLink.conversationStatus(), + workerLink.assigneeId(), + workerLink.issuedBy(), + workerLink.replacesLinkId(), + workerLink.idempotencyKey(), + workerLink.createdAt(), + workerLink.updatedAt(), + workerLink.version() + ); + } + + public WorkerLink toDomain() { + return new WorkerLink( + workerLinkId, + taskId, + companyId, + tokenHash, + expiresAt, + status, + conversationStatus, + assigneeId, + issuedBy, + replacesLinkId, + idempotencyKey, + createdAt, + updatedAt, + version + ); + } + + public void applyState(WorkerLink workerLink) { + Objects.requireNonNull(workerLink, "workerLink must not be null"); + if (!workerLinkId.equals(workerLink.workerLinkId()) + || !taskId.equals(workerLink.taskId()) + || !companyId.equals(workerLink.companyId()) + || !tokenHash.equals(workerLink.tokenHash()) + || !createdAt.equals(workerLink.createdAt())) { + throw new IllegalArgumentException("immutable worker link fields must not change"); + } + if (version != workerLink.version()) { + throw new IllegalArgumentException("worker link version does not match"); + } + this.status = workerLink.status(); + this.conversationStatus = workerLink.conversationStatus(); + this.assigneeId = workerLink.assigneeId(); + this.updatedAt = workerLink.updatedAt(); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerResponseJpaEntity.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerResponseJpaEntity.java new file mode 100644 index 0000000..d369baa --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerResponseJpaEntity.java @@ -0,0 +1,87 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import com.fowoco.server.workerlink.domain.WorkerResponse; +import com.fowoco.server.workerlink.domain.WorkerResponseType; +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 = "worker_response") +public class WorkerResponseJpaEntity { + + @Id + @Column(name = "response_id", nullable = false, updatable = false) + private UUID responseId; + + @Column(name = "worker_link_id", nullable = false, updatable = false) + private UUID workerLinkId; + + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Enumerated(EnumType.STRING) + @Column(name = "response_type", nullable = false, updatable = false, length = 30) + private WorkerResponseType responseType; + + @Column(name = "message", updatable = false, length = 1000) + private String message; + + @Column(name = "idempotency_key", nullable = false, updatable = false, length = 100) + private String idempotencyKey; + + @Column(name = "received_at", nullable = false, updatable = false) + private Instant receivedAt; + + protected WorkerResponseJpaEntity() { + } + + private WorkerResponseJpaEntity( + UUID responseId, + UUID workerLinkId, + UUID companyId, + WorkerResponseType responseType, + String message, + String idempotencyKey, + Instant receivedAt + ) { + this.responseId = responseId; + this.workerLinkId = workerLinkId; + this.companyId = companyId; + this.responseType = responseType; + this.message = message; + this.idempotencyKey = idempotencyKey; + this.receivedAt = receivedAt; + } + + public static WorkerResponseJpaEntity fromDomain(WorkerResponse response) { + Objects.requireNonNull(response, "response must not be null"); + return new WorkerResponseJpaEntity( + response.responseId(), + response.workerLinkId(), + response.companyId(), + response.responseType(), + response.message(), + response.idempotencyKey(), + response.receivedAt() + ); + } + + public WorkerResponse toDomain() { + return new WorkerResponse( + responseId, + workerLinkId, + companyId, + responseType, + message, + idempotencyKey, + receivedAt + ); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/security/SecureWorkerLinkGenerator.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/security/SecureWorkerLinkGenerator.java new file mode 100644 index 0000000..bc00799 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/security/SecureWorkerLinkGenerator.java @@ -0,0 +1,30 @@ +package com.fowoco.server.workerlink.infrastructure.security; + +import com.fowoco.server.workerlink.application.port.WorkerLinkGenerator; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Objects; +import org.springframework.stereotype.Component; + +@Component +public final class SecureWorkerLinkGenerator implements WorkerLinkGenerator { + + private static final int ENTROPY_BYTES = 32; + + private final SecureRandom secureRandom; + private final WorkerLinkHasher workerLinkHasher; + + public SecureWorkerLinkGenerator(WorkerLinkHasher workerLinkHasher) { + this.secureRandom = new SecureRandom(); + this.workerLinkHasher = Objects.requireNonNull(workerLinkHasher, "workerLinkHasher must not be null"); + } + + @Override + public GeneratedWorkerLinkToken generate() { + byte[] tokenBytes = new byte[ENTROPY_BYTES]; + secureRandom.nextBytes(tokenBytes); + String rawValue = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + String tokenHash = workerLinkHasher.hash(rawValue); + return new GeneratedWorkerLinkToken(rawValue, tokenHash); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/security/WorkerLinkHasher.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/security/WorkerLinkHasher.java new file mode 100644 index 0000000..5a08fa6 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/security/WorkerLinkHasher.java @@ -0,0 +1,24 @@ +package com.fowoco.server.workerlink.infrastructure.security; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import org.springframework.stereotype.Component; + +@Component +public final class WorkerLinkHasher { + + public String hash(String rawToken) { + if (rawToken == null || rawToken.isBlank()) { + throw new IllegalArgumentException("rawToken must not be blank"); + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hashed); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } +} diff --git a/src/main/resources/db/migration-postgresql/V13__worker_link_tenant_bootstrap.sql b/src/main/resources/db/migration-postgresql/V13__worker_link_tenant_bootstrap.sql new file mode 100644 index 0000000..bbba055 --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V13__worker_link_tenant_bootstrap.sql @@ -0,0 +1,18 @@ +CREATE FUNCTION public.bootstrap_company_id_by_worker_link_token_hash( + p_token_hash TEXT +) +RETURNS UUID +LANGUAGE SQL +STABLE +SECURITY DEFINER +SET search_path = pg_catalog, public, pg_temp +AS $$ + SELECT link.company_id + FROM public.worker_link AS link + WHERE link.token_hash = p_token_hash + LIMIT 1 +$$; + +REVOKE ALL + ON FUNCTION public.bootstrap_company_id_by_worker_link_token_hash(TEXT) + FROM PUBLIC; diff --git a/src/main/resources/db/migration/V11__create_worker_link.sql b/src/main/resources/db/migration/V11__create_worker_link.sql new file mode 100644 index 0000000..f2501d6 --- /dev/null +++ b/src/main/resources/db/migration/V11__create_worker_link.sql @@ -0,0 +1,74 @@ +CREATE TABLE worker_link ( + worker_link_id UUID NOT NULL, + task_id UUID NOT NULL, + company_id UUID NOT NULL, + token_hash VARCHAR(64) NOT NULL, + expires_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + status VARCHAR(20) NOT NULL, + conversation_status VARCHAR(20) NOT NULL, + assignee_id UUID, + issued_by UUID NOT NULL, + replaces_link_id UUID, + idempotency_key VARCHAR(100) 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_worker_link PRIMARY KEY (worker_link_id), + CONSTRAINT uq_worker_link_token_hash UNIQUE (token_hash), + CONSTRAINT uq_worker_link_task_idempotency UNIQUE (task_id, idempotency_key), + CONSTRAINT fk_worker_link_company + FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_link_task_company + FOREIGN KEY (task_id, company_id) + REFERENCES task (task_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_link_issued_by_company + FOREIGN KEY (issued_by, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_link_assignee_company + FOREIGN KEY (assignee_id, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_link_replaces + FOREIGN KEY (replaces_link_id) REFERENCES worker_link (worker_link_id) ON DELETE SET NULL, + CONSTRAINT ck_worker_link_status CHECK (status IN ('ACTIVE', 'EXPIRED', 'REVOKED')), + CONSTRAINT ck_worker_link_conversation_status + CHECK (conversation_status IN ('WAITING_WORKER', 'NEEDS_FOLLOWUP', 'REOPENED')), + CONSTRAINT ck_worker_link_hash_length CHECK (CHAR_LENGTH(token_hash) = 64), + CONSTRAINT ck_worker_link_hash_lowercase CHECK (token_hash = LOWER(token_hash)), + CONSTRAINT ck_worker_link_expiry CHECK (expires_at > created_at), + CONSTRAINT ck_worker_link_version CHECK (version >= 0), + CONSTRAINT ck_worker_link_updated_at CHECK (updated_at >= created_at) +); + +CREATE INDEX idx_worker_link_company ON worker_link (company_id); +CREATE INDEX idx_worker_link_task ON worker_link (task_id, company_id); + +CREATE TABLE worker_response ( + response_id UUID NOT NULL, + worker_link_id UUID NOT NULL, + company_id UUID NOT NULL, + response_type VARCHAR(30) NOT NULL, + message VARCHAR(1000), + idempotency_key VARCHAR(100) NOT NULL, + received_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_worker_response PRIMARY KEY (response_id), + CONSTRAINT uq_worker_response_idempotency UNIQUE (worker_link_id, idempotency_key), + CONSTRAINT fk_worker_response_company + FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_response_link + FOREIGN KEY (worker_link_id) REFERENCES worker_link (worker_link_id) ON DELETE RESTRICT, + CONSTRAINT ck_worker_response_type + CHECK (response_type IN ('ACKNOWLEDGED', 'QUESTION', 'NOT_UNDERSTOOD', 'DOCUMENT_SUBMITTED', 'DIFFICULT')) +); + +CREATE INDEX idx_worker_response_link ON worker_response (worker_link_id); +CREATE INDEX idx_worker_response_company ON worker_response (company_id); + +CREATE TABLE worker_response_upload ( + response_id UUID NOT NULL, + stored_file_id UUID NOT NULL, + CONSTRAINT pk_worker_response_upload PRIMARY KEY (response_id, stored_file_id), + CONSTRAINT fk_worker_response_upload_response + FOREIGN KEY (response_id) REFERENCES worker_response (response_id) ON DELETE CASCADE, + CONSTRAINT fk_worker_response_upload_file + FOREIGN KEY (stored_file_id) REFERENCES stored_file (stored_file_id) ON DELETE RESTRICT +); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 67b839c..2111aad 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -244,6 +244,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsExactlyInAnyOrder( "bootstrap_company_id_by_normalized_email", "bootstrap_company_id_by_refresh_token_hash", + "bootstrap_company_id_by_worker_link_token_hash", "bootstrap_claim_event_publications", "bootstrap_count_outstanding_event_publications", "bootstrap_oldest_outstanding_event_occurred_at" @@ -252,6 +253,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsExactlyInAnyOrder( "bootstrap_company_id_by_normalized_email", "bootstrap_company_id_by_refresh_token_hash", + "bootstrap_company_id_by_worker_link_token_hash", "bootstrap_claim_event_publications", "bootstrap_count_outstanding_event_publications", "bootstrap_oldest_outstanding_event_occurred_at" diff --git a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java new file mode 100644 index 0000000..d0d4551 --- /dev/null +++ b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java @@ -0,0 +1,359 @@ +package com.fowoco.server.workerlink; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +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.Map; +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 WorkerLinkSecurityIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("A0000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("B0000000-0000-0000-0000-000000000002"); + private static final UUID HR_A = UUID.fromString("A1000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("B1000000-0000-0000-0000-000000000002"); + private static final String HR_A_EMAIL = "hr.link.a@example.com"; + private static final String HR_B_EMAIL = "hr.link.b@example.com"; + private static final String PASSWORD = "Test-password-1!"; + private static final String BOUNDARY = "FowocoLinkTestBoundary1234"; + + @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 worker_response_upload"); + jdbcTemplate.update("DELETE FROM worker_response"); + jdbcTemplate.update("DELETE FROM worker_link"); + 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() { + resetState(); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + } + + @Test + void fullFlow_issueViewUploadRespond_succeeds() throws Exception { + String hrToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(hrToken, "전체흐름테스트근로자"); + String taskId = createApprovedTask(hrToken, workerId); + + HttpResponse issueResponse = postJsonWithIdempotencyKey( + "/api/v1/tasks/" + taskId + "/worker-link", + """ + {"expires_in_hours":72,"rotate_existing":false} + """, + hrToken, + "fullflow-issue-key" + ); + assertThat(issueResponse.statusCode()).as("issue response body: %s", issueResponse.body()).isEqualTo(201); + String workerUrl = JsonPath.read(issueResponse.body(), "$.worker_url"); + assertThat(workerUrl).isNotBlank(); + + HttpResponse viewResponse = getJson("/api/v1/public/worker-links/" + workerUrl, null); + assertThat(viewResponse.statusCode()).isEqualTo(200); + assertThat(viewResponse.headers().firstValue("Cache-Control")).contains("no-store"); + + HttpResponse uploadResponse = uploadFile(workerUrl, "passport.pdf", "application/pdf", "content".getBytes(StandardCharsets.UTF_8)); + assertThat(uploadResponse.statusCode()).isEqualTo(201); + String uploadId = JsonPath.read(uploadResponse.body(), "$.upload_id"); + + jdbcTemplate.update("UPDATE stored_file SET verified = true WHERE stored_file_id = ?", UUID.fromString(uploadId)); + + HttpResponse responseSubmit = postJson( + "/api/v1/public/worker-links/" + workerUrl + "/responses", + """ + {"response_type":"DOCUMENT_SUBMITTED","upload_ids":["%s"],"idempotency_key":"key-1"} + """.formatted(uploadId), + null + ); + assertThat(responseSubmit.statusCode()).isEqualTo(201); + assertThat(JsonPath.read(responseSubmit.body(), "$.response_id")).isNotBlank(); + } + + @Test + void issueRejectsUnapprovedTask() throws Exception { + String hrToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(hrToken, "미승인테스트근로자"); + String taskId = createUnapprovedTask(hrToken, workerId); + + HttpResponse issueResponse = postJsonWithIdempotencyKey( + "/api/v1/tasks/" + taskId + "/worker-link", + """ + {"expires_in_hours":72,"rotate_existing":false} + """, + hrToken, + "unapproved-issue-key" + ); + + assertThat(issueResponse.statusCode()).isEqualTo(422); + } + + @Test + void issueRejectsOtherCompanyTask() throws Exception { + String hrTokenA = accessToken(login(HR_A_EMAIL)); + String hrTokenB = accessToken(login(HR_B_EMAIL)); + String workerId = registerWorker(hrTokenA, "타사업장테스트근로자"); + String taskId = createApprovedTask(hrTokenA, workerId); + + HttpResponse issueResponse = postJsonWithIdempotencyKey( + "/api/v1/tasks/" + taskId + "/worker-link", + """ + {"expires_in_hours":72,"rotate_existing":false} + """, + hrTokenB, + "othercompany-issue-key" + ); + + assertThat(issueResponse.statusCode()).isEqualTo(404); + } + + @Test + void viewReturns410ForNonExistentToken() throws Exception { + HttpResponse viewResponse = getJson("/api/v1/public/worker-links/nonexistenttoken12345", null); + assertThat(viewResponse.statusCode()).isEqualTo(410); + } + + 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 createApprovedTask(String token, String workerId) throws Exception { + String taskId = createUnapprovedTask(token, workerId); + completeRequiredChecklistItems(taskId, token); + + String approvalBody = """ + { + "expected_version":0, + "ai_snapshot":{"intent":"EXPIRY_RENEWAL","confidence":0.94}, + "hr_snapshot":{"worker_id":"%s","contract_end_date":"2027-08-31","monthly_wage":2500000}, + "changed_fields":["monthly_wage"], + "source_versions":{"agent_version":"agent-1","workflow_catalog_version":"2026.07"} + } + """.formatted(workerId); + HttpResponse approvalRequest = postJson( + "/api/v1/tasks/" + taskId + "/approval-requests", approvalBody, token + ); + assertThat(approvalRequest.statusCode()).as("approval response body: %s", approvalRequest.body()).isEqualTo(201); + + HttpResponse approve = postJson( + "/api/v1/tasks/" + taskId + "/approve", + """ + {"expected_version":1,"reason":"테스트 승인"} + """, + token + ); + assertThat(approve.statusCode()).as("approve response body: %s", approve.body()).isEqualTo(200); + return taskId; + } + + private void completeRequiredChecklistItems(String taskId, String token) throws Exception { + HttpResponse detail = getJson("/api/v1/tasks/" + taskId, token); + assertThat(detail.statusCode()).isEqualTo(200); + + List> checklistItems = JsonPath.read(detail.body(), "$.checklist_items"); + for (Map item : checklistItems) { + boolean required = (boolean) item.get("required"); + if (!required) { + continue; + } + String itemId = (String) item.get("checklist_item_id"); + int itemVersion = (int) item.get("version"); + int taskVersion = ((Number) JsonPath.read(detail.body(), "$.version")).intValue(); + + HttpResponse patchResponse = patchJson( + "/api/v1/tasks/" + taskId + "/checklist-items/" + itemId, + """ + {"completed":true,"expected_version":%d,"expected_task_version":%d} + """.formatted(itemVersion, taskVersion), + token + ); + assertThat(patchResponse.statusCode()) + .as("checklist patch response: %s", patchResponse.body()) + .isEqualTo(200); + } + } + + private String createUnapprovedTask(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 HttpResponse uploadFile(String token, String filename, String mimeType, byte[] content) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writePart(out, "file", filename, mimeType, content); + writeFieldPart(out, "clientRequestId", UUID.randomUUID().toString()); + out.write(("--" + BOUNDARY + "--\r\n").getBytes(StandardCharsets.UTF_8)); + + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/public/worker-links/" + token + "/documents")) + .header(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=" + BOUNDARY) + .POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray())) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private void writePart(ByteArrayOutputStream out, String name, String filename, String mimeType, byte[] content) + throws 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(ByteArrayOutputStream out, String name, String value) throws 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.Builder builder = HttpRequest.newBuilder(uri(path)).GET(); + if (token != null) { + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse postJson(String path, String body, String token) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (token != null) { + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse postJsonWithIdempotencyKey(String path, String body, String token, String idempotencyKey) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .header("Idempotency-Key", idempotencyKey) + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (token != null) { + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse patchJson(String path, String body, String token) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .method("PATCH", HttpRequest.BodyPublishers.ofString(body)); + 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); + } +}