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 e91a8881..df171520 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -20,6 +20,7 @@ public enum AuditAction { AI_RUN_ANSWERS_SUBMITTED, AI_RUN_CANDIDATES_DECIDED, OUTBOX_MANUAL_RETRY_REQUESTED, + WORKER_LINK_SENT, WORKER_LINK_RESPONSE_SUBMITTED, WORKER_LINK_RESPONSES_REVIEWED, WORKER_LINK_ACCESSED, diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java index f713f7cf..a3074fbc 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java @@ -10,6 +10,7 @@ public enum AuditTargetType { DOCUMENT_REQUEST_DRAFT, AI_RUN, OUTBOX_EVENT, + WORKER_LINK, USER_ACCOUNT, WORKER_IMPORT } diff --git a/src/main/java/com/fowoco/server/casework/application/CaseDisplayFacts.java b/src/main/java/com/fowoco/server/casework/application/CaseDisplayFacts.java index 8038057e..4032f5a2 100644 --- a/src/main/java/com/fowoco/server/casework/application/CaseDisplayFacts.java +++ b/src/main/java/com/fowoco/server/casework/application/CaseDisplayFacts.java @@ -5,14 +5,14 @@ * * @param completed Case 업무가 모두 완료됐는지 여부 * @param cancelled Case가 취소됐는지 여부 - * @param linkIssued 근로자용 요청 링크를 발급했는지 여부 + * @param linkSent HR이 근로자용 요청 링크의 전달 완료를 기록했는지 여부 * @param reviewRequired 도착한 답변이나 서류에 HR 검토가 남아 있는지 여부 * @param unreadResponse 도착했지만 아직 읽지 않은 근로자 응답이 있는지 여부 */ public record CaseDisplayFacts( boolean completed, boolean cancelled, - boolean linkIssued, + boolean linkSent, boolean reviewRequired, boolean unreadResponse ) { diff --git a/src/main/java/com/fowoco/server/casework/application/CaseDisplayStatusResolver.java b/src/main/java/com/fowoco/server/casework/application/CaseDisplayStatusResolver.java index a80c82ee..96f78c00 100644 --- a/src/main/java/com/fowoco/server/casework/application/CaseDisplayStatusResolver.java +++ b/src/main/java/com/fowoco/server/casework/application/CaseDisplayStatusResolver.java @@ -23,7 +23,7 @@ public CaseDisplayState resolve(CaseDisplayFacts facts) { facts.unreadResponse() ); } - if (facts.linkIssued()) { + if (facts.linkSent()) { return new CaseDisplayState(CaseDisplayStatus.REQUEST_SENT, false); } return new CaseDisplayState(CaseDisplayStatus.DOCUMENT_PENDING, false); diff --git a/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java b/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java index 8dfc4d81..e47fbf48 100644 --- a/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java +++ b/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java @@ -116,7 +116,7 @@ private CaseProjection toProjection(CaseRecord record, List CaseDisplayState displayState = displayStatusResolver.resolve(new CaseDisplayFacts( completed, record.lifecycleStatus() == CaseLifecycleStatus.CANCELLED, - record.linkIssued(), + record.linkSent(), record.reviewRequired(), record.unreadResponse() )); diff --git a/src/main/java/com/fowoco/server/casework/application/port/CaseQueryRepository.java b/src/main/java/com/fowoco/server/casework/application/port/CaseQueryRepository.java index d219cc5a..42561148 100644 --- a/src/main/java/com/fowoco/server/casework/application/port/CaseQueryRepository.java +++ b/src/main/java/com/fowoco/server/casework/application/port/CaseQueryRepository.java @@ -31,7 +31,7 @@ record CaseRecord( CasePriority priority, String workflowCatalogVersion, String workflowSnapshotJson, - boolean linkIssued, + boolean linkSent, boolean reviewRequired, boolean unreadResponse, int completedChecklistItems, diff --git a/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcCaseQueryRepository.java b/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcCaseQueryRepository.java index fb0dcc85..be562923 100644 --- a/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcCaseQueryRepository.java +++ b/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcCaseQueryRepository.java @@ -37,7 +37,10 @@ public class JdbcCaseQueryRepository implements CaseQueryRepository { AND link.company_id = linked_task.company_id WHERE linked_task.case_id = c.case_id AND linked_task.company_id = c.company_id - ) AS link_issued, + AND link.status = 'ACTIVE' + AND link.expires_at > CURRENT_TIMESTAMP + AND link.delivery_status = 'SENT' + ) AS link_sent, ( EXISTS ( SELECT 1 FROM task review_task @@ -247,7 +250,7 @@ private CaseRecord mapCase(ResultSet resultSet) throws SQLException { CasePriority.valueOf(resultSet.getString("priority")), resultSet.getString("workflow_catalog_version"), resultSet.getString("workflow_snapshot_json"), - resultSet.getBoolean("link_issued"), + resultSet.getBoolean("link_sent"), resultSet.getBoolean("review_required"), resultSet.getBoolean("unread_response"), resultSet.getInt("completed_checklist_items"), diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDeliveryController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDeliveryController.java new file mode 100644 index 00000000..a671537d --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDeliveryController.java @@ -0,0 +1,92 @@ +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.common.web.RequestMetadata; +import com.fowoco.server.workerlink.application.WorkerLinkDeliveryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Worker Link", description = "근로자 보안 링크 발급과 전달 상태") +@RestController +@SecurityRequirement(name = "bearerAuth") +public class WorkerLinkDeliveryController { + + private final WorkerLinkDeliveryService service; + private final ActorContextProvider actorContextProvider; + + public WorkerLinkDeliveryController( + WorkerLinkDeliveryService service, + ActorContextProvider actorContextProvider + ) { + this.service = service; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "getTaskWorkerLinkDelivery", + summary = "현재 근로자 링크 전달 상태 조회", + description = "원본 token을 노출하지 않고 현재 활성 링크의 발급·전달 상태를 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + @GetMapping( + path = "/api/v1/tasks/{taskId}/worker-link", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public WorkerLinkDeliveryResponse findCurrent( + @Parameter(description = "업무 ID") @PathVariable UUID taskId + ) { + return WorkerLinkDeliveryResponse.from(service.findCurrent(taskId, actor())); + } + + @Operation( + operationId = "markWorkerLinkSent", + summary = "근로자 링크 전달 완료 기록", + description = "HR이 외부 채널로 링크를 전달했다고 기록합니다. 실제 수신 확인을 의미하지 않습니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "전달 완료 기록 성공 또는 기존 결과 반환"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"), + @ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"), + @ApiResponse(responseCode = "422", description = "만료되거나 폐기된 링크") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + @PostMapping( + path = "/api/v1/worker-links/{workerLinkId}/sent", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public WorkerLinkDeliveryResponse markSent( + @Parameter(description = "근로자 링크 ID") @PathVariable UUID workerLinkId, + HttpServletRequest servletRequest + ) { + return WorkerLinkDeliveryResponse.from(service.markSent( + workerLinkId, + actor(), + RequestMetadata.from(servletRequest) + )); + } + + private ActorContext actor() { + return actorContextProvider.requireCurrentActor(); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDeliveryResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDeliveryResponse.java new file mode 100644 index 00000000..687d6f78 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDeliveryResponse.java @@ -0,0 +1,28 @@ +package com.fowoco.server.workerlink.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.workerlink.application.WorkerLinkDeliveryResult; +import com.fowoco.server.workerlink.domain.WorkerLinkDeliveryStatus; +import com.fowoco.server.workerlink.domain.WorkerLinkStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.UUID; + +@Schema(name = "WorkerLinkDeliveryResponse", description = "HR용 근로자 링크 전달 상태") +public record WorkerLinkDeliveryResponse( + @JsonProperty("worker_link_id") UUID workerLinkId, + @JsonProperty("link_status") WorkerLinkStatus linkStatus, + @JsonProperty("delivery_status") WorkerLinkDeliveryStatus deliveryStatus, + @JsonProperty("sent_at") Instant sentAt, + @JsonProperty("expires_at") Instant expiresAt +) { + public static WorkerLinkDeliveryResponse from(WorkerLinkDeliveryResult result) { + return new WorkerLinkDeliveryResponse( + result.workerLinkId(), + result.linkStatus(), + result.deliveryStatus(), + result.sentAt(), + result.expiresAt() + ); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueResponse.java index 9408ea6e..b48b3ea7 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueResponse.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueResponse.java @@ -2,12 +2,18 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fowoco.server.workerlink.application.WorkerLinkIssueResult; +import com.fowoco.server.workerlink.domain.WorkerLinkDeliveryStatus; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; +import java.util.UUID; @Schema(name = "WorkerLinkIssueResponse", description = "근로자 보안 링크 발급 결과") public final class WorkerLinkIssueResponse { + @JsonProperty("worker_link_id") + @Schema(name = "worker_link_id", description = "발급된 근로자 링크 ID") + private final UUID workerLinkId; + @JsonProperty("worker_url") @Schema(name = "worker_url", description = "근로자에게 전달할 링크 URL. 같은 idempotency key로 재요청한 경우 null") private final String workerUrl; @@ -16,13 +22,31 @@ public final class WorkerLinkIssueResponse { @Schema(name = "expires_at") private final Instant expiresAt; + @JsonProperty("delivery_status") + @Schema(name = "delivery_status", description = "HR의 링크 전달 완료 기록 상태") + private final WorkerLinkDeliveryStatus deliveryStatus; + + @JsonProperty("sent_at") + @Schema(name = "sent_at", description = "HR이 전달 완료를 기록한 서버 시각. 미전송이면 null") + private final Instant sentAt; + @JsonProperty("already_issued") @Schema(name = "already_issued", description = "같은 idempotency key로 이미 발급된 적이 있어 재사용된 응답인지 여부") private final boolean alreadyIssued; - private WorkerLinkIssueResponse(String workerUrl, Instant expiresAt, boolean alreadyIssued) { + private WorkerLinkIssueResponse( + UUID workerLinkId, + String workerUrl, + Instant expiresAt, + WorkerLinkDeliveryStatus deliveryStatus, + Instant sentAt, + boolean alreadyIssued + ) { + this.workerLinkId = workerLinkId; this.workerUrl = workerUrl; this.expiresAt = expiresAt; + this.deliveryStatus = deliveryStatus; + this.sentAt = sentAt; this.alreadyIssued = alreadyIssued; } @@ -31,7 +55,18 @@ public static WorkerLinkIssueResponse from(WorkerLinkIssueResult result) { // 확인 후 실제 프론트 base URL + 경로로 조립해야 함. // 지금은 원문 토큰만 그대로 노출한 상태(미완성). // 재시도 worker_url을 null로 반환함. - return new WorkerLinkIssueResponse(result.rawToken(), result.expiresAt(), result.alreadyIssued()); + return new WorkerLinkIssueResponse( + result.workerLinkId(), + result.rawToken(), + result.expiresAt(), + result.deliveryStatus(), + result.sentAt(), + result.alreadyIssued() + ); + } + + public UUID getWorkerLinkId() { + return workerLinkId; } public String getWorkerUrl() { @@ -42,6 +77,14 @@ public Instant getExpiresAt() { return expiresAt; } + public WorkerLinkDeliveryStatus getDeliveryStatus() { + return deliveryStatus; + } + + public Instant getSentAt() { + return sentAt; + } + public boolean isAlreadyIssued() { return alreadyIssued; } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDeliveryResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDeliveryResult.java new file mode 100644 index 00000000..2cf97811 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDeliveryResult.java @@ -0,0 +1,25 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.domain.WorkerLinkDeliveryStatus; +import com.fowoco.server.workerlink.domain.WorkerLinkStatus; +import java.time.Instant; +import java.util.UUID; + +public record WorkerLinkDeliveryResult( + UUID workerLinkId, + WorkerLinkStatus linkStatus, + WorkerLinkDeliveryStatus deliveryStatus, + Instant sentAt, + Instant expiresAt +) { + public static WorkerLinkDeliveryResult from(WorkerLink link) { + return new WorkerLinkDeliveryResult( + link.workerLinkId(), + link.status(), + link.deliveryStatus(), + link.sentAt(), + link.expiresAt() + ); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDeliveryService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDeliveryService.java new file mode 100644 index 00000000..d2fc7659 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDeliveryService.java @@ -0,0 +1,117 @@ +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.auth.application.ActorContext; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.domain.WorkerLinkDeliveryStatus; +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkerLinkDeliveryService { + + private static final String AUDIT_EVENT_VERSION = "1"; + + private final TaskRepository taskRepository; + private final WorkerLinkRepository workerLinkRepository; + private final AuditEventRepository auditRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public WorkerLinkDeliveryService( + TaskRepository taskRepository, + WorkerLinkRepository workerLinkRepository, + AuditEventRepository auditRepository, + TenantDatabaseContext tenantDatabaseContext, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.taskRepository = taskRepository; + this.workerLinkRepository = workerLinkRepository; + this.auditRepository = auditRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional(readOnly = true) + public WorkerLinkDeliveryResult findCurrent(UUID taskId, ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + taskRepository.findByIdAndCompanyId(taskId, actor.companyId()) + .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); + WorkerLink link = workerLinkRepository.findActiveByTaskIdAndCompanyId(taskId, actor.companyId()) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_RESOURCE_NOT_FOUND)); + return WorkerLinkDeliveryResult.from(link); + } + + @Transactional + public WorkerLinkDeliveryResult markSent( + UUID workerLinkId, + ActorContext actor, + RequestMetadata metadata + ) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + WorkerLink link = workerLinkRepository.findByIdAndCompanyId(workerLinkId, actor.companyId()) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_RESOURCE_NOT_FOUND)); + // PostgreSQL TIMESTAMPTZ stores microseconds. Normalize before returning the + // first response so a later DB round-trip preserves the exact same sentAt. + Instant now = clock.instant().truncatedTo(ChronoUnit.MICROS); + if (!link.isUsable(now)) { + throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_ACTIVE); + } + if (link.deliveryStatus() == WorkerLinkDeliveryStatus.SENT) { + return WorkerLinkDeliveryResult.from(link); + } + + WorkerLink saved = workerLinkRepository.update(link.markSent(actor.actorId(), now)); + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + actor.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + AuditAction.WORKER_LINK_SENT, + AuditTargetType.WORKER_LINK, + workerLinkId, + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + "근로자 링크 전달 완료 기록 (taskId=" + link.taskId() + ")", + now + )); + return WorkerLinkDeliveryResult.from(saved); + } + + private UserRole effectiveRole(ActorContext actor) { + return actor.roles().stream() + .min(Comparator.comparingInt(this::rolePriority)) + .orElseThrow(); + } + + private int rolePriority(UserRole role) { + return switch (role) { + case ADMIN -> 0; + case HR -> 1; + case VIEWER -> 2; + }; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueResult.java index f94c178c..1956c390 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueResult.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueResult.java @@ -1,6 +1,15 @@ package com.fowoco.server.workerlink.application; +import com.fowoco.server.workerlink.domain.WorkerLinkDeliveryStatus; import java.time.Instant; +import java.util.UUID; -public record WorkerLinkIssueResult(String rawToken, Instant expiresAt, boolean alreadyIssued) { +public record WorkerLinkIssueResult( + UUID workerLinkId, + String rawToken, + Instant expiresAt, + WorkerLinkDeliveryStatus deliveryStatus, + Instant sentAt, + boolean alreadyIssued +) { } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java index b67967a3..b7f298b7 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java @@ -77,7 +77,14 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext .findByTaskIdAndIdempotencyKey(command.taskId(), idempotencyKeyHash); if (existingByIdempotency.isPresent()) { WorkerLink previous = existingByIdempotency.get(); - return new WorkerLinkIssueResult(null, previous.expiresAt(), true); + return new WorkerLinkIssueResult( + previous.workerLinkId(), + null, + previous.expiresAt(), + previous.deliveryStatus(), + previous.sentAt(), + true + ); } Instant now = clock.instant(); @@ -111,6 +118,13 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext ); workerLinkRepository.insert(workerLink); - return new WorkerLinkIssueResult(generated.rawValue(), expiresAt, false); + return new WorkerLinkIssueResult( + workerLink.workerLinkId(), + generated.rawValue(), + expiresAt, + workerLink.deliveryStatus(), + workerLink.sentAt(), + false + ); } } 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 index 0f2cda34..52a875de 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java +++ b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java @@ -17,6 +17,11 @@ public enum WorkerLinkErrorCode implements ApiErrorCode { HttpStatus.CONFLICT, "이미 유효한 근로자 링크가 있습니다. rotateExisting=true로 재발급해 주세요." ), + WORKER_LINK_RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "근로자 링크를 찾을 수 없습니다."), + WORKER_LINK_NOT_ACTIVE( + HttpStatus.UNPROCESSABLE_CONTENT, + "현재 사용할 수 있는 근로자 링크만 전달 완료로 기록할 수 있습니다." + ), WORKER_LINK_NOT_FOUND(HttpStatus.GONE, "링크를 찾을 수 없거나 더 이상 사용할 수 없습니다."), UPLOAD_NOT_AVAILABLE( 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 index 36814b86..a58cfb73 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java @@ -13,6 +13,8 @@ public interface WorkerLinkRepository { Optional findByTokenHash(String tokenHash); + Optional findByIdAndCompanyId(UUID workerLinkId, UUID companyId); + Optional findActiveByTaskIdAndCompanyId(UUID taskId, UUID companyId); Optional findByTaskIdAndIdempotencyKey(UUID taskId, String idempotencyKey); diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java index 6b46b6f6..f9226cf7 100644 --- a/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java @@ -13,6 +13,9 @@ public final class WorkerLink { private final Instant expiresAt; private final WorkerLinkStatus status; private final ConversationStatus conversationStatus; + private final WorkerLinkDeliveryStatus deliveryStatus; + private final Instant sentAt; + private final UUID sentBy; private final UUID assigneeId; private final UUID issuedBy; private final UUID replacesLinkId; @@ -29,6 +32,9 @@ public WorkerLink( Instant expiresAt, WorkerLinkStatus status, ConversationStatus conversationStatus, + WorkerLinkDeliveryStatus deliveryStatus, + Instant sentAt, + UUID sentBy, UUID assigneeId, UUID issuedBy, UUID replacesLinkId, @@ -44,6 +50,10 @@ public WorkerLink( 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.deliveryStatus = Objects.requireNonNull(deliveryStatus, "deliveryStatus must not be null"); + validateDelivery(deliveryStatus, sentAt, sentBy, createdAt); + this.sentAt = sentAt; + this.sentBy = sentBy; this.assigneeId = assigneeId; this.issuedBy = Objects.requireNonNull(issuedBy, "issuedBy must not be null"); this.replacesLinkId = replacesLinkId; @@ -78,6 +88,9 @@ public static WorkerLink issue( expiresAt, WorkerLinkStatus.ACTIVE, ConversationStatus.WAITING_WORKER, + WorkerLinkDeliveryStatus.NOT_SENT, + null, + null, null, issuedBy, replacesLinkId, @@ -91,7 +104,8 @@ public static WorkerLink issue( public WorkerLink revoke(Instant now) { return new WorkerLink( workerLinkId, taskId, companyId, tokenHash, expiresAt, - WorkerLinkStatus.REVOKED, conversationStatus, assigneeId, issuedBy, + WorkerLinkStatus.REVOKED, conversationStatus, deliveryStatus, sentAt, sentBy, + assigneeId, issuedBy, replacesLinkId, idempotencyKey, createdAt, now, version ); } @@ -99,7 +113,8 @@ public WorkerLink revoke(Instant now) { public WorkerLink markNeedsFollowup(Instant now) { return new WorkerLink( workerLinkId, taskId, companyId, tokenHash, expiresAt, - status, ConversationStatus.NEEDS_FOLLOWUP, assigneeId, issuedBy, + status, ConversationStatus.NEEDS_FOLLOWUP, deliveryStatus, sentAt, sentBy, + assigneeId, issuedBy, replacesLinkId, idempotencyKey, createdAt, now, version ); } @@ -110,11 +125,25 @@ public WorkerLink markReviewed(Instant now) { } return new WorkerLink( workerLinkId, taskId, companyId, tokenHash, expiresAt, - status, ConversationStatus.REOPENED, assigneeId, issuedBy, + status, ConversationStatus.REOPENED, deliveryStatus, sentAt, sentBy, + assigneeId, issuedBy, replacesLinkId, idempotencyKey, createdAt, now, version ); } + public WorkerLink markSent(UUID actorId, Instant now) { + Objects.requireNonNull(actorId, "actorId must not be null"); + Objects.requireNonNull(now, "now must not be null"); + if (deliveryStatus == WorkerLinkDeliveryStatus.SENT) { + return this; + } + return new WorkerLink( + workerLinkId, taskId, companyId, tokenHash, expiresAt, + status, conversationStatus, WorkerLinkDeliveryStatus.SENT, now, actorId, + assigneeId, issuedBy, replacesLinkId, idempotencyKey, createdAt, now, version + ); + } + public boolean isUsable(Instant now) { return status == WorkerLinkStatus.ACTIVE && expiresAt.isAfter(now); } @@ -126,6 +155,22 @@ private static String requireText(String value, String fieldName) { return value; } + private static void validateDelivery( + WorkerLinkDeliveryStatus deliveryStatus, + Instant sentAt, + UUID sentBy, + Instant createdAt + ) { + Objects.requireNonNull(createdAt, "createdAt must not be null"); + boolean sent = deliveryStatus == WorkerLinkDeliveryStatus.SENT; + if (sent != (sentAt != null && sentBy != null)) { + throw new IllegalArgumentException("sentAt and sentBy must match deliveryStatus"); + } + if (sentAt != null && sentAt.isBefore(createdAt)) { + throw new IllegalArgumentException("sentAt must not be before createdAt"); + } + } + public UUID workerLinkId() { return workerLinkId; } @@ -154,6 +199,18 @@ public ConversationStatus conversationStatus() { return conversationStatus; } + public WorkerLinkDeliveryStatus deliveryStatus() { + return deliveryStatus; + } + + public Instant sentAt() { + return sentAt; + } + + public UUID sentBy() { + return sentBy; + } + public UUID assigneeId() { return assigneeId; } diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerLinkDeliveryStatus.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLinkDeliveryStatus.java new file mode 100644 index 00000000..e87ae1c1 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLinkDeliveryStatus.java @@ -0,0 +1,6 @@ +package com.fowoco.server.workerlink.domain; + +public enum WorkerLinkDeliveryStatus { + NOT_SENT, + SENT +} 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 index 12a9acb4..ca0f0bda 100644 --- a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java @@ -55,6 +55,26 @@ public Optional findByTokenHash(String tokenHash) { .map(WorkerLinkJpaEntity::toDomain); } + @Override + public Optional findByIdAndCompanyId(UUID workerLinkId, UUID companyId) { + Objects.requireNonNull(workerLinkId, "workerLinkId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + return entityManager.createQuery( + """ + select link + from WorkerLinkJpaEntity link + where link.workerLinkId = :workerLinkId + and link.companyId = :companyId + """, + WorkerLinkJpaEntity.class + ) + .setParameter("workerLinkId", workerLinkId) + .setParameter("companyId", companyId) + .getResultStream() + .findFirst() + .map(WorkerLinkJpaEntity::toDomain); + } + @Override public Optional findActiveByTaskIdAndCompanyId(UUID taskId, UUID companyId) { Objects.requireNonNull(taskId, "taskId must not be null"); 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 index 1c95803c..2dca9711 100644 --- a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java @@ -2,6 +2,7 @@ import com.fowoco.server.workerlink.domain.ConversationStatus; import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.domain.WorkerLinkDeliveryStatus; import com.fowoco.server.workerlink.domain.WorkerLinkStatus; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -42,6 +43,16 @@ public class WorkerLinkJpaEntity { @Column(name = "conversation_status", nullable = false, length = 20) private ConversationStatus conversationStatus; + @Enumerated(EnumType.STRING) + @Column(name = "delivery_status", nullable = false, length = 20) + private WorkerLinkDeliveryStatus deliveryStatus; + + @Column(name = "sent_at") + private Instant sentAt; + + @Column(name = "sent_by") + private UUID sentBy; + @Column(name = "assignee_id") private UUID assigneeId; @@ -75,6 +86,9 @@ private WorkerLinkJpaEntity( Instant expiresAt, WorkerLinkStatus status, ConversationStatus conversationStatus, + WorkerLinkDeliveryStatus deliveryStatus, + Instant sentAt, + UUID sentBy, UUID assigneeId, UUID issuedBy, UUID replacesLinkId, @@ -90,6 +104,9 @@ private WorkerLinkJpaEntity( this.expiresAt = expiresAt; this.status = status; this.conversationStatus = conversationStatus; + this.deliveryStatus = deliveryStatus; + this.sentAt = sentAt; + this.sentBy = sentBy; this.assigneeId = assigneeId; this.issuedBy = issuedBy; this.replacesLinkId = replacesLinkId; @@ -109,6 +126,9 @@ public static WorkerLinkJpaEntity fromDomain(WorkerLink workerLink) { workerLink.expiresAt(), workerLink.status(), workerLink.conversationStatus(), + workerLink.deliveryStatus(), + workerLink.sentAt(), + workerLink.sentBy(), workerLink.assigneeId(), workerLink.issuedBy(), workerLink.replacesLinkId(), @@ -128,6 +148,9 @@ public WorkerLink toDomain() { expiresAt, status, conversationStatus, + deliveryStatus, + sentAt, + sentBy, assigneeId, issuedBy, replacesLinkId, @@ -152,6 +175,9 @@ public void applyState(WorkerLink workerLink) { } this.status = workerLink.status(); this.conversationStatus = workerLink.conversationStatus(); + this.deliveryStatus = workerLink.deliveryStatus(); + this.sentAt = workerLink.sentAt(); + this.sentBy = workerLink.sentBy(); this.assigneeId = workerLink.assigneeId(); this.updatedAt = workerLink.updatedAt(); } diff --git a/src/main/resources/db/migration/V32__add_worker_link_delivery_status.sql b/src/main/resources/db/migration/V32__add_worker_link_delivery_status.sql new file mode 100644 index 00000000..4a372fe8 --- /dev/null +++ b/src/main/resources/db/migration/V32__add_worker_link_delivery_status.sql @@ -0,0 +1,24 @@ +ALTER TABLE worker_link + ADD COLUMN delivery_status VARCHAR(20) NOT NULL DEFAULT 'NOT_SENT'; + +ALTER TABLE worker_link + ADD COLUMN sent_at TIMESTAMP(6) WITH TIME ZONE; + +ALTER TABLE worker_link + ADD COLUMN sent_by UUID; + +ALTER TABLE worker_link + ADD CONSTRAINT fk_worker_link_sent_by_company + FOREIGN KEY (sent_by, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_link + ADD CONSTRAINT ck_worker_link_delivery_status + CHECK (delivery_status IN ('NOT_SENT', 'SENT')); + +ALTER TABLE worker_link + ADD CONSTRAINT ck_worker_link_delivery_state + CHECK ( + (delivery_status = 'NOT_SENT' AND sent_at IS NULL AND sent_by IS NULL) + OR (delivery_status = 'SENT' AND sent_at IS NOT NULL AND sent_by IS NOT NULL) + ); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 095216f8..38920d2d 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -274,6 +274,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("worker_link_id", new ColumnSpec("uuid", false)) .containsEntry("task_id", new ColumnSpec("uuid", false)) .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("delivery_status", new ColumnSpec("varchar", false)) + .containsEntry("sent_at", new ColumnSpec("timestamptz", true)) + .containsEntry("sent_by", new ColumnSpec("uuid", true)) .containsEntry("replaces_link_id", new ColumnSpec("uuid", true)); assertThat(columnSpecs(connection, "worker_response")) .containsEntry("response_id", new ColumnSpec("uuid", false)) @@ -375,6 +378,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { "fk_worker_document_task_worker_company", "uq_worker_link_id_company", "fk_worker_link_replaces_company", + "fk_worker_link_sent_by_company", + "ck_worker_link_delivery_status", + "ck_worker_link_delivery_state", "uq_worker_response_id_company", "fk_worker_response_link_company", "uq_stored_file_id_company", @@ -622,6 +628,26 @@ INSERT INTO worker_link ( TASK_A, COMPANY_A, REVOKED_WORKER_LINK_TOKEN_HASH, USER_A, TASK_A, COMPANY_A, EXPIRED_WORKER_LINK_TOKEN_HASH, USER_A )); + assertThat(queryNullableString( + connection, + "SELECT delivery_status FROM worker_link WHERE worker_link_id = ?::uuid", + "21000000-0000-0000-0000-000000000001" + )).isEqualTo("NOT_SENT"); + assertSqlState(connection, "23514", """ + UPDATE worker_link + SET delivery_status = 'SENT' + WHERE worker_link_id = '21000000-0000-0000-0000-000000000001' + """); + assertSqlState(connection, "23503", """ + UPDATE worker_link + SET delivery_status = 'SENT', sent_at = CURRENT_TIMESTAMP, sent_by = '%s' + WHERE worker_link_id = '21000000-0000-0000-0000-000000000001' + """.formatted(USER_B)); + execute(connection, """ + UPDATE worker_link + SET delivery_status = 'SENT', sent_at = CURRENT_TIMESTAMP, sent_by = '%s' + WHERE worker_link_id = '21000000-0000-0000-0000-000000000001' + """.formatted(USER_A)); execute(connection, """ INSERT INTO approval_request ( approval_request_id, task_id, company_id, diff --git a/src/test/java/com/fowoco/server/casework/CaseQueryIntegrationTest.java b/src/test/java/com/fowoco/server/casework/CaseQueryIntegrationTest.java index 0d214145..e83e5a07 100644 --- a/src/test/java/com/fowoco/server/casework/CaseQueryIntegrationTest.java +++ b/src/test/java/com/fowoco/server/casework/CaseQueryIntegrationTest.java @@ -184,6 +184,46 @@ void keepsStableCaseOrderAcrossPagesWhenPriorityAndUpdatedAtAreEqual() throws Ex .containsExactly(CASE_ORDER_SECOND.toString(), CASE_CANCELLED.toString()); } + @Test + void distinguishesIssuedLinkFromHrSentLink() throws Exception { + jdbcTemplate.update("DELETE FROM worker_response"); + jdbcTemplate.update( + """ + UPDATE worker_link + SET conversation_status = 'WAITING_WORKER', + delivery_status = 'NOT_SENT', + sent_at = NULL, + sent_by = NULL + WHERE task_id = ? + """, + TASK_A_WAITING + ); + String token = login(); + + HttpResponse issuedOnly = get("/api/v1/cases/" + CASE_A + "/projection", token); + assertThat(JsonPath.read(issuedOnly.body(), "$.display_status")) + .isEqualTo("DOCUMENT_PENDING"); + + jdbcTemplate.update( + """ + UPDATE worker_link + SET delivery_status = 'SENT', sent_at = CURRENT_TIMESTAMP, sent_by = ? + WHERE task_id = ? + """, + HR_A, + TASK_A_WAITING + ); + + HttpResponse sent = get("/api/v1/cases/" + CASE_A + "/projection", token); + assertThat(JsonPath.read(sent.body(), "$.display_status")) + .isEqualTo("REQUEST_SENT"); + + jdbcTemplate.update("UPDATE worker_link SET status = 'REVOKED' WHERE task_id = ?", TASK_A_WAITING); + HttpResponse revoked = get("/api/v1/cases/" + CASE_A + "/projection", token); + assertThat(JsonPath.read(revoked.body(), "$.display_status")) + .isEqualTo("DOCUMENT_PENDING"); + } + private String login() throws Exception { HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/auth/login")) .header(HttpHeaders.CONTENT_TYPE, "application/json") diff --git a/src/test/java/com/fowoco/server/casework/application/CaseDisplayStatusResolverTest.java b/src/test/java/com/fowoco/server/casework/application/CaseDisplayStatusResolverTest.java index 3180afc9..cae0d27e 100644 --- a/src/test/java/com/fowoco/server/casework/application/CaseDisplayStatusResolverTest.java +++ b/src/test/java/com/fowoco/server/casework/application/CaseDisplayStatusResolverTest.java @@ -14,7 +14,7 @@ class CaseDisplayStatusResolverTest { private final CaseDisplayStatusResolver resolver = new CaseDisplayStatusResolver(); @Test - void showsDocumentPendingBeforeWorkerLinkIsIssued() { + void showsDocumentPendingBeforeWorkerLinkIsSent() { CaseDisplayState result = resolver.resolve(facts(false, false, false, false)); assertThat(result.status()).isEqualTo(DOCUMENT_PENDING); @@ -22,7 +22,7 @@ void showsDocumentPendingBeforeWorkerLinkIsIssued() { } @Test - void showsRequestSentAfterWorkerLinkIsIssuedWithoutResponse() { + void showsRequestSentAfterWorkerLinkIsSentWithoutResponse() { CaseDisplayState result = resolver.resolve(facts(false, true, false, false)); assertThat(result.status()).isEqualTo(REQUEST_SENT); @@ -63,24 +63,24 @@ void cancelledTakesPriorityAndClearsUnreadBadge() { private CaseDisplayFacts facts( boolean completed, - boolean linkIssued, + boolean linkSent, boolean reviewRequired, boolean unreadResponse ) { - return facts(completed, false, linkIssued, reviewRequired, unreadResponse); + return facts(completed, false, linkSent, reviewRequired, unreadResponse); } private CaseDisplayFacts facts( boolean completed, boolean cancelled, - boolean linkIssued, + boolean linkSent, boolean reviewRequired, boolean unreadResponse ) { return new CaseDisplayFacts( completed, cancelled, - linkIssued, + linkSent, reviewRequired, unreadResponse ); diff --git a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java index 5bfa382f..c9785a88 100644 --- a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java @@ -34,8 +34,10 @@ class WorkerLinkSecurityIntegrationTest { 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 UUID VIEWER_A = UUID.fromString("A1000000-0000-0000-0000-000000000003"); 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 VIEWER_A_EMAIL = "viewer.link.a@example.com"; private static final String PASSWORD = "Test-password-1!"; private static final String BOUNDARY = "FowocoLinkTestBoundary1234"; @@ -56,8 +58,9 @@ void seedCompaniesAndUsers() { 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); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash, "HR"); + insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash, "HR"); + insertUser(VIEWER_A, COMPANY_A, VIEWER_A_EMAIL, passwordHash, "VIEWER"); } @BeforeEach @@ -229,6 +232,114 @@ void workerResponsesAreHiddenFromOtherCompany() throws Exception { assertThat(readResponse.statusCode()).isEqualTo(404); } + @Test + void hrCanQueryAndMarkWorkerLinkSentIdempotently() 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, + "delivery-issue-key" + ); + assertThat(issueResponse.statusCode()).isEqualTo(201); + String workerLinkId = JsonPath.read(issueResponse.body(), "$.worker_link_id"); + assertThat(JsonPath.read(issueResponse.body(), "$.delivery_status")) + .isEqualTo("NOT_SENT"); + assertThat(JsonPath.read(issueResponse.body(), "$.sent_at")).isNull(); + + HttpResponse beforeSent = getJson( + "/api/v1/tasks/" + taskId + "/worker-link", + hrToken + ); + assertThat(beforeSent.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(beforeSent.body(), "$.worker_link_id")) + .isEqualTo(workerLinkId); + assertThat(JsonPath.read(beforeSent.body(), "$.delivery_status")) + .isEqualTo("NOT_SENT"); + + HttpResponse sent = postWithoutBody( + "/api/v1/worker-links/" + workerLinkId + "/sent", + hrToken + ); + assertThat(sent.statusCode()).as("sent response body: %s", sent.body()).isEqualTo(200); + assertThat(JsonPath.read(sent.body(), "$.delivery_status")).isEqualTo("SENT"); + assertThat(JsonPath.read(sent.body(), "$.sent_at")).isNotBlank(); + assertThat(jdbcTemplate.queryForObject( + "SELECT sent_by FROM worker_link WHERE worker_link_id = ?", + UUID.class, + UUID.fromString(workerLinkId) + )).isEqualTo(HR_A); + + HttpResponse firstRetry = postWithoutBody( + "/api/v1/worker-links/" + workerLinkId + "/sent", + hrToken + ); + HttpResponse secondRetry = postWithoutBody( + "/api/v1/worker-links/" + workerLinkId + "/sent", + hrToken + ); + assertThat(firstRetry.statusCode()).isEqualTo(200); + assertThat(secondRetry.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(firstRetry.body(), "$.sent_at")) + .isEqualTo(JsonPath.read(sent.body(), "$.sent_at")); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE target_id = ? AND action = 'WORKER_LINK_SENT'", + Integer.class, + UUID.fromString(workerLinkId) + )).isEqualTo(1); + } + + @Test + void deliveryEndpointsHideOtherCompanyAndRejectViewer() throws Exception { + String hrTokenA = accessToken(login(HR_A_EMAIL)); + String hrTokenB = accessToken(login(HR_B_EMAIL)); + String viewerTokenA = accessToken(login(VIEWER_A_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} + """, + hrTokenA, + "delivery-security-issue-key" + ); + String workerLinkId = JsonPath.read(issueResponse.body(), "$.worker_link_id"); + + assertThat(getJson("/api/v1/tasks/" + taskId + "/worker-link", hrTokenB).statusCode()) + .isEqualTo(404); + assertThat(postWithoutBody( + "/api/v1/worker-links/" + workerLinkId + "/sent", + hrTokenB + ).statusCode()).isEqualTo(404); + assertThat(getJson("/api/v1/tasks/" + taskId + "/worker-link", viewerTokenA).statusCode()) + .isEqualTo(403); + assertThat(postWithoutBody( + "/api/v1/worker-links/" + workerLinkId + "/sent", + viewerTokenA + ).statusCode()).isEqualTo(403); + } + + @Test + void documentsWorkerLinkDeliveryEndpointsInOpenApi() throws Exception { + HttpResponse response = getJson("/v3/api-docs", null); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.read( + response.body(), + "$.paths['/api/v1/tasks/{taskId}/worker-link'].get.operationId" + )).isEqualTo("getTaskWorkerLinkDelivery"); + assertThat(JsonPath.read( + response.body(), + "$.paths['/api/v1/worker-links/{workerLinkId}/sent'].post.operationId" + )).isEqualTo("markWorkerLinkSent"); + } + @Test void issueRejectsUnapprovedTask() throws Exception { String hrToken = accessToken(login(HR_A_EMAIL)); @@ -436,15 +547,21 @@ INSERT INTO company (company_id, name, status, created_at, updated_at, version) ); } - private void insertUser(UUID userId, UUID companyId, String email, String passwordHash) { + private void insertUser( + UUID userId, + UUID companyId, + String email, + String passwordHash, + String role + ) { jdbcTemplate.update( """ INSERT INTO user_account ( user_id, company_id, email, normalized_email, password_hash, role, status, created_at, updated_at, version - ) VALUES (?, ?, ?, ?, ?, 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + ) VALUES (?, ?, ?, ?, ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) """, - userId, companyId, email, email, passwordHash + userId, companyId, email, email, passwordHash, role ); } diff --git a/src/test/java/com/fowoco/server/workerlink/domain/WorkerLinkTest.java b/src/test/java/com/fowoco/server/workerlink/domain/WorkerLinkTest.java new file mode 100644 index 00000000..f112e3a2 --- /dev/null +++ b/src/test/java/com/fowoco/server/workerlink/domain/WorkerLinkTest.java @@ -0,0 +1,53 @@ +package com.fowoco.server.workerlink.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class WorkerLinkTest { + + private static final UUID LINK_ID = UUID.fromString("10000000-0000-0000-0000-000000000001"); + private static final UUID TASK_ID = UUID.fromString("20000000-0000-0000-0000-000000000002"); + private static final UUID COMPANY_ID = UUID.fromString("30000000-0000-0000-0000-000000000003"); + private static final UUID HR_ID = UUID.fromString("40000000-0000-0000-0000-000000000004"); + private static final Instant ISSUED_AT = Instant.parse("2026-08-08T00:00:00Z"); + + @Test + void issuedLinkStartsNotSent() { + WorkerLink link = issueLink(); + + assertThat(link.deliveryStatus()).isEqualTo(WorkerLinkDeliveryStatus.NOT_SENT); + assertThat(link.sentAt()).isNull(); + assertThat(link.sentBy()).isNull(); + } + + @Test + void markSentRecordsActorAndServerTimeOnlyOnce() { + WorkerLink link = issueLink(); + Instant sentAt = ISSUED_AT.plusSeconds(60); + + WorkerLink sent = link.markSent(HR_ID, sentAt); + WorkerLink repeated = sent.markSent(UUID.randomUUID(), sentAt.plusSeconds(60)); + + assertThat(sent.deliveryStatus()).isEqualTo(WorkerLinkDeliveryStatus.SENT); + assertThat(sent.sentAt()).isEqualTo(sentAt); + assertThat(sent.sentBy()).isEqualTo(HR_ID); + assertThat(repeated).isSameAs(sent); + } + + private WorkerLink issueLink() { + return WorkerLink.issue( + LINK_ID, + TASK_ID, + COMPANY_ID, + "a".repeat(64), + ISSUED_AT.plusSeconds(3600), + HR_ID, + null, + "worker-link-test-key", + ISSUED_AT + ); + } +}