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 55f4569..3302300 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/task/api/ChangeTaskAssigneeRequest.java b/src/main/java/com/fowoco/server/task/api/ChangeTaskAssigneeRequest.java new file mode 100644 index 0000000..a4c377d --- /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 0000000..1d88471 --- /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 b208353..41c35d6 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 b05e250..2fd67f7 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/application/ChangeTaskAssigneeCommand.java b/src/main/java/com/fowoco/server/task/application/ChangeTaskAssigneeCommand.java new file mode 100644 index 0000000..ac470e7 --- /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 0000000..23e5e6a --- /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/TaskResult.java b/src/main/java/com/fowoco/server/task/application/TaskResult.java index a21be9e..faea9f4 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/TaskWorkflowService.java b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java index 96e7bd4..cfccfe5 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; @@ -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; @@ -64,6 +67,7 @@ public class TaskWorkflowService { public TaskWorkflowService( ActorAuthorizer actorAuthorizer, + CompanyMemberDirectory companyMemberDirectory, TenantDatabaseContext tenantDatabaseContext, TaskRepository taskRepository, TaskChecklistRepository checklistRepository, @@ -79,6 +83,7 @@ public TaskWorkflowService( Clock clock ) { this.actorAuthorizer = actorAuthorizer; + this.companyMemberDirectory = companyMemberDirectory; this.tenantDatabaseContext = tenantDatabaseContext; this.taskRepository = taskRepository; this.checklistRepository = checklistRepository; @@ -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, @@ -469,16 +519,57 @@ 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 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 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 51f8fe9..55d2f4a 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 45cd8dc..87c6da8 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 560c69e..849e623 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/V42__add_task_assignee.sql b/src/main/resources/db/migration/V42__add_task_assignee.sql new file mode 100644 index 0000000..1f7f8fe --- /dev/null +++ b/src/main/resources/db/migration/V42__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); diff --git a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java index 0a9d9cf..b279a5b 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,122 @@ 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 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")); + 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); + assertThat(JsonPath.read(detail.body(), "$.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 +811,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 c6393eb..da5af06 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);