Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public enum AuditTargetType {
DOCUMENT_REQUEST_DRAFT,
AI_RUN,
OUTBOX_EVENT,
WORKER_LINK,
USER_ACCOUNT,
WORKER_IMPORT
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ private CaseProjection toProjection(CaseRecord record, List<CaseTaskProjection>
CaseDisplayState displayState = displayStatusResolver.resolve(new CaseDisplayFacts(
completed,
record.lifecycleStatus() == CaseLifecycleStatus.CANCELLED,
record.linkIssued(),
record.linkSent(),
record.reviewRequired(),
record.unreadResponse()
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ record CaseRecord(
CasePriority priority,
String workflowCatalogVersion,
String workflowSnapshotJson,
boolean linkIssued,
boolean linkSent,
boolean reviewRequired,
boolean unreadResponse,
int completedChecklistItems,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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() {
Expand All @@ -42,6 +77,14 @@ public Instant getExpiresAt() {
return expiresAt;
}

public WorkerLinkDeliveryStatus getDeliveryStatus() {
return deliveryStatus;
}

public Instant getSentAt() {
return sentAt;
}

public boolean isAlreadyIssued() {
return alreadyIssued;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Loading
Loading