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 @@ -48,4 +48,37 @@ AND unfinished_task.status NOT IN ('COMPLETED', 'CANCELLED')
);
return updated == 1;
}

@Override
public boolean cancelIfAllTasksCancelled(UUID caseId, UUID companyId, Instant cancelledAt) {
int updated = jdbcTemplate.update(
"""
UPDATE workflow_case workflow_case
SET lifecycle_status = 'CANCELLED',
updated_at = ?,
version = version + 1
WHERE workflow_case.case_id = ?
AND workflow_case.company_id = ?
AND workflow_case.lifecycle_status = 'ACTIVE'
AND EXISTS (
SELECT 1
FROM task cancelled_task
WHERE cancelled_task.case_id = workflow_case.case_id
AND cancelled_task.company_id = workflow_case.company_id
AND cancelled_task.status = 'CANCELLED'
)
AND NOT EXISTS (
SELECT 1
FROM task remaining_task
WHERE remaining_task.case_id = workflow_case.case_id
AND remaining_task.company_id = workflow_case.company_id
AND remaining_task.status <> 'CANCELLED'
)
""",
Timestamp.from(cancelledAt),
caseId,
companyId
);
return updated == 1;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
import com.fowoco.server.reliability.application.port.DomainEventPublisher;
import com.fowoco.server.task.application.TaskContentCodec.EncodedTaskContent;
import com.fowoco.server.task.application.error.TaskErrorCode;
import com.fowoco.server.task.application.port.TaskChecklistRepository;
import com.fowoco.server.task.application.port.TaskCaseLifecycleUpdater;
import com.fowoco.server.task.application.port.TaskCaseRegistrar;
import com.fowoco.server.task.application.port.TaskChecklistRepository;
import com.fowoco.server.task.application.port.TaskRepository;
import com.fowoco.server.task.application.port.TaskRepository.TaskPage;
import com.fowoco.server.task.application.port.TaskRepository.TaskSearchCriteria;
Expand Down Expand Up @@ -54,6 +55,7 @@ public class TaskWorkflowService {
private final TenantDatabaseContext tenantDatabaseContext;
private final TaskRepository taskRepository;
private final TaskChecklistRepository checklistRepository;
private final TaskCaseLifecycleUpdater taskCaseLifecycleUpdater;
private final TaskCaseRegistrar taskCaseRegistrar;
private final TaskTransitionRecorder transitionRecorder;
private final WorkerTaskContextReader workerReader;
Expand All @@ -71,6 +73,7 @@ public TaskWorkflowService(
TenantDatabaseContext tenantDatabaseContext,
TaskRepository taskRepository,
TaskChecklistRepository checklistRepository,
TaskCaseLifecycleUpdater taskCaseLifecycleUpdater,
TaskCaseRegistrar taskCaseRegistrar,
TaskTransitionRecorder transitionRecorder,
WorkerTaskContextReader workerReader,
Expand All @@ -87,6 +90,7 @@ public TaskWorkflowService(
this.tenantDatabaseContext = tenantDatabaseContext;
this.taskRepository = taskRepository;
this.checklistRepository = checklistRepository;
this.taskCaseLifecycleUpdater = taskCaseLifecycleUpdater;
this.taskCaseRegistrar = taskCaseRegistrar;
this.transitionRecorder = transitionRecorder;
this.workerReader = workerReader;
Expand Down Expand Up @@ -506,6 +510,20 @@ public TaskResult cancel(
metadata,
now
));
if (savedTask.caseId() != null) {
boolean completed = taskCaseLifecycleUpdater.completeIfAllTasksFinished(
savedTask.caseId(),
savedTask.companyId(),
now
);
if (!completed) {
taskCaseLifecycleUpdater.cancelIfAllTasksCancelled(
savedTask.caseId(),
savedTask.companyId(),
now
);
}
}
return toResult(
savedTask,
checklistRepository.findAllByTaskIdAndCompanyId(taskId, actor.companyId()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@
public interface TaskCaseLifecycleUpdater {

boolean completeIfAllTasksFinished(UUID caseId, UUID companyId, Instant completedAt);

boolean cancelIfAllTasksCancelled(UUID caseId, UUID companyId, Instant cancelledAt);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
UPDATE workflow_case
SET lifecycle_status = 'COMPLETED',
updated_at = CURRENT_TIMESTAMP,
version = version + 1
WHERE lifecycle_status = 'ACTIVE'
AND EXISTS (
SELECT 1
FROM task completed_task
WHERE completed_task.case_id = workflow_case.case_id
AND completed_task.company_id = workflow_case.company_id
AND completed_task.status = 'COMPLETED'
)
AND NOT EXISTS (
SELECT 1
FROM task unfinished_task
WHERE unfinished_task.case_id = workflow_case.case_id
AND unfinished_task.company_id = workflow_case.company_id
AND unfinished_task.status NOT IN ('COMPLETED', 'CANCELLED')
);

UPDATE workflow_case
SET lifecycle_status = 'CANCELLED',
updated_at = CURRENT_TIMESTAMP,
version = version + 1
WHERE lifecycle_status = 'ACTIVE'
AND EXISTS (
SELECT 1
FROM task cancelled_task
WHERE cancelled_task.case_id = workflow_case.case_id
AND cancelled_task.company_id = workflow_case.company_id
AND cancelled_task.status = 'CANCELLED'
)
AND NOT EXISTS (
SELECT 1
FROM task remaining_task
WHERE remaining_task.case_id = workflow_case.case_id
AND remaining_task.company_id = workflow_case.company_id
AND remaining_task.status <> 'CANCELLED'
);
100 changes: 100 additions & 0 deletions src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,106 @@ void missingRequiredSlotCreatesNeedsInfoAndCannotRequestApproval() throws Except
.containsExactly("due_at");
}

@Test
void cancellingTheOnlyTaskCancelsItsCaseProjection() throws Exception {
String token = login(HR_A_EMAIL);
HttpResponse<String> created = post("/api/v1/tasks", validCreateBody(), token);
UUID taskId = UUID.fromString(JsonPath.read(created.body(), "$.task_id"));
UUID caseId = UUID.fromString(JsonPath.read(created.body(), "$.case_id"));

assertThat(post(
"/api/v1/tasks/" + taskId + "/cancel",
"{\"expected_version\":0,\"reason\":\"업무 계획 취소\"}",
token
).statusCode()).isEqualTo(200);

HttpResponse<String> projection = get(
"/api/v1/cases/" + caseId + "/projection",
token
);
assertThat(projection.statusCode()).isEqualTo(200);
assertThat(JsonPath.<String>read(projection.body(), "$.lifecycle_status"))
.isEqualTo("CANCELLED");
assertThat(JsonPath.<String>read(projection.body(), "$.display_status"))
.isEqualTo("CANCELLED");
assertThat(JsonPath.<Object>read(projection.body(), "$.current_task")).isNull();

HttpResponse<String> page = get("/api/v1/cases?page=0&size=20", token);
assertThat(page.statusCode()).isEqualTo(200);
assertThat(JsonPath.<String>read(page.body(), "$.items[0].display_status"))
.isEqualTo("CANCELLED");
assertThat(JsonPath.<Object>read(page.body(), "$.items[0].current_task")).isNull();
}

@Test
void cancellingOneTaskKeepsCaseActiveWhileAnotherTaskIsNonTerminal() throws Exception {
String token = login(HR_A_EMAIL);
HttpResponse<String> firstCreated = post("/api/v1/tasks", validCreateBody(), token);
UUID firstTaskId = UUID.fromString(JsonPath.read(firstCreated.body(), "$.task_id"));
UUID caseId = UUID.fromString(JsonPath.read(firstCreated.body(), "$.case_id"));
HttpResponse<String> secondCreated = post(
"/api/v1/tasks",
createBody(caseId, "후속 재계약 확인"),
token
);
UUID secondTaskId = UUID.fromString(JsonPath.read(secondCreated.body(), "$.task_id"));

assertThat(post(
"/api/v1/tasks/" + firstTaskId + "/cancel",
"{\"expected_version\":0,\"reason\":\"첫 업무만 취소\"}",
token
).statusCode()).isEqualTo(200);

HttpResponse<String> projection = get(
"/api/v1/cases/" + caseId + "/projection",
token
);
assertThat(JsonPath.<String>read(projection.body(), "$.lifecycle_status"))
.isEqualTo("ACTIVE");
assertThat(JsonPath.<String>read(projection.body(), "$.current_task.task_id"))
.isEqualTo(secondTaskId.toString());
}

@Test
void cancellingLastNonTerminalTaskCompletesCaseWhenAnotherTaskIsCompleted() throws Exception {
String token = login(HR_A_EMAIL);
HttpResponse<String> completedTaskCreated = post("/api/v1/tasks", validCreateBody(), token);
UUID completedTaskId = UUID.fromString(JsonPath.read(
completedTaskCreated.body(),
"$.task_id"
));
UUID caseId = UUID.fromString(JsonPath.read(completedTaskCreated.body(), "$.case_id"));
HttpResponse<String> cancelledTaskCreated = post(
"/api/v1/tasks",
createBody(caseId, "취소할 후속 업무"),
token
);
UUID cancelledTaskId = UUID.fromString(JsonPath.read(
cancelledTaskCreated.body(),
"$.task_id"
));
jdbcTemplate.update(
"UPDATE task SET status = 'COMPLETED' WHERE task_id = ?",
completedTaskId
);

assertThat(post(
"/api/v1/tasks/" + cancelledTaskId + "/cancel",
"{\"expected_version\":0,\"reason\":\"불필요한 후속 업무\"}",
token
).statusCode()).isEqualTo(200);

HttpResponse<String> projection = get(
"/api/v1/cases/" + caseId + "/projection",
token
);
assertThat(JsonPath.<String>read(projection.body(), "$.lifecycle_status"))
.isEqualTo("COMPLETED");
assertThat(JsonPath.<String>read(projection.body(), "$.display_status"))
.isEqualTo("COMPLETED");
assertThat(JsonPath.<Object>read(projection.body(), "$.current_task")).isNull();
}

@Test
void createsACompanyOnboardingTaskWithoutWorkerOrCase() throws Exception {
String token = login(HR_A_EMAIL);
Expand Down
Loading