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
29 changes: 29 additions & 0 deletions src/main/java/com/fowoco/server/audit/api/AuditController.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,35 @@ public List<AuditEventResponse> taskActivities(@PathVariable UUID taskId) {
.toList();
}

@Operation(
operationId = "getWorkerActivities",
summary = "근로자 안내이력",
description = "여러 업무에 흩어진 근로자 안내·응답 기록 중 화면에 안전한 항목만 최신순으로 반환합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "근로자 안내이력 페이지"),
@ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"),
@ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound")
})
@GetMapping(
path = "/workers/{workerId}/activities",
produces = MediaType.APPLICATION_JSON_VALUE
)
@PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')")
public WorkerActivityPageResponse workerActivities(
@PathVariable UUID workerId,
@Parameter(description = "직전 응답의 next_cursor. 내용을 해석하거나 수정하지 않습니다.")
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit
) {
return WorkerActivityPageResponse.from(auditQueryService.getWorkerActivities(
workerId,
cursor,
limit,
actorContextProvider.requireCurrentActor()
));
}

@Operation(
operationId = "searchAuditEvents",
summary = "사업장 감사 이벤트 검색",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.fowoco.server.audit.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.audit.application.WorkerActivityPageResult;
import java.util.List;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record WorkerActivityPageResponse(
List<WorkerActivityResponse> items,
String nextCursor
) {
static WorkerActivityPageResponse from(WorkerActivityPageResult result) {
return new WorkerActivityPageResponse(
result.items().stream().map(WorkerActivityResponse::from).toList(),
result.nextCursor()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.fowoco.server.audit.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.audit.application.WorkerActivityType;
import com.fowoco.server.audit.application.WorkerActivityView;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.UUID;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record WorkerActivityResponse(
@Schema(description = "활동 ID") UUID activityId,
@Schema(description = "화면용 활동 종류") WorkerActivityType type,
@Schema(description = "관련 업무카드 ID") UUID taskId,
@Schema(description = "관련 업무카드 제목") String taskTitle,
@Schema(description = "개인정보를 제외한 화면용 설명") String summary,
@Schema(description = "발생 시각") Instant occurredAt
) {
static WorkerActivityResponse from(WorkerActivityView view) {
return new WorkerActivityResponse(
view.activityId(),
view.type(),
view.taskId(),
view.taskTitle(),
view.summary(),
view.occurredAt()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ public class AuditCursorCodec {
private static final String DELIMITER = "|";

public String encode(AuditEvent event) {
String raw = event.createdAt() + DELIMITER + event.auditEventId();
return encode(event.createdAt(), event.auditEventId());
}

public String encode(Instant createdAt, UUID auditEventId) {
String raw = createdAt + DELIMITER + auditEventId;
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(raw.getBytes(StandardCharsets.UTF_8));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fowoco.server.audit.application.AuditCursorCodec.DecodedAuditCursor;
import com.fowoco.server.audit.application.port.AuditEventRepository;
import com.fowoco.server.audit.application.port.AuditEventRepository.WorkerActivityRecord;
import com.fowoco.server.audit.domain.ActorType;
import com.fowoco.server.audit.domain.AuditAction;
import com.fowoco.server.audit.domain.AuditEvent;
Expand All @@ -16,7 +17,10 @@
import com.fowoco.server.settings.domain.AuditVisibility;
import com.fowoco.server.task.application.error.TaskErrorCode;
import com.fowoco.server.task.application.port.TaskRepository;
import com.fowoco.server.worker.application.error.WorkerErrorCode;
import com.fowoco.server.worker.application.port.WorkerRepository;
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Service;
Expand All @@ -26,10 +30,17 @@
public class AuditQueryService {

private static final int MAX_PAGE_SIZE = 100;
private static final EnumSet<AuditAction> WORKER_ACTIVITY_ACTIONS = EnumSet.of(
AuditAction.WORKER_LINK_SENT,
AuditAction.WORKER_LINK_ACCESSED,
AuditAction.WORKER_LINK_RESPONSE_SUBMITTED,
AuditAction.WORKER_LINK_RESPONSES_REVIEWED
);

private final ActorAuthorizer actorAuthorizer;
private final TenantDatabaseContext tenantDatabaseContext;
private final TaskRepository taskRepository;
private final WorkerRepository workerRepository;
private final AuditEventRepository auditRepository;
private final CompanySettingsRepository companySettingsRepository;
private final AuditCursorCodec cursorCodec;
Expand All @@ -38,13 +49,15 @@ public AuditQueryService(
ActorAuthorizer actorAuthorizer,
TenantDatabaseContext tenantDatabaseContext,
TaskRepository taskRepository,
WorkerRepository workerRepository,
AuditEventRepository auditRepository,
CompanySettingsRepository companySettingsRepository,
AuditCursorCodec cursorCodec
) {
this.actorAuthorizer = actorAuthorizer;
this.tenantDatabaseContext = tenantDatabaseContext;
this.taskRepository = taskRepository;
this.workerRepository = workerRepository;
this.auditRepository = auditRepository;
this.companySettingsRepository = companySettingsRepository;
this.cursorCodec = cursorCodec;
Expand All @@ -61,6 +74,45 @@ public List<AuditEventView> getTaskActivities(UUID taskId, ActorContext actor) {
.toList();
}

@Transactional(readOnly = true)
public WorkerActivityPageResult getWorkerActivities(
UUID workerId,
String cursor,
int requestedLimit,
ActorContext actor
) {
bindTenant(actor);
actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR, UserRole.VIEWER);
workerRepository.findByWorkerIdAndCompanyId(workerId, actor.companyId())
.orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND));
int limit = Math.max(1, Math.min(requestedLimit, MAX_PAGE_SIZE));
DecodedAuditCursor decodedCursor = cursorCodec.decode(cursor);
List<WorkerActivityRecord> fetched = auditRepository.findWorkerActivities(
new WorkerActivitySearchCriteria(
actor.companyId(),
workerId,
WORKER_ACTIVITY_ACTIONS,
decodedCursor.createdAt(),
decodedCursor.auditEventId(),
limit + 1
)
);
boolean hasNext = fetched.size() > limit;
List<WorkerActivityRecord> currentPage = hasNext
? fetched.subList(0, limit)
: fetched;
String nextCursor = hasNext
? cursorCodec.encode(
currentPage.get(currentPage.size() - 1).occurredAt(),
currentPage.get(currentPage.size() - 1).auditEventId()
)
: null;
return new WorkerActivityPageResult(
currentPage.stream().map(this::toWorkerActivity).toList(),
nextCursor
);
}

@Transactional(readOnly = true)
public AuditPageResult search(
ActorType actorType,
Expand Down Expand Up @@ -107,6 +159,49 @@ private String normalizeTraceId(String traceId) {
return traceId == null || traceId.isBlank() ? null : traceId.trim();
}

private WorkerActivityView toWorkerActivity(WorkerActivityRecord record) {
return switch (record.action()) {
case WORKER_LINK_SENT -> workerActivity(
record,
WorkerActivityType.GUIDANCE_SENT,
"모바일 안내를 전송했습니다."
);
case WORKER_LINK_ACCESSED -> workerActivity(
record,
WorkerActivityType.GUIDANCE_OPENED,
"근로자가 모바일 안내를 열었습니다."
);
case WORKER_LINK_RESPONSE_SUBMITTED -> workerActivity(
record,
WorkerActivityType.WORKER_RESPONSE_SUBMITTED,
"근로자가 안내에 응답했습니다."
);
case WORKER_LINK_RESPONSES_REVIEWED -> workerActivity(
record,
WorkerActivityType.RESPONSE_REVIEWED,
"담당자가 근로자 응답을 확인했습니다."
);
default -> throw new IllegalStateException(
"Unsupported worker activity action: " + record.action()
);
};
}

private WorkerActivityView workerActivity(
WorkerActivityRecord record,
WorkerActivityType type,
String summary
) {
return new WorkerActivityView(
record.auditEventId(),
type,
record.taskId(),
record.taskTitle(),
summary,
record.occurredAt()
);
}

private void requireAuditSearchPermission(ActorContext actor) {
actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR);
AuditVisibility visibility = companySettingsRepository
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.fowoco.server.audit.application;

import java.util.List;

public record WorkerActivityPageResult(
List<WorkerActivityView> items,
String nextCursor
) {
public WorkerActivityPageResult {
items = List.copyOf(items);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.fowoco.server.audit.application;

import com.fowoco.server.audit.domain.AuditAction;
import java.time.Instant;
import java.util.Set;
import java.util.UUID;

public record WorkerActivitySearchCriteria(
UUID companyId,
UUID workerId,
Set<AuditAction> actions,
Instant beforeCreatedAt,
UUID beforeAuditEventId,
int limit
) {
public WorkerActivitySearchCriteria {
actions = Set.copyOf(actions);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.fowoco.server.audit.application;

public enum WorkerActivityType {
GUIDANCE_SENT,
GUIDANCE_OPENED,
WORKER_RESPONSE_SUBMITTED,
RESPONSE_REVIEWED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.fowoco.server.audit.application;

import java.time.Instant;
import java.util.Objects;
import java.util.UUID;

public record WorkerActivityView(
UUID activityId,
WorkerActivityType type,
UUID taskId,
String taskTitle,
String summary,
Instant occurredAt
) {
public WorkerActivityView {
Objects.requireNonNull(activityId, "activityId must not be null");
Objects.requireNonNull(type, "type must not be null");
Objects.requireNonNull(taskId, "taskId must not be null");
if (taskTitle == null || taskTitle.isBlank()) {
throw new IllegalArgumentException("taskTitle must not be blank");
}
taskTitle = taskTitle.strip();
if (summary == null || summary.isBlank()) {
throw new IllegalArgumentException("summary must not be blank");
}
summary = summary.strip();
Objects.requireNonNull(occurredAt, "occurredAt must not be null");
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.fowoco.server.audit.application.port;

import com.fowoco.server.audit.application.AuditSearchCriteria;
import com.fowoco.server.audit.application.WorkerActivitySearchCriteria;
import com.fowoco.server.audit.domain.AuditEvent;
import com.fowoco.server.audit.domain.AuditAction;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
Expand All @@ -15,4 +18,15 @@ public interface AuditEventRepository {
List<AuditEvent> findTaskActivities(UUID companyId, UUID taskId);

List<AuditEvent> search(AuditSearchCriteria criteria);

List<WorkerActivityRecord> findWorkerActivities(WorkerActivitySearchCriteria criteria);

record WorkerActivityRecord(
UUID auditEventId,
AuditAction action,
UUID taskId,
String taskTitle,
Instant occurredAt
) {
}
}
Loading
Loading