From 79514cb18c61f71ff7300068af3bb7f0b80b92c9 Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 12 Aug 2026 10:45:32 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(task):=20=EC=97=85=EB=AC=B4=20?= =?UTF-8?q?=EB=8B=B4=EB=8B=B9=EC=9E=90=20=EC=A0=80=EC=9E=A5=EA=B3=BC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/audit/domain/AuditAction.java | 1 + .../server/casework/api/CaseTaskResponse.java | 7 +- .../application/CaseQueryService.java | 4 +- .../application/CaseTaskProjection.java | 4 +- .../application/port/CaseQueryRepository.java | 4 +- .../persistence/JdbcCaseQueryRepository.java | 22 +++- .../task/api/ChangeTaskAssigneeRequest.java | 24 ++++ .../server/task/api/TaskAssigneeResponse.java | 17 +++ .../server/task/api/TaskController.java | 32 +++++ .../server/task/api/TaskDetailResponse.java | 2 + .../server/task/api/TaskSummaryResponse.java | 6 +- .../ChangeTaskAssigneeCommand.java | 16 +++ .../task/application/TaskAssigneeView.java | 17 +++ .../task/application/TaskPageResult.java | 3 +- .../server/task/application/TaskResult.java | 1 + .../task/application/TaskSummaryView.java | 14 +++ .../task/application/TaskWorkflowService.java | 116 +++++++++++++++++- .../task/application/error/TaskErrorCode.java | 5 + .../com/fowoco/server/task/domain/Task.java | 29 +++++ .../persistence/TaskJpaEntity.java | 5 + .../db/migration/V43__add_task_assignee.sql | 14 +++ 21 files changed, 329 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/fowoco/server/task/api/ChangeTaskAssigneeRequest.java create mode 100644 src/main/java/com/fowoco/server/task/api/TaskAssigneeResponse.java create mode 100644 src/main/java/com/fowoco/server/task/application/ChangeTaskAssigneeCommand.java create mode 100644 src/main/java/com/fowoco/server/task/application/TaskAssigneeView.java create mode 100644 src/main/java/com/fowoco/server/task/application/TaskSummaryView.java create mode 100644 src/main/resources/db/migration/V43__add_task_assignee.sql 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 55f4569d..33023002 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -3,6 +3,7 @@ public enum AuditAction { TASK_CREATED, TASK_UPDATED, + TASK_ASSIGNEE_CHANGED, CHECKLIST_ITEM_UPDATED, TASK_CANCELLED, APPROVAL_REQUESTED, diff --git a/src/main/java/com/fowoco/server/casework/api/CaseTaskResponse.java b/src/main/java/com/fowoco/server/casework/api/CaseTaskResponse.java index 70ee5246..ffd5260d 100644 --- a/src/main/java/com/fowoco/server/casework/api/CaseTaskResponse.java +++ b/src/main/java/com/fowoco/server/casework/api/CaseTaskResponse.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.fowoco.server.casework.application.CaseTaskProjection; +import com.fowoco.server.task.api.TaskAssigneeResponse; import com.fowoco.server.task.domain.TaskStatus; import com.fowoco.server.task.domain.TaskType; import java.time.LocalDate; @@ -14,7 +15,8 @@ public record CaseTaskResponse( TaskType taskType, String title, TaskStatus status, - LocalDate dueDate + LocalDate dueDate, + TaskAssigneeResponse assignee ) { static CaseTaskResponse from(CaseTaskProjection task) { return task == null ? null : new CaseTaskResponse( @@ -22,7 +24,8 @@ static CaseTaskResponse from(CaseTaskProjection task) { task.taskType(), task.title(), task.status(), - task.dueDate() + task.dueDate(), + new TaskAssigneeResponse(task.assigneeId(), task.assigneeDisplayName()) ); } } 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 e47fbf48..28b4f182 100644 --- a/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java +++ b/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java @@ -218,7 +218,9 @@ private static CaseTaskProjection toTaskProjection(CaseTaskRecord task) { task.taskType(), task.title(), task.status(), - task.dueDate() + task.dueDate(), + task.assigneeId(), + task.assigneeDisplayName() ); } diff --git a/src/main/java/com/fowoco/server/casework/application/CaseTaskProjection.java b/src/main/java/com/fowoco/server/casework/application/CaseTaskProjection.java index dca08813..ea228a89 100644 --- a/src/main/java/com/fowoco/server/casework/application/CaseTaskProjection.java +++ b/src/main/java/com/fowoco/server/casework/application/CaseTaskProjection.java @@ -10,6 +10,8 @@ public record CaseTaskProjection( TaskType taskType, String title, TaskStatus status, - LocalDate dueDate + LocalDate dueDate, + UUID assigneeId, + String assigneeDisplayName ) { } 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 42561148..e308512f 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 @@ -52,7 +52,9 @@ record CaseTaskRecord( TaskType taskType, String title, TaskStatus status, - LocalDate dueDate + LocalDate dueDate, + UUID assigneeId, + String assigneeDisplayName ) { } } 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 be562923..cc47511a 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 @@ -222,11 +222,21 @@ public List findTasks(UUID companyId, List caseIds) { } return jdbcTemplate.query( """ - SELECT case_id, task_id, task_type, title, status, due_date + SELECT task.case_id, + task.task_id, + task.task_type, + task.title, + task.status, + task.due_date, + COALESCE(task.assignee_id, task.created_by) AS assignee_id, + assignee.display_name AS assignee_display_name FROM task - WHERE company_id = :companyId - AND case_id IN (:caseIds) - ORDER BY due_date ASC, created_at ASC + JOIN user_account assignee + ON assignee.user_id = COALESCE(task.assignee_id, task.created_by) + AND assignee.company_id = task.company_id + WHERE task.company_id = :companyId + AND task.case_id IN (:caseIds) + ORDER BY task.due_date ASC, task.created_at ASC """, Map.of("companyId", companyId, "caseIds", caseIds), (resultSet, rowNumber) -> new CaseTaskRecord( @@ -235,7 +245,9 @@ AND case_id IN (:caseIds) TaskType.valueOf(resultSet.getString("task_type")), resultSet.getString("title"), TaskStatus.valueOf(resultSet.getString("status")), - resultSet.getObject("due_date", java.time.LocalDate.class) + resultSet.getObject("due_date", java.time.LocalDate.class), + resultSet.getObject("assignee_id", UUID.class), + resultSet.getString("assignee_display_name") ) ); } diff --git a/src/main/java/com/fowoco/server/task/api/ChangeTaskAssigneeRequest.java b/src/main/java/com/fowoco/server/task/api/ChangeTaskAssigneeRequest.java new file mode 100644 index 00000000..a4c377d5 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/api/ChangeTaskAssigneeRequest.java @@ -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); + } +} diff --git a/src/main/java/com/fowoco/server/task/api/TaskAssigneeResponse.java b/src/main/java/com/fowoco/server/task/api/TaskAssigneeResponse.java new file mode 100644 index 00000000..1d88471b --- /dev/null +++ b/src/main/java/com/fowoco/server/task/api/TaskAssigneeResponse.java @@ -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()); + } +} diff --git a/src/main/java/com/fowoco/server/task/api/TaskController.java b/src/main/java/com/fowoco/server/task/api/TaskController.java index b2083530..41c35d6b 100644 --- a/src/main/java/com/fowoco/server/task/api/TaskController.java +++ b/src/main/java/com/fowoco/server/task/api/TaskController.java @@ -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 = "체크리스트와 재평가된 업무 상태"), diff --git a/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java b/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java index b05e250a..2fd67f75 100644 --- a/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java +++ b/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java @@ -29,6 +29,7 @@ public record TaskDetailResponse( TaskSource source, TaskStatus status, LocalDate dueDate, + TaskAssigneeResponse assignee, long contentRevision, long version, List missingRequiredSlots, @@ -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(), diff --git a/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java b/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java index c17e3d90..fc916d87 100644 --- a/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java +++ b/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java @@ -7,6 +7,7 @@ import com.fowoco.server.task.domain.TaskStatus; import com.fowoco.server.task.domain.TaskTargetType; import com.fowoco.server.task.domain.TaskType; +import com.fowoco.server.task.application.TaskSummaryView; import java.time.Instant; import java.time.LocalDate; import java.util.UUID; @@ -24,12 +25,14 @@ public record TaskSummaryResponse( TaskSource source, TaskStatus status, LocalDate dueDate, + TaskAssigneeResponse assignee, long contentRevision, long version, Instant createdAt, Instant updatedAt ) { - static TaskSummaryResponse from(Task task) { + static TaskSummaryResponse from(TaskSummaryView view) { + Task task = view.task(); return new TaskSummaryResponse( task.taskId(), task.targetType(), @@ -42,6 +45,7 @@ static TaskSummaryResponse from(Task task) { task.source(), task.status(), task.dueDate(), + TaskAssigneeResponse.from(view.assignee()), task.contentRevision(), task.version(), task.createdAt(), diff --git a/src/main/java/com/fowoco/server/task/application/ChangeTaskAssigneeCommand.java b/src/main/java/com/fowoco/server/task/application/ChangeTaskAssigneeCommand.java new file mode 100644 index 00000000..ac470e78 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/ChangeTaskAssigneeCommand.java @@ -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"); + } + } +} diff --git a/src/main/java/com/fowoco/server/task/application/TaskAssigneeView.java b/src/main/java/com/fowoco/server/task/application/TaskAssigneeView.java new file mode 100644 index 00000000..23e5e6a4 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/TaskAssigneeView.java @@ -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(); + } +} diff --git a/src/main/java/com/fowoco/server/task/application/TaskPageResult.java b/src/main/java/com/fowoco/server/task/application/TaskPageResult.java index e8538193..e0c8ea4b 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskPageResult.java +++ b/src/main/java/com/fowoco/server/task/application/TaskPageResult.java @@ -1,10 +1,9 @@ package com.fowoco.server.task.application; -import com.fowoco.server.task.domain.Task; import java.util.List; public record TaskPageResult( - List items, + List items, int page, int size, long totalElements, diff --git a/src/main/java/com/fowoco/server/task/application/TaskResult.java b/src/main/java/com/fowoco/server/task/application/TaskResult.java index a21be9e8..faea9f41 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskResult.java +++ b/src/main/java/com/fowoco/server/task/application/TaskResult.java @@ -9,6 +9,7 @@ public record TaskResult( Task task, + TaskAssigneeView assignee, Map businessData, List checklistItems, List missingRequiredSlots diff --git a/src/main/java/com/fowoco/server/task/application/TaskSummaryView.java b/src/main/java/com/fowoco/server/task/application/TaskSummaryView.java new file mode 100644 index 00000000..69b9e5e3 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/TaskSummaryView.java @@ -0,0 +1,14 @@ +package com.fowoco.server.task.application; + +import com.fowoco.server.task.domain.Task; +import java.util.Objects; + +public record TaskSummaryView( + Task task, + TaskAssigneeView assignee +) { + public TaskSummaryView { + Objects.requireNonNull(task, "task must not be null"); + Objects.requireNonNull(assignee, "assignee must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java index 96e7bd49..41823d11 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java +++ b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java @@ -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; @@ -37,6 +39,7 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -48,6 +51,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; @@ -64,6 +68,7 @@ public class TaskWorkflowService { public TaskWorkflowService( ActorAuthorizer actorAuthorizer, + CompanyMemberDirectory companyMemberDirectory, TenantDatabaseContext tenantDatabaseContext, TaskRepository taskRepository, TaskChecklistRepository checklistRepository, @@ -79,6 +84,7 @@ public TaskWorkflowService( Clock clock ) { this.actorAuthorizer = actorAuthorizer; + this.companyMemberDirectory = companyMemberDirectory; this.tenantDatabaseContext = tenantDatabaseContext; this.taskRepository = taskRepository; this.checklistRepository = checklistRepository; @@ -219,7 +225,7 @@ public TaskPageResult findAll( size )); return new TaskPageResult( - result.items(), + taskSummaryViews(result.items(), actor.companyId()), result.page(), result.size(), result.totalElements(), @@ -333,6 +339,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, @@ -469,16 +520,79 @@ private TaskResult toResult( List checklistItems, WorkerTaskContext worker, WorkflowDefinition workflow + ) { + return toResult( + task, + checklistItems, + worker, + workflow, + requireAssignee(task, task.companyId()) + ); + } + + private TaskResult toResult( + Task task, + List checklistItems, + WorkerTaskContext worker, + WorkflowDefinition workflow, + TaskAssigneeView assignee ) { Map businessData = contentCodec.decodeBusinessData(task.businessDataJson()); return new TaskResult( task, + assignee, businessData, checklistItems, missingRequiredSlots(workflow, worker, task.dueDate(), businessData) ); } + private List taskSummaryViews(List tasks, UUID companyId) { + Map assignees = new HashMap<>(); + companyMemberDirectory.findByCompanyId(companyId, null, false) + .forEach(member -> assignees.put(member.userId(), toAssigneeView(member))); + return tasks.stream() + .map(task -> new TaskSummaryView( + task, + requireAssignee(task, assignees) + )) + .toList(); + } + + private TaskAssigneeView requireAssignableAssignee(UUID companyId, UUID assigneeId) { + CompanyMemberAccount member = requireCompanyMember(companyId, assigneeId); + if (!member.active() || member.role() == UserRole.VIEWER) { + 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 TaskAssigneeView requireAssignee( + Task task, + Map assignees + ) { + TaskAssigneeView assignee = assignees.get(task.assigneeId()); + if (assignee == null) { + throw new IllegalStateException("Persisted task assignee is missing"); + } + return assignee; + } + + 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 missingRequiredSlots( WorkflowDefinition workflow, WorkerTaskContext worker, diff --git a/src/main/java/com/fowoco/server/task/application/error/TaskErrorCode.java b/src/main/java/com/fowoco/server/task/application/error/TaskErrorCode.java index 51f8fe9b..55d2f4a3 100644 --- a/src/main/java/com/fowoco/server/task/application/error/TaskErrorCode.java +++ b/src/main/java/com/fowoco/server/task/application/error/TaskErrorCode.java @@ -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( diff --git a/src/main/java/com/fowoco/server/task/domain/Task.java b/src/main/java/com/fowoco/server/task/domain/Task.java index 45cd8dc5..87c6da8d 100644 --- a/src/main/java/com/fowoco/server/task/domain/Task.java +++ b/src/main/java/com/fowoco/server/task/domain/Task.java @@ -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; @@ -48,6 +49,7 @@ public Task( TaskSource source, TaskStatus status, LocalDate dueDate, + UUID assigneeId, UUID createdBy, UUID updatedBy, Instant createdAt, @@ -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); @@ -121,6 +124,7 @@ public Task( status, dueDate, createdBy, + createdBy, updatedBy, createdAt, updatedAt, @@ -169,6 +173,7 @@ public static Task create( dueDate, actorId, actorId, + actorId, now, now, 0 @@ -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, @@ -481,6 +506,10 @@ public LocalDate dueDate() { return dueDate; } + public UUID assigneeId() { + return assigneeId; + } + public UUID createdBy() { return createdBy; } diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/TaskJpaEntity.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/TaskJpaEntity.java index 560c69e3..849e6236 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/TaskJpaEntity.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/TaskJpaEntity.java @@ -64,6 +64,8 @@ public class TaskJpaEntity { private TaskStatus status; @Column(name = "due_date") private LocalDate dueDate; + @Column(name = "assignee_id") + private UUID assigneeId; @Column(name = "created_by", nullable = false, updatable = false) private UUID createdBy; @Column(name = "updated_by", nullable = false) @@ -92,6 +94,7 @@ public void apply(Task task) { this.contentRevision = task.contentRevision(); this.status = task.status(); this.dueDate = task.dueDate(); + this.assigneeId = task.assigneeId(); this.updatedBy = task.updatedBy(); this.updatedAt = task.updatedAt(); } @@ -113,6 +116,7 @@ private void copyFrom(Task task) { this.source = task.source(); this.status = task.status(); this.dueDate = task.dueDate(); + this.assigneeId = task.assigneeId(); this.createdBy = task.createdBy(); this.updatedBy = task.updatedBy(); this.createdAt = task.createdAt(); @@ -137,6 +141,7 @@ public Task toDomain() { source, status, dueDate, + assigneeId == null ? createdBy : assigneeId, createdBy, updatedBy, createdAt, diff --git a/src/main/resources/db/migration/V43__add_task_assignee.sql b/src/main/resources/db/migration/V43__add_task_assignee.sql new file mode 100644 index 00000000..1f7f8fee --- /dev/null +++ b/src/main/resources/db/migration/V43__add_task_assignee.sql @@ -0,0 +1,14 @@ +ALTER TABLE task + ADD COLUMN assignee_id UUID; + +UPDATE task +SET assignee_id = created_by +WHERE assignee_id IS NULL; + +ALTER TABLE task + ADD CONSTRAINT fk_task_assignee_company + FOREIGN KEY (assignee_id, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT; + +CREATE INDEX idx_task_company_assignee + ON task (company_id, assignee_id); From dcea5906f858890f1d3e1bb2114321c8cfe1dbaa Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 12 Aug 2026 10:45:32 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test(task):=20=EB=8B=B4=EB=8B=B9=EC=9E=90?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=EA=B3=BC=20=EC=82=AC=EC=97=85=EC=9E=A5=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../task/TaskWorkflowIntegrationTest.java | 141 +++++++++++++++++- .../fowoco/server/task/domain/TaskTest.java | 32 ++++ 2 files changed, 168 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java index 0a9d9cf2..607d2c26 100644 --- a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java +++ b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java @@ -1,6 +1,7 @@ package com.fowoco.server.task; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.jayway.jsonpath.JsonPath; import java.net.URI; @@ -22,6 +23,7 @@ import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.HttpHeaders; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; @@ -37,6 +39,10 @@ class TaskWorkflowIntegrationTest { UUID.fromString("c1000000-0000-0000-0000-000000000001"); private static final UUID VIEWER_A = UUID.fromString("c2000000-0000-0000-0000-000000000001"); + private static final UUID HR_A_SECOND = + UUID.fromString("c1000000-0000-0000-0000-000000000002"); + private static final UUID SUSPENDED_HR_A = + UUID.fromString("c1000000-0000-0000-0000-000000000003"); private static final UUID HR_B = UUID.fromString("d1000000-0000-0000-0000-000000000001"); private static final UUID WORKER_A = @@ -44,6 +50,7 @@ class TaskWorkflowIntegrationTest { private static final String PASSWORD = "Test-password-1!"; private static final String HR_A_EMAIL = "task.hr.a@example.com"; private static final String VIEWER_A_EMAIL = "task.viewer.a@example.com"; + private static final String HR_A_SECOND_EMAIL = "task.hr.a.second@example.com"; private static final String HR_B_EMAIL = "task.hr.b@example.com"; @LocalServerPort @@ -77,12 +84,134 @@ void resetAndSeed() { insertCompany(COMPANY_A, "Task 테스트 사업장 A"); insertCompany(COMPANY_B, "Task 테스트 사업장 B"); String passwordHash = passwordEncoder.encode(PASSWORD); - insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash, "HR"); - insertUser(VIEWER_A, COMPANY_A, VIEWER_A_EMAIL, passwordHash, "VIEWER"); - insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash, "HR"); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, "담당자 A", passwordHash, "HR"); + insertUser(HR_A_SECOND, COMPANY_A, HR_A_SECOND_EMAIL, "담당자 B", passwordHash, "HR"); + insertUser( + SUSPENDED_HR_A, + COMPANY_A, + "task.hr.a.suspended@example.com", + "정지 담당자", + passwordHash, + "HR" + ); + jdbcTemplate.update( + "UPDATE user_account SET status = 'SUSPENDED' WHERE user_id = ?", + SUSPENDED_HR_A + ); + insertUser(VIEWER_A, COMPANY_A, VIEWER_A_EMAIL, "조회자 A", passwordHash, "VIEWER"); + insertUser(HR_B, COMPANY_B, HR_B_EMAIL, "담당자 B사", passwordHash, "HR"); insertWorker(); } + @Test + void changesTaskAssigneeAndReturnsItFromTaskAndCaseQueries() throws Exception { + String hrToken = login(HR_A_EMAIL); + HttpResponse created = post("/api/v1/tasks", validCreateBody(), hrToken); + UUID taskId = UUID.fromString(JsonPath.read(created.body(), "$.task_id")); + UUID caseId = UUID.fromString(JsonPath.read(created.body(), "$.case_id")); + assertThat(JsonPath.read(created.body(), "$.assignee.user_id")) + .isEqualTo(HR_A.toString()); + assertThat(JsonPath.read(created.body(), "$.assignee.display_name")) + .isEqualTo("담당자 A"); + + HttpResponse changed = patch( + "/api/v1/tasks/" + taskId + "/assignee", + """ + { + "assignee_id":"%s", + "expected_version":0 + } + """.formatted(HR_A_SECOND), + hrToken + ); + + assertThat(changed.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(changed.body(), "$.assignee.user_id")) + .isEqualTo(HR_A_SECOND.toString()); + assertThat(JsonPath.read(changed.body(), "$.assignee.display_name")) + .isEqualTo("담당자 B"); + assertThat(JsonPath.read(changed.body(), "$.version").longValue()).isEqualTo(1); + + HttpResponse detail = get("/api/v1/tasks/" + taskId, hrToken); + HttpResponse page = get("/api/v1/tasks?case_id=" + caseId, hrToken); + HttpResponse caseProjection = get( + "/api/v1/cases/" + caseId + "/projection", + hrToken + ); + assertThat(JsonPath.read(detail.body(), "$.assignee.user_id")) + .isEqualTo(HR_A_SECOND.toString()); + assertThat(JsonPath.read(page.body(), "$.items[0].assignee.display_name")) + .isEqualTo("담당자 B"); + assertThat(JsonPath.read( + caseProjection.body(), + "$.current_task.assignee.user_id" + )).isEqualTo(HR_A_SECOND.toString()); + assertThat(jdbcTemplate.queryForObject( + "SELECT assignee_id FROM task WHERE task_id = ?", + UUID.class, + taskId + )).isEqualTo(HR_A_SECOND); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event " + + "WHERE target_id = ? AND action = 'TASK_ASSIGNEE_CHANGED'", + Integer.class, + taskId + )).isEqualTo(1); + } + + @Test + void rejectsUnauthorizedCrossCompanyInactiveViewerAndStaleAssigneeChanges() throws Exception { + String hrToken = login(HR_A_EMAIL); + UUID taskId = UUID.fromString(JsonPath.read( + post("/api/v1/tasks", validCreateBody(), hrToken).body(), + "$.task_id" + )); + String request = """ + {"assignee_id":"%s","expected_version":0} + """.formatted(HR_A_SECOND); + assertThat(patch("/api/v1/tasks/" + taskId + "/assignee", request, hrToken).statusCode()) + .isEqualTo(200); + + assertThat(patch( + "/api/v1/tasks/" + taskId + "/assignee", + request, + hrToken + ).statusCode()).isEqualTo(409); + assertThat(patch( + "/api/v1/tasks/" + taskId + "/assignee", + """ + {"assignee_id":"%s","expected_version":1} + """.formatted(HR_B), + hrToken + ).statusCode()).isEqualTo(404); + assertThat(patch( + "/api/v1/tasks/" + taskId + "/assignee", + """ + {"assignee_id":"%s","expected_version":1} + """.formatted(SUSPENDED_HR_A), + hrToken + ).statusCode()).isEqualTo(422); + assertThat(patch( + "/api/v1/tasks/" + taskId + "/assignee", + """ + {"assignee_id":"%s","expected_version":1} + """.formatted(VIEWER_A), + hrToken + ).statusCode()).isEqualTo(422); + assertThat(patch( + "/api/v1/tasks/" + taskId + "/assignee", + """ + {"assignee_id":"%s","expected_version":1} + """.formatted(HR_A), + login(VIEWER_A_EMAIL) + ).statusCode()).isEqualTo(403); + assertThatThrownBy(() -> jdbcTemplate.update( + "UPDATE task SET assignee_id = ? WHERE task_id = ?", + HR_B, + taskId + )).isInstanceOf(DataIntegrityViolationException.class); + } + @Test void supportsTheCatalogTaskChecklistAndCancelApiFlow() throws Exception { String token = login(HR_A_EMAIL); @@ -694,18 +823,20 @@ private void insertUser( UUID userId, UUID companyId, String email, + String displayName, String passwordHash, String role ) { jdbcTemplate.update( """ INSERT INTO user_account ( - user_id, company_id, email, normalized_email, password_hash, + user_id, company_id, display_name, email, normalized_email, password_hash, role, status, created_at, updated_at, version - ) VALUES (?, ?, ?, ?, ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) """, userId, companyId, + displayName, email, email, passwordHash, diff --git a/src/test/java/com/fowoco/server/task/domain/TaskTest.java b/src/test/java/com/fowoco/server/task/domain/TaskTest.java index c6393eb4..da5af06a 100644 --- a/src/test/java/com/fowoco/server/task/domain/TaskTest.java +++ b/src/test/java/com/fowoco/server/task/domain/TaskTest.java @@ -101,6 +101,38 @@ void rejectsAStaleExpectedVersion() { assertThat(exception.errorCode()).isEqualTo(TaskErrorCode.CONCURRENT_MODIFICATION)); } + @Test + void changesAssigneeWithoutChangingTaskContentOrStatus() { + Task task = task(TaskStatus.APPROVED); + UUID nextAssigneeId = UUID.fromString("10000000-0000-0000-0000-000000000002"); + + boolean changed = task.changeAssignee( + nextAssigneeId, + 0, + ACTOR_ID, + NOW.plusSeconds(1) + ); + + assertThat(changed).isTrue(); + assertThat(task.assigneeId()).isEqualTo(nextAssigneeId); + assertThat(task.status()).isEqualTo(TaskStatus.APPROVED); + assertThat(task.contentRevision()).isZero(); + } + + @Test + void terminalTaskRejectsAssigneeChange() { + Task task = task(TaskStatus.COMPLETED); + + assertThatThrownBy(() -> task.changeAssignee( + UUID.randomUUID(), + 0, + ACTOR_ID, + NOW.plusSeconds(1) + )).isInstanceOfSatisfying(ApiException.class, exception -> + assertThat(exception.errorCode()) + .isEqualTo(TaskErrorCode.TASK_TRANSITION_NOT_ALLOWED)); + } + @Test void terminalTaskCannotBeCancelledAgain() { Task task = task(TaskStatus.COMPLETED); From 7b8de5c6cd3848f9158eeef790a1a1c243500fa9 Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 12 Aug 2026 11:10:02 +0900 Subject: [PATCH 3/5] =?UTF-8?q?chore(database):=20=EB=8B=B4=EB=8B=B9?= =?UTF-8?q?=EC=9E=90=20migration=EC=9D=84=20V42=EB=A1=9C=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{V43__add_task_assignee.sql => V42__add_task_assignee.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V43__add_task_assignee.sql => V42__add_task_assignee.sql} (100%) diff --git a/src/main/resources/db/migration/V43__add_task_assignee.sql b/src/main/resources/db/migration/V42__add_task_assignee.sql similarity index 100% rename from src/main/resources/db/migration/V43__add_task_assignee.sql rename to src/main/resources/db/migration/V42__add_task_assignee.sql From 1af3423eccf09450f09cda120211d2b267b39be9 Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 12 Aug 2026 11:10:02 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor(task):=20=EB=8B=B4=EB=8B=B9?= =?UTF-8?q?=EC=9E=90=20=EB=B3=80=EA=B2=BD=20=EB=B2=94=EC=9C=84=EB=A5=BC=20?= =?UTF-8?q?Task=20=EC=83=81=EC=84=B8=EB=A1=9C=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/casework/api/CaseTaskResponse.java | 7 ++--- .../application/CaseQueryService.java | 4 +-- .../application/CaseTaskProjection.java | 4 +-- .../application/port/CaseQueryRepository.java | 4 +-- .../persistence/JdbcCaseQueryRepository.java | 22 ++++------------ .../server/task/api/TaskSummaryResponse.java | 6 +---- .../task/application/TaskPageResult.java | 3 ++- .../task/application/TaskSummaryView.java | 14 ---------- .../task/application/TaskWorkflowService.java | 26 +------------------ .../task/TaskWorkflowIntegrationTest.java | 14 +--------- 10 files changed, 15 insertions(+), 89 deletions(-) delete mode 100644 src/main/java/com/fowoco/server/task/application/TaskSummaryView.java diff --git a/src/main/java/com/fowoco/server/casework/api/CaseTaskResponse.java b/src/main/java/com/fowoco/server/casework/api/CaseTaskResponse.java index ffd5260d..70ee5246 100644 --- a/src/main/java/com/fowoco/server/casework/api/CaseTaskResponse.java +++ b/src/main/java/com/fowoco/server/casework/api/CaseTaskResponse.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.fowoco.server.casework.application.CaseTaskProjection; -import com.fowoco.server.task.api.TaskAssigneeResponse; import com.fowoco.server.task.domain.TaskStatus; import com.fowoco.server.task.domain.TaskType; import java.time.LocalDate; @@ -15,8 +14,7 @@ public record CaseTaskResponse( TaskType taskType, String title, TaskStatus status, - LocalDate dueDate, - TaskAssigneeResponse assignee + LocalDate dueDate ) { static CaseTaskResponse from(CaseTaskProjection task) { return task == null ? null : new CaseTaskResponse( @@ -24,8 +22,7 @@ static CaseTaskResponse from(CaseTaskProjection task) { task.taskType(), task.title(), task.status(), - task.dueDate(), - new TaskAssigneeResponse(task.assigneeId(), task.assigneeDisplayName()) + task.dueDate() ); } } 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 28b4f182..e47fbf48 100644 --- a/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java +++ b/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java @@ -218,9 +218,7 @@ private static CaseTaskProjection toTaskProjection(CaseTaskRecord task) { task.taskType(), task.title(), task.status(), - task.dueDate(), - task.assigneeId(), - task.assigneeDisplayName() + task.dueDate() ); } diff --git a/src/main/java/com/fowoco/server/casework/application/CaseTaskProjection.java b/src/main/java/com/fowoco/server/casework/application/CaseTaskProjection.java index ea228a89..dca08813 100644 --- a/src/main/java/com/fowoco/server/casework/application/CaseTaskProjection.java +++ b/src/main/java/com/fowoco/server/casework/application/CaseTaskProjection.java @@ -10,8 +10,6 @@ public record CaseTaskProjection( TaskType taskType, String title, TaskStatus status, - LocalDate dueDate, - UUID assigneeId, - String assigneeDisplayName + LocalDate dueDate ) { } 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 e308512f..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 @@ -52,9 +52,7 @@ record CaseTaskRecord( TaskType taskType, String title, TaskStatus status, - LocalDate dueDate, - UUID assigneeId, - String assigneeDisplayName + LocalDate dueDate ) { } } 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 cc47511a..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 @@ -222,21 +222,11 @@ public List findTasks(UUID companyId, List caseIds) { } return jdbcTemplate.query( """ - SELECT task.case_id, - task.task_id, - task.task_type, - task.title, - task.status, - task.due_date, - COALESCE(task.assignee_id, task.created_by) AS assignee_id, - assignee.display_name AS assignee_display_name + SELECT case_id, task_id, task_type, title, status, due_date FROM task - JOIN user_account assignee - ON assignee.user_id = COALESCE(task.assignee_id, task.created_by) - AND assignee.company_id = task.company_id - WHERE task.company_id = :companyId - AND task.case_id IN (:caseIds) - ORDER BY task.due_date ASC, task.created_at ASC + WHERE company_id = :companyId + AND case_id IN (:caseIds) + ORDER BY due_date ASC, created_at ASC """, Map.of("companyId", companyId, "caseIds", caseIds), (resultSet, rowNumber) -> new CaseTaskRecord( @@ -245,9 +235,7 @@ AND task.case_id IN (:caseIds) TaskType.valueOf(resultSet.getString("task_type")), resultSet.getString("title"), TaskStatus.valueOf(resultSet.getString("status")), - resultSet.getObject("due_date", java.time.LocalDate.class), - resultSet.getObject("assignee_id", UUID.class), - resultSet.getString("assignee_display_name") + resultSet.getObject("due_date", java.time.LocalDate.class) ) ); } diff --git a/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java b/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java index fc916d87..c17e3d90 100644 --- a/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java +++ b/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java @@ -7,7 +7,6 @@ import com.fowoco.server.task.domain.TaskStatus; import com.fowoco.server.task.domain.TaskTargetType; import com.fowoco.server.task.domain.TaskType; -import com.fowoco.server.task.application.TaskSummaryView; import java.time.Instant; import java.time.LocalDate; import java.util.UUID; @@ -25,14 +24,12 @@ public record TaskSummaryResponse( TaskSource source, TaskStatus status, LocalDate dueDate, - TaskAssigneeResponse assignee, long contentRevision, long version, Instant createdAt, Instant updatedAt ) { - static TaskSummaryResponse from(TaskSummaryView view) { - Task task = view.task(); + static TaskSummaryResponse from(Task task) { return new TaskSummaryResponse( task.taskId(), task.targetType(), @@ -45,7 +42,6 @@ static TaskSummaryResponse from(TaskSummaryView view) { task.source(), task.status(), task.dueDate(), - TaskAssigneeResponse.from(view.assignee()), task.contentRevision(), task.version(), task.createdAt(), diff --git a/src/main/java/com/fowoco/server/task/application/TaskPageResult.java b/src/main/java/com/fowoco/server/task/application/TaskPageResult.java index e0c8ea4b..e8538193 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskPageResult.java +++ b/src/main/java/com/fowoco/server/task/application/TaskPageResult.java @@ -1,9 +1,10 @@ package com.fowoco.server.task.application; +import com.fowoco.server.task.domain.Task; import java.util.List; public record TaskPageResult( - List items, + List items, int page, int size, long totalElements, diff --git a/src/main/java/com/fowoco/server/task/application/TaskSummaryView.java b/src/main/java/com/fowoco/server/task/application/TaskSummaryView.java deleted file mode 100644 index 69b9e5e3..00000000 --- a/src/main/java/com/fowoco/server/task/application/TaskSummaryView.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.fowoco.server.task.application; - -import com.fowoco.server.task.domain.Task; -import java.util.Objects; - -public record TaskSummaryView( - Task task, - TaskAssigneeView assignee -) { - public TaskSummaryView { - Objects.requireNonNull(task, "task must not be null"); - Objects.requireNonNull(assignee, "assignee must not be null"); - } -} diff --git a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java index 41823d11..9da22da5 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java +++ b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java @@ -39,7 +39,6 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.Comparator; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -225,7 +224,7 @@ public TaskPageResult findAll( size )); return new TaskPageResult( - taskSummaryViews(result.items(), actor.companyId()), + result.items(), result.page(), result.size(), result.totalElements(), @@ -547,18 +546,6 @@ private TaskResult toResult( ); } - private List taskSummaryViews(List tasks, UUID companyId) { - Map assignees = new HashMap<>(); - companyMemberDirectory.findByCompanyId(companyId, null, false) - .forEach(member -> assignees.put(member.userId(), toAssigneeView(member))); - return tasks.stream() - .map(task -> new TaskSummaryView( - task, - requireAssignee(task, assignees) - )) - .toList(); - } - private TaskAssigneeView requireAssignableAssignee(UUID companyId, UUID assigneeId) { CompanyMemberAccount member = requireCompanyMember(companyId, assigneeId); if (!member.active() || member.role() == UserRole.VIEWER) { @@ -571,17 +558,6 @@ private TaskAssigneeView requireAssignee(Task task, UUID companyId) { return toAssigneeView(requireCompanyMember(companyId, task.assigneeId())); } - private TaskAssigneeView requireAssignee( - Task task, - Map assignees - ) { - TaskAssigneeView assignee = assignees.get(task.assigneeId()); - if (assignee == null) { - throw new IllegalStateException("Persisted task assignee is missing"); - } - return assignee; - } - private CompanyMemberAccount requireCompanyMember(UUID companyId, UUID userId) { return companyMemberDirectory.findByCompanyId(companyId, null, false).stream() .filter(member -> member.userId().equals(userId)) diff --git a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java index 607d2c26..b279a5b7 100644 --- a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java +++ b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java @@ -104,11 +104,10 @@ void resetAndSeed() { } @Test - void changesTaskAssigneeAndReturnsItFromTaskAndCaseQueries() throws Exception { + void changesTaskAssigneeAndReturnsItFromTaskDetail() throws Exception { String hrToken = login(HR_A_EMAIL); HttpResponse created = post("/api/v1/tasks", validCreateBody(), hrToken); UUID taskId = UUID.fromString(JsonPath.read(created.body(), "$.task_id")); - UUID caseId = UUID.fromString(JsonPath.read(created.body(), "$.case_id")); assertThat(JsonPath.read(created.body(), "$.assignee.user_id")) .isEqualTo(HR_A.toString()); assertThat(JsonPath.read(created.body(), "$.assignee.display_name")) @@ -133,19 +132,8 @@ void changesTaskAssigneeAndReturnsItFromTaskAndCaseQueries() throws Exception { assertThat(JsonPath.read(changed.body(), "$.version").longValue()).isEqualTo(1); HttpResponse detail = get("/api/v1/tasks/" + taskId, hrToken); - HttpResponse page = get("/api/v1/tasks?case_id=" + caseId, hrToken); - HttpResponse caseProjection = get( - "/api/v1/cases/" + caseId + "/projection", - hrToken - ); assertThat(JsonPath.read(detail.body(), "$.assignee.user_id")) .isEqualTo(HR_A_SECOND.toString()); - assertThat(JsonPath.read(page.body(), "$.items[0].assignee.display_name")) - .isEqualTo("담당자 B"); - assertThat(JsonPath.read( - caseProjection.body(), - "$.current_task.assignee.user_id" - )).isEqualTo(HR_A_SECOND.toString()); assertThat(jdbcTemplate.queryForObject( "SELECT assignee_id FROM task WHERE task_id = ?", UUID.class, From ee8f7e4b00a67e0cdd281d5eba64f4496bd10e1e Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 12 Aug 2026 11:11:54 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor(task):=20=EB=8B=B4=EB=8B=B9?= =?UTF-8?q?=EC=9E=90=20=ED=97=88=EC=9A=A9=20=EC=97=AD=ED=95=A0=EC=9D=84=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fowoco/server/task/application/TaskWorkflowService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java index 9da22da5..cfccfe50 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java +++ b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java @@ -548,7 +548,8 @@ private TaskResult toResult( private TaskAssigneeView requireAssignableAssignee(UUID companyId, UUID assigneeId) { CompanyMemberAccount member = requireCompanyMember(companyId, assigneeId); - if (!member.active() || member.role() == UserRole.VIEWER) { + 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);