feat: add task assignment

This commit is contained in:
Urban Modig
2026-07-26 22:49:06 +02:00
parent 059d4da921
commit aaebe888f3
26 changed files with 964 additions and 39 deletions

View File

@ -2,10 +2,15 @@ package se.rubble.hemhub.api;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import se.rubble.hemhub.task.InvalidTaskException;
import se.rubble.hemhub.task.InvalidTaskAssignmentException;
import se.rubble.hemhub.task.AssigneeNotFoundException;
import se.rubble.hemhub.task.TaskAssignmentConflictException;
import se.rubble.hemhub.task.TaskNotFoundException;
import se.rubble.hemhub.user.InvalidUserNameException;
import se.rubble.hemhub.user.UserNameAlreadyExistsException;
@ -33,5 +38,39 @@ public class ApiExceptionHandler {
return ResponseEntity.badRequest()
.body(new ApiError("INVALID_TASK", exception.getMessage()));
}
}
@ExceptionHandler(InvalidTaskAssignmentException.class)
public ResponseEntity<ApiError> handleInvalidTaskAssignment(
InvalidTaskAssignmentException exception) {
return ResponseEntity.badRequest()
.body(new ApiError("INVALID_TASK_ASSIGNMENT", exception.getMessage()));
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiError> handleInvalidPathParameter() {
return ResponseEntity.badRequest()
.body(new ApiError(
"INVALID_TASK_ASSIGNMENT",
"Uppgifts-id måste vara ett giltigt UUID."));
}
@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<ApiError> handleTaskNotFound() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("TASK_NOT_FOUND", "Uppgiften finns inte."));
}
@ExceptionHandler(AssigneeNotFoundException.class)
public ResponseEntity<ApiError> handleAssigneeNotFound() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("USER_NOT_FOUND", "Användaren finns inte."));
}
@ExceptionHandler(TaskAssignmentConflictException.class)
public ResponseEntity<ApiError> handleTaskAssignmentConflict() {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError(
"TASK_ASSIGNMENT_NOT_ALLOWED",
"Ansvarig kan endast ändras för väntande uppgifter."));
}
}

View File

@ -0,0 +1,4 @@
package se.rubble.hemhub.task;
public class AssigneeNotFoundException extends RuntimeException {
}

View File

@ -2,7 +2,11 @@ package se.rubble.hemhub.task;
import tools.jackson.databind.JsonNode;
public record CreateTaskRequest(String title, String description, JsonNode points) {
public record CreateTaskRequest(
String title,
String description,
JsonNode points,
JsonNode assigneeId) {
Integer integerPoints() {
if (points == null || !points.isIntegralNumber() || !points.canConvertToInt()) {
@ -11,4 +15,8 @@ public record CreateTaskRequest(String title, String description, JsonNode point
return points.intValue();
}
UUIDValue parsedAssigneeId() {
return UUIDValue.optional(assigneeId);
}
}

View File

@ -0,0 +1,8 @@
package se.rubble.hemhub.task;
public class InvalidTaskAssignmentException extends RuntimeException {
public InvalidTaskAssignmentException(String message) {
super(message);
}
}

View File

@ -8,7 +8,11 @@ import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.FetchType;
import se.rubble.hemhub.user.User;
@Entity
@Table(name = "task")
@ -30,6 +34,10 @@ class Task {
@Column(nullable = false)
private int points;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "assignee_id")
private User assignee;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@ -42,6 +50,7 @@ class Task {
String description,
TaskStatus status,
int points,
User assignee,
Instant createdAt) {
if (points < 1 || points > 99) {
throw new InvalidTaskException(
@ -53,6 +62,7 @@ class Task {
this.description = description;
this.status = status;
this.points = points;
this.assignee = assignee;
this.createdAt = createdAt;
}
@ -76,6 +86,21 @@ class Task {
return points;
}
User getAssignee() {
return assignee;
}
void changeAssignee(User assignee) {
ensureAssignmentChangeAllowed();
this.assignee = assignee;
}
void ensureAssignmentChangeAllowed() {
if (status != TaskStatus.WAITING) {
throw new TaskAssignmentConflictException();
}
}
Instant getCreatedAt() {
return createdAt;
}

View File

@ -0,0 +1,4 @@
package se.rubble.hemhub.task;
public class TaskAssignmentConflictException extends RuntimeException {
}

View File

@ -1,10 +1,13 @@
package se.rubble.hemhub.task;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
@ -28,9 +31,24 @@ public class TaskController {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) {
UUIDValue assigneeId = request == null
? UUIDValue.optional(null)
: request.parsedAssigneeId();
return taskService.create(
request == null ? null : request.title(),
request == null ? null : request.description(),
request == null ? null : request.integerPoints());
request == null ? null : request.integerPoints(),
assigneeId.value());
}
@PutMapping("/{taskId}/assignee")
public TaskResponse updateAssignee(
@PathVariable UUID taskId,
@RequestBody(required = false) UpdateTaskAssigneeRequest request) {
if (request == null) {
throw new InvalidTaskAssignmentException("Fältet assigneeId måste anges.");
}
return taskService.updateAssignee(taskId, request.parsedAssigneeId().value());
}
}

View File

@ -0,0 +1,4 @@
package se.rubble.hemhub.task;
public class TaskNotFoundException extends RuntimeException {
}

View File

@ -4,9 +4,13 @@ import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.EntityGraph;
interface TaskRepository extends JpaRepository<Task, UUID> {
@EntityGraph(attributePaths = "assignee")
List<Task> findAllByOrderByCreatedAtAscIdAsc();
}
@EntityGraph(attributePaths = "assignee")
java.util.Optional<Task> findOneById(UUID id);
}

View File

@ -9,6 +9,7 @@ public record TaskResponse(
String description,
TaskStatus status,
int points,
AssigneeResponse assignee,
Instant createdAt) {
static TaskResponse from(Task task) {
@ -18,6 +19,14 @@ public record TaskResponse(
task.getDescription(),
task.getStatus(),
task.getPoints(),
AssigneeResponse.from(task.getAssignee()),
task.getCreatedAt());
}
public record AssigneeResponse(UUID id, String name) {
static AssigneeResponse from(se.rubble.hemhub.user.User user) {
return user == null ? null : new AssigneeResponse(user.getId(), user.getName());
}
}
}

View File

@ -9,19 +9,24 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import se.rubble.hemhub.user.User;
import se.rubble.hemhub.user.UserRepository;
@Service
class TaskService {
private final TaskRepository taskRepository;
private final UserRepository userRepository;
private final Clock clock;
@Autowired
TaskService(TaskRepository taskRepository) {
this(taskRepository, Clock.systemUTC());
TaskService(TaskRepository taskRepository, UserRepository userRepository) {
this(taskRepository, userRepository, Clock.systemUTC());
}
TaskService(TaskRepository taskRepository, Clock clock) {
TaskService(TaskRepository taskRepository, UserRepository userRepository, Clock clock) {
this.taskRepository = taskRepository;
this.userRepository = userRepository;
this.clock = clock;
}
@ -36,7 +41,8 @@ class TaskService {
TaskResponse create(
String requestedTitle,
String requestedDescription,
Integer requestedPoints) {
Integer requestedPoints,
UUID requestedAssigneeId) {
String title = requestedTitle == null ? "" : requestedTitle.trim();
String description = normalizeDescription(requestedDescription);
@ -55,17 +61,39 @@ class TaskService {
"Poäng måste vara ett heltal mellan 1 och 99.");
}
User assignee = findAssignee(requestedAssigneeId);
Task task = new Task(
UUID.randomUUID(),
title,
description,
TaskStatus.WAITING,
requestedPoints,
assignee,
Instant.now(clock));
return TaskResponse.from(taskRepository.save(task));
}
@Transactional
TaskResponse updateAssignee(UUID taskId, UUID requestedAssigneeId) {
Task task = taskRepository.findOneById(taskId)
.orElseThrow(TaskNotFoundException::new);
task.ensureAssignmentChangeAllowed();
User assignee = findAssignee(requestedAssigneeId);
task.changeAssignee(assignee);
return TaskResponse.from(task);
}
private User findAssignee(UUID requestedAssigneeId) {
if (requestedAssigneeId == null) {
return null;
}
return userRepository.findById(requestedAssigneeId)
.orElseThrow(AssigneeNotFoundException::new);
}
private static String normalizeDescription(String requestedDescription) {
if (requestedDescription == null) {
return null;

View File

@ -0,0 +1,25 @@
package se.rubble.hemhub.task;
import java.util.UUID;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeType;
record UUIDValue(boolean present, UUID value) {
static UUIDValue optional(JsonNode node) {
if (node == null || node.isNull()) {
return new UUIDValue(node != null, null);
}
if (node.getNodeType() != JsonNodeType.STRING) {
throw new InvalidTaskAssignmentException("Användar-id måste vara ett giltigt UUID.");
}
try {
return new UUIDValue(true, UUID.fromString(node.stringValue()));
} catch (IllegalArgumentException exception) {
throw new InvalidTaskAssignmentException("Användar-id måste vara ett giltigt UUID.");
}
}
}

View File

@ -0,0 +1,16 @@
package se.rubble.hemhub.task;
import tools.jackson.databind.JsonNode;
public record UpdateTaskAssigneeRequest(JsonNode assigneeId) {
UUIDValue parsedAssigneeId() {
UUIDValue parsed = UUIDValue.optional(assigneeId);
if (!parsed.present()) {
throw new InvalidTaskAssignmentException("Fältet assigneeId måste anges.");
}
return parsed;
}
}

View File

@ -10,7 +10,7 @@ import jakarta.persistence.Table;
@Entity
@Table(name = "app_user")
class User {
public class User {
@Id
private UUID id;
@ -34,11 +34,11 @@ class User {
this.createdAt = createdAt;
}
UUID getId() {
public UUID getId() {
return id;
}
String getName() {
public String getName() {
return name;
}

View File

@ -4,8 +4,7 @@ import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
interface UserRepository extends JpaRepository<User, UUID> {
public interface UserRepository extends JpaRepository<User, UUID> {
boolean existsByNormalizedName(String normalizedName);
}

View File

@ -0,0 +1,6 @@
ALTER TABLE task
ADD COLUMN assignee_id UUID;
ALTER TABLE task
ADD CONSTRAINT fk_task_assignee
FOREIGN KEY (assignee_id) REFERENCES app_user (id);

View File

@ -15,6 +15,7 @@ import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -52,6 +53,7 @@ class TaskApiTest {
.andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.status").value("WAITING"))
.andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.assignee").value((Object) null))
.andExpect(jsonPath("$.createdAt").isString());
mockMvc.perform(get("/api/tasks"))
@ -59,6 +61,67 @@ class TaskApiTest {
.andExpect(jsonPath("$[0].points").value(7));
}
@Test
void createsUnassignedTaskWhenAssigneeIsExplicitlyNull() throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "Dammsuga", "points": 1, "assigneeId": null}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.assignee").value((Object) null));
}
@Test
void createsAndListsTaskWithAssignee() throws Exception {
UUID userId = createUser("Anna");
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "Dammsuga",
"points": 7,
"assigneeId": "%s"
}
""".formatted(userId)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.assignee.id").value(userId.toString()))
.andExpect(jsonPath("$.assignee.name").value("Anna"));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].assignee.id").value(userId.toString()))
.andExpect(jsonPath("$[0].assignee.name").value("Anna"));
}
@Test
void rejectsUnknownAssigneeWhenCreatingTask() throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "Dammsuga",
"points": 7,
"assigneeId": "00000000-0000-0000-0000-000000000099"
}
"""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("USER_NOT_FOUND"));
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "Dammsuga",
"points": 7,
"assigneeId": "inte-ett-uuid"
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
}
@Test
void storesBlankDescriptionAsNull() throws Exception {
mockMvc.perform(post("/api/tasks")
@ -133,9 +196,12 @@ class TaskApiTest {
UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002");
UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003");
taskRepository.save(new Task(newestId, "Nyast", null, TaskStatus.WAITING, 3, newer));
taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, 2, older));
taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, 1, older));
taskRepository.save(new Task(
newestId, "Nyast", null, TaskStatus.WAITING, 3, null, newer));
taskRepository.save(new Task(
secondId, "Andra", null, TaskStatus.IN_PROGRESS, 2, null, older));
taskRepository.save(new Task(
firstId, "Första", null, TaskStatus.COMPLETED, 1, null, older));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
@ -145,6 +211,93 @@ class TaskApiTest {
.andExpect(jsonPath("$[2].title").value("Nyast"));
}
@Test
void assignsChangesAndRemovesAssigneeWithoutChangingOtherTaskFields() throws Exception {
UUID firstUserId = createUser("Bo");
UUID secondUserId = createUser("Cecilia");
String taskId = createTask("Dammsuga", "Bottenvåningen", 7, null);
updateAssignee(taskId, """
{"assigneeId": "%s"}
""".formatted(firstUserId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.assignee.id").value(firstUserId.toString()))
.andExpect(jsonPath("$.status").value("WAITING"))
.andExpect(jsonPath("$.title").value("Dammsuga"))
.andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.points").value(7));
updateAssignee(taskId, """
{"assigneeId": "%s"}
""".formatted(secondUserId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.assignee.id").value(secondUserId.toString()));
updateAssignee(taskId, """
{"assigneeId": null}
""")
.andExpect(status().isOk())
.andExpect(jsonPath("$.assignee").value((Object) null))
.andExpect(jsonPath("$.status").value("WAITING"))
.andExpect(jsonPath("$.title").value("Dammsuga"))
.andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.points").value(7));
}
@Test
void rejectsMissingAssigneeFieldAndInvalidUuid() throws Exception {
String taskId = createTask("Dammsuga", null, 1, null);
updateAssignee(taskId, "{}")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
updateAssignee(taskId, """
{"assigneeId": "inte-ett-uuid"}
""")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
updateAssignee("inte-ett-uuid", """
{"assigneeId": null}
""")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
}
@Test
void returnsNotFoundForUnknownTaskAndUnknownAssignee() throws Exception {
String taskId = createTask("Dammsuga", null, 1, null);
updateAssignee("00000000-0000-0000-0000-000000000099", """
{"assigneeId": null}
""")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
updateAssignee(taskId, """
{"assigneeId": "00000000-0000-0000-0000-000000000099"}
""")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("USER_NOT_FOUND"));
}
@Test
void rejectsAssigneeChangeForTaskThatIsNotWaiting() throws Exception {
UUID taskId = UUID.randomUUID();
taskRepository.save(new Task(
taskId,
"Pågående",
null,
TaskStatus.IN_PROGRESS,
3,
null,
Instant.parse("2026-07-26T12:00:00Z")));
updateAssignee(taskId.toString(), """
{"assigneeId": null}
""")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("TASK_ASSIGNMENT_NOT_ALLOWED"));
}
private ResultActions createTaskWithPoints(int points) throws Exception {
return mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
@ -159,4 +312,50 @@ class TaskApiTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK"));
}
private UUID createUser(String name) throws Exception {
String response = mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "%s"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
String id = com.jayway.jsonpath.JsonPath.read(response, "$.id");
return UUID.fromString(id);
}
private String createTask(
String title,
String description,
int points,
UUID assigneeId) throws Exception {
String descriptionJson = description == null ? "null" : "\"%s\"".formatted(description);
String assigneeJson = assigneeId == null ? "null" : "\"%s\"".formatted(assigneeId);
String response = mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "%s",
"description": %s,
"points": %d,
"assigneeId": %s
}
""".formatted(title, descriptionJson, points, assigneeJson)))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
return com.jayway.jsonpath.JsonPath.read(response, "$.id");
}
private ResultActions updateAssignee(String taskId, String body) throws Exception {
return mockMvc.perform(put("/api/tasks/{taskId}/assignee", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content(body));
}
}

View File

@ -22,6 +22,7 @@ class TaskTest {
null,
TaskStatus.WAITING,
points,
null,
Instant.parse("2026-07-26T12:00:00Z"));
}
}