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 @@ -3,6 +3,7 @@
public enum AuditAction {
TASK_CREATED,
TASK_UPDATED,
TASK_ASSIGNEE_CHANGED,
CHECKLIST_ITEM_UPDATED,
TASK_CANCELLED,
APPROVAL_REQUESTED,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.fowoco.server.task.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.task.application.ChangeTaskAssigneeCommand;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record ChangeTaskAssigneeRequest(
@Schema(
description = "같은 사업장의 활성 ADMIN 또는 HR 사용자 ID",
example = "7e2722bb-3c72-4aa0-b37c-28931c4f8e53"
)
@NotNull UUID assigneeId,
@Schema(description = "조회한 업무카드의 현재 version", example = "3")
@NotNull @Min(0) Long expectedVersion
) {
ChangeTaskAssigneeCommand toCommand() {
return new ChangeTaskAssigneeCommand(assigneeId, expectedVersion);
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/fowoco/server/task/api/TaskAssigneeResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.fowoco.server.task.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.task.application.TaskAssigneeView;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record TaskAssigneeResponse(
@Schema(description = "담당자 사용자 ID") UUID userId,
@Schema(description = "화면에 표시할 담당자 이름") String displayName
) {
public static TaskAssigneeResponse from(TaskAssigneeView assignee) {
return new TaskAssigneeResponse(assignee.userId(), assignee.displayName());
}
}
32 changes: 32 additions & 0 deletions src/main/java/com/fowoco/server/task/api/TaskController.java
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,38 @@ public TaskDetailResponse update(
));
}

@Operation(
operationId = "changeTaskAssignee",
summary = "업무카드 담당자 변경",
description = "같은 사업장의 활성 HR 또는 관리자를 담당자로 지정합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "담당자가 변경된 업무카드"),
@ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"),
@ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"),
@ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"),
@ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"),
@ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity")
})
@PreAuthorize("hasAnyRole('ADMIN', 'HR')")
@PatchMapping(
path = "/{taskId}/assignee",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public TaskDetailResponse changeAssignee(
@PathVariable UUID taskId,
@Valid @RequestBody ChangeTaskAssigneeRequest request,
HttpServletRequest servletRequest
) {
return TaskDetailResponse.from(taskService.changeAssignee(
taskId,
request.toCommand(),
actor(),
RequestMetadata.from(servletRequest)
));
}

@Operation(operationId = "updateTaskChecklistItem", summary = "체크리스트 항목 수정")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "체크리스트와 재평가된 업무 상태"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public record TaskDetailResponse(
TaskSource source,
TaskStatus status,
LocalDate dueDate,
TaskAssigneeResponse assignee,
long contentRevision,
long version,
List<String> missingRequiredSlots,
Expand All @@ -54,6 +55,7 @@ static TaskDetailResponse from(TaskResult result) {
task.source(),
task.status(),
task.dueDate(),
TaskAssigneeResponse.from(result.assignee()),
task.contentRevision(),
task.version(),
result.missingRequiredSlots(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.fowoco.server.task.application;

import java.util.Objects;
import java.util.UUID;

public record ChangeTaskAssigneeCommand(
UUID assigneeId,
long expectedVersion
) {
public ChangeTaskAssigneeCommand {
Objects.requireNonNull(assigneeId, "assigneeId must not be null");
if (expectedVersion < 0) {
throw new IllegalArgumentException("expectedVersion must not be negative");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.fowoco.server.task.application;

import java.util.Objects;
import java.util.UUID;

public record TaskAssigneeView(
UUID userId,
String displayName
) {
public TaskAssigneeView {
Objects.requireNonNull(userId, "userId must not be null");
if (displayName == null || displayName.isBlank()) {
throw new IllegalArgumentException("displayName must not be blank");
}
displayName = displayName.strip();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

public record TaskResult(
Task task,
TaskAssigneeView assignee,
Map<String, Object> businessData,
List<TaskChecklistItem> checklistItems,
List<String> missingRequiredSlots
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import com.fowoco.server.audit.domain.AuditTargetType;
import com.fowoco.server.auth.application.ActorAuthorizer;
import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.auth.application.CompanyMemberAccount;
import com.fowoco.server.auth.application.port.CompanyMemberDirectory;
import com.fowoco.server.auth.domain.UserRole;
import com.fowoco.server.common.error.ApiException;
import com.fowoco.server.common.id.UuidGenerator;
Expand Down Expand Up @@ -48,6 +50,7 @@ public class TaskWorkflowService {

private static final String AUDIT_EVENT_VERSION = "1";
private final ActorAuthorizer actorAuthorizer;
private final CompanyMemberDirectory companyMemberDirectory;
private final TenantDatabaseContext tenantDatabaseContext;
private final TaskRepository taskRepository;
private final TaskChecklistRepository checklistRepository;
Expand All @@ -64,6 +67,7 @@ public class TaskWorkflowService {

public TaskWorkflowService(
ActorAuthorizer actorAuthorizer,
CompanyMemberDirectory companyMemberDirectory,
TenantDatabaseContext tenantDatabaseContext,
TaskRepository taskRepository,
TaskChecklistRepository checklistRepository,
Expand All @@ -79,6 +83,7 @@ public TaskWorkflowService(
Clock clock
) {
this.actorAuthorizer = actorAuthorizer;
this.companyMemberDirectory = companyMemberDirectory;
this.tenantDatabaseContext = tenantDatabaseContext;
this.taskRepository = taskRepository;
this.checklistRepository = checklistRepository;
Expand Down Expand Up @@ -333,6 +338,51 @@ public TaskResult update(
);
}

@Transactional
public TaskResult changeAssignee(
UUID taskId,
ChangeTaskAssigneeCommand command,
ActorContext actor,
RequestMetadata metadata
) {
bindTenant(actor);
actorAuthorizer.requireHrWrite(actor);
Task task = requireTask(taskId, actor.companyId());
TaskAssigneeView nextAssignee = requireAssignableAssignee(
actor.companyId(),
command.assigneeId()
);
UUID previousAssigneeId = task.assigneeId();
Instant now = Instant.now(clock);
boolean changed = task.changeAssignee(
command.assigneeId(),
command.expectedVersion(),
actor.actorId(),
now
);
Task savedTask = changed ? taskRepository.save(task) : task;
if (changed) {
appendAudit(
savedTask,
actor,
AuditAction.TASK_ASSIGNEE_CHANGED,
"업무 담당자를 변경함 (%s → %s)".formatted(
previousAssigneeId,
savedTask.assigneeId()
),
metadata,
now
);
}
return toResult(
savedTask,
checklistRepository.findAllByTaskIdAndCompanyId(taskId, actor.companyId()),
findWorker(savedTask, actor.companyId()),
catalogService.requireWorkflow(savedTask.workflowId()),
nextAssignee
);
}

@Transactional
public TaskResult updateChecklistItem(
UUID taskId,
Expand Down Expand Up @@ -469,16 +519,57 @@ private TaskResult toResult(
List<TaskChecklistItem> checklistItems,
WorkerTaskContext worker,
WorkflowDefinition workflow
) {
return toResult(
task,
checklistItems,
worker,
workflow,
requireAssignee(task, task.companyId())
);
}

private TaskResult toResult(
Task task,
List<TaskChecklistItem> checklistItems,
WorkerTaskContext worker,
WorkflowDefinition workflow,
TaskAssigneeView assignee
) {
Map<String, Object> businessData = contentCodec.decodeBusinessData(task.businessDataJson());
return new TaskResult(
task,
assignee,
businessData,
checklistItems,
missingRequiredSlots(workflow, worker, task.dueDate(), businessData)
);
}

private TaskAssigneeView requireAssignableAssignee(UUID companyId, UUID assigneeId) {
CompanyMemberAccount member = requireCompanyMember(companyId, assigneeId);
boolean assignableRole = member.role() == UserRole.ADMIN || member.role() == UserRole.HR;
if (!member.active() || !assignableRole) {
throw new ApiException(TaskErrorCode.TASK_ASSIGNEE_NOT_ASSIGNABLE);
}
return toAssigneeView(member);
}

private TaskAssigneeView requireAssignee(Task task, UUID companyId) {
return toAssigneeView(requireCompanyMember(companyId, task.assigneeId()));
}

private CompanyMemberAccount requireCompanyMember(UUID companyId, UUID userId) {
return companyMemberDirectory.findByCompanyId(companyId, null, false).stream()
.filter(member -> member.userId().equals(userId))
.findFirst()
.orElseThrow(() -> new ApiException(TaskErrorCode.TASK_ASSIGNEE_NOT_FOUND));
}

private TaskAssigneeView toAssigneeView(CompanyMemberAccount member) {
return new TaskAssigneeView(member.userId(), member.displayName());
}

private List<String> missingRequiredSlots(
WorkflowDefinition workflow,
WorkerTaskContext worker,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@

public enum TaskErrorCode implements ApiErrorCode {
TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "업무카드를 찾을 수 없습니다."),
TASK_ASSIGNEE_NOT_FOUND(HttpStatus.NOT_FOUND, "지정할 담당자를 찾을 수 없습니다."),
TASK_ASSIGNEE_NOT_ASSIGNABLE(
HttpStatus.UNPROCESSABLE_CONTENT,
"활성 상태의 HR 또는 관리자만 업무 담당자로 지정할 수 있습니다."
),
WORKER_NOT_FOUND(HttpStatus.NOT_FOUND, "근로자를 찾을 수 없습니다."),
WORKFLOW_NOT_FOUND(HttpStatus.NOT_FOUND, "Workflow를 찾을 수 없습니다."),
WORKFLOW_TASK_TYPE_MISMATCH(
Expand Down
29 changes: 29 additions & 0 deletions src/main/java/com/fowoco/server/task/domain/Task.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public final class Task {
private final TaskSource source;
private TaskStatus status;
private LocalDate dueDate;
private UUID assigneeId;
private final UUID createdBy;
private UUID updatedBy;
private final Instant createdAt;
Expand All @@ -48,6 +49,7 @@ public Task(
TaskSource source,
TaskStatus status,
LocalDate dueDate,
UUID assigneeId,
UUID createdBy,
UUID updatedBy,
Instant createdAt,
Expand All @@ -74,6 +76,7 @@ public Task(
this.source = Objects.requireNonNull(source);
this.status = Objects.requireNonNull(status);
this.dueDate = dueDate;
this.assigneeId = Objects.requireNonNull(assigneeId);
this.createdBy = Objects.requireNonNull(createdBy);
this.updatedBy = Objects.requireNonNull(updatedBy);
this.createdAt = Objects.requireNonNull(createdAt);
Expand Down Expand Up @@ -121,6 +124,7 @@ public Task(
status,
dueDate,
createdBy,
createdBy,
updatedBy,
createdAt,
updatedAt,
Expand Down Expand Up @@ -169,6 +173,7 @@ public static Task create(
dueDate,
actorId,
actorId,
actorId,
now,
now,
0
Expand Down Expand Up @@ -287,6 +292,26 @@ public TaskStatus cancel(long expectedVersion, UUID actorId, Instant now) {
return transition(TaskStatus.CANCELLED, actorId, now);
}

public boolean changeAssignee(
UUID assigneeId,
long expectedVersion,
UUID actorId,
Instant now
) {
requireVersion(expectedVersion);
if (status.isTerminal()) {
throw new ApiException(TaskErrorCode.TASK_TRANSITION_NOT_ALLOWED);
}
UUID nextAssigneeId = Objects.requireNonNull(assigneeId);
if (this.assigneeId.equals(nextAssigneeId)) {
return false;
}
this.assigneeId = nextAssigneeId;
this.updatedBy = Objects.requireNonNull(actorId);
this.updatedAt = Objects.requireNonNull(now);
return true;
}

public UpdateOutcome updateContent(
String title,
String description,
Expand Down Expand Up @@ -481,6 +506,10 @@ public LocalDate dueDate() {
return dueDate;
}

public UUID assigneeId() {
return assigneeId;
}

public UUID createdBy() {
return createdBy;
}
Expand Down
Loading
Loading