From aaebe888f3bae43b3413e9fa3893fec90afac8b4 Mon Sep 17 00:00:00 2001
From: Urban Modig
Date: Sun, 26 Jul 2026 22:49:06 +0200
Subject: [PATCH] feat: add task assignment
---
README.md | 2 +-
.../hemhub/api/ApiExceptionHandler.java | 41 +++-
.../task/AssigneeNotFoundException.java | 4 +
.../rubble/hemhub/task/CreateTaskRequest.java | 10 +-
.../task/InvalidTaskAssignmentException.java | 8 +
.../main/java/se/rubble/hemhub/task/Task.java | 25 +++
.../task/TaskAssignmentConflictException.java | 4 +
.../se/rubble/hemhub/task/TaskController.java | 20 +-
.../hemhub/task/TaskNotFoundException.java | 4 +
.../se/rubble/hemhub/task/TaskRepository.java | 6 +-
.../se/rubble/hemhub/task/TaskResponse.java | 9 +
.../se/rubble/hemhub/task/TaskService.java | 36 ++-
.../java/se/rubble/hemhub/task/UUIDValue.java | 25 +++
.../task/UpdateTaskAssigneeRequest.java | 16 ++
.../main/java/se/rubble/hemhub/user/User.java | 6 +-
.../se/rubble/hemhub/user/UserRepository.java | 3 +-
.../db/migration/V4__add_task_assignee.sql | 6 +
.../se/rubble/hemhub/task/TaskApiTest.java | 205 +++++++++++++++++-
.../java/se/rubble/hemhub/task/TaskTest.java | 1 +
docs/architecture.md | 19 +-
docs/features/004-task-assignment.md | 122 +++++++++++
docs/roadmap.md | 22 +-
frontend/src/App.test.tsx | 152 ++++++++++++-
frontend/src/App.tsx | 2 +-
frontend/src/TaskBoard.tsx | 182 +++++++++++++++-
frontend/src/styles.css | 73 +++++++
26 files changed, 964 insertions(+), 39 deletions(-)
create mode 100644 backend/src/main/java/se/rubble/hemhub/task/AssigneeNotFoundException.java
create mode 100644 backend/src/main/java/se/rubble/hemhub/task/InvalidTaskAssignmentException.java
create mode 100644 backend/src/main/java/se/rubble/hemhub/task/TaskAssignmentConflictException.java
create mode 100644 backend/src/main/java/se/rubble/hemhub/task/TaskNotFoundException.java
create mode 100644 backend/src/main/java/se/rubble/hemhub/task/UUIDValue.java
create mode 100644 backend/src/main/java/se/rubble/hemhub/task/UpdateTaskAssigneeRequest.java
create mode 100644 backend/src/main/resources/db/migration/V4__add_task_assignee.sql
create mode 100644 docs/features/004-task-assignment.md
diff --git a/README.md b/README.md
index 523a332..0fecab7 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Backend använder en lokal H2-databas i minnet. Databasschemat hanteras med
Flyway, och lokal utvecklingsdata återställs när backend startas om.
API:t innehåller endpoints under `/api/users` för användare och `/api/tasks` för
-att skapa och lista gemensamma hushållsuppgifter.
+att skapa, lista och tilldela gemensamma hushållsuppgifter.
## Starta backend
diff --git a/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java b/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java
index c001781..0515bd8 100644
--- a/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java
+++ b/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java
@@ -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 handleInvalidTaskAssignment(
+ InvalidTaskAssignmentException exception) {
+ return ResponseEntity.badRequest()
+ .body(new ApiError("INVALID_TASK_ASSIGNMENT", exception.getMessage()));
+ }
+
+ @ExceptionHandler(MethodArgumentTypeMismatchException.class)
+ public ResponseEntity handleInvalidPathParameter() {
+ return ResponseEntity.badRequest()
+ .body(new ApiError(
+ "INVALID_TASK_ASSIGNMENT",
+ "Uppgifts-id måste vara ett giltigt UUID."));
+ }
+
+ @ExceptionHandler(TaskNotFoundException.class)
+ public ResponseEntity handleTaskNotFound() {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(new ApiError("TASK_NOT_FOUND", "Uppgiften finns inte."));
+ }
+
+ @ExceptionHandler(AssigneeNotFoundException.class)
+ public ResponseEntity handleAssigneeNotFound() {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(new ApiError("USER_NOT_FOUND", "Användaren finns inte."));
+ }
+
+ @ExceptionHandler(TaskAssignmentConflictException.class)
+ public ResponseEntity handleTaskAssignmentConflict() {
+ return ResponseEntity.status(HttpStatus.CONFLICT)
+ .body(new ApiError(
+ "TASK_ASSIGNMENT_NOT_ALLOWED",
+ "Ansvarig kan endast ändras för väntande uppgifter."));
+ }
+}
diff --git a/backend/src/main/java/se/rubble/hemhub/task/AssigneeNotFoundException.java b/backend/src/main/java/se/rubble/hemhub/task/AssigneeNotFoundException.java
new file mode 100644
index 0000000..261afb7
--- /dev/null
+++ b/backend/src/main/java/se/rubble/hemhub/task/AssigneeNotFoundException.java
@@ -0,0 +1,4 @@
+package se.rubble.hemhub.task;
+
+public class AssigneeNotFoundException extends RuntimeException {
+}
diff --git a/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java b/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java
index 215e7f7..01824d2 100644
--- a/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java
+++ b/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java
@@ -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);
+ }
}
diff --git a/backend/src/main/java/se/rubble/hemhub/task/InvalidTaskAssignmentException.java b/backend/src/main/java/se/rubble/hemhub/task/InvalidTaskAssignmentException.java
new file mode 100644
index 0000000..4ba3d25
--- /dev/null
+++ b/backend/src/main/java/se/rubble/hemhub/task/InvalidTaskAssignmentException.java
@@ -0,0 +1,8 @@
+package se.rubble.hemhub.task;
+
+public class InvalidTaskAssignmentException extends RuntimeException {
+
+ public InvalidTaskAssignmentException(String message) {
+ super(message);
+ }
+}
diff --git a/backend/src/main/java/se/rubble/hemhub/task/Task.java b/backend/src/main/java/se/rubble/hemhub/task/Task.java
index b76d883..52771f3 100644
--- a/backend/src/main/java/se/rubble/hemhub/task/Task.java
+++ b/backend/src/main/java/se/rubble/hemhub/task/Task.java
@@ -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;
}
diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskAssignmentConflictException.java b/backend/src/main/java/se/rubble/hemhub/task/TaskAssignmentConflictException.java
new file mode 100644
index 0000000..4065080
--- /dev/null
+++ b/backend/src/main/java/se/rubble/hemhub/task/TaskAssignmentConflictException.java
@@ -0,0 +1,4 @@
+package se.rubble.hemhub.task;
+
+public class TaskAssignmentConflictException extends RuntimeException {
+}
diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskController.java b/backend/src/main/java/se/rubble/hemhub/task/TaskController.java
index 07831c0..ca1f46d 100644
--- a/backend/src/main/java/se/rubble/hemhub/task/TaskController.java
+++ b/backend/src/main/java/se/rubble/hemhub/task/TaskController.java
@@ -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());
}
}
diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskNotFoundException.java b/backend/src/main/java/se/rubble/hemhub/task/TaskNotFoundException.java
new file mode 100644
index 0000000..14770cc
--- /dev/null
+++ b/backend/src/main/java/se/rubble/hemhub/task/TaskNotFoundException.java
@@ -0,0 +1,4 @@
+package se.rubble.hemhub.task;
+
+public class TaskNotFoundException extends RuntimeException {
+}
diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskRepository.java b/backend/src/main/java/se/rubble/hemhub/task/TaskRepository.java
index 167e194..0ec71a4 100644
--- a/backend/src/main/java/se/rubble/hemhub/task/TaskRepository.java
+++ b/backend/src/main/java/se/rubble/hemhub/task/TaskRepository.java
@@ -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 {
+ @EntityGraph(attributePaths = "assignee")
List findAllByOrderByCreatedAtAscIdAsc();
-}
+ @EntityGraph(attributePaths = "assignee")
+ java.util.Optional findOneById(UUID id);
+}
diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java b/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java
index 51f76ee..bed7793 100644
--- a/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java
+++ b/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java
@@ -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());
+ }
+ }
}
diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskService.java b/backend/src/main/java/se/rubble/hemhub/task/TaskService.java
index b5ae1fb..284bb9a 100644
--- a/backend/src/main/java/se/rubble/hemhub/task/TaskService.java
+++ b/backend/src/main/java/se/rubble/hemhub/task/TaskService.java
@@ -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;
diff --git a/backend/src/main/java/se/rubble/hemhub/task/UUIDValue.java b/backend/src/main/java/se/rubble/hemhub/task/UUIDValue.java
new file mode 100644
index 0000000..e73b25e
--- /dev/null
+++ b/backend/src/main/java/se/rubble/hemhub/task/UUIDValue.java
@@ -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.");
+ }
+ }
+}
diff --git a/backend/src/main/java/se/rubble/hemhub/task/UpdateTaskAssigneeRequest.java b/backend/src/main/java/se/rubble/hemhub/task/UpdateTaskAssigneeRequest.java
new file mode 100644
index 0000000..61fa6db
--- /dev/null
+++ b/backend/src/main/java/se/rubble/hemhub/task/UpdateTaskAssigneeRequest.java
@@ -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;
+ }
+}
diff --git a/backend/src/main/java/se/rubble/hemhub/user/User.java b/backend/src/main/java/se/rubble/hemhub/user/User.java
index ea3298d..46913b8 100644
--- a/backend/src/main/java/se/rubble/hemhub/user/User.java
+++ b/backend/src/main/java/se/rubble/hemhub/user/User.java
@@ -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;
}
diff --git a/backend/src/main/java/se/rubble/hemhub/user/UserRepository.java b/backend/src/main/java/se/rubble/hemhub/user/UserRepository.java
index 013b4a9..3f5fb54 100644
--- a/backend/src/main/java/se/rubble/hemhub/user/UserRepository.java
+++ b/backend/src/main/java/se/rubble/hemhub/user/UserRepository.java
@@ -4,8 +4,7 @@ import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
-interface UserRepository extends JpaRepository {
+public interface UserRepository extends JpaRepository {
boolean existsByNormalizedName(String normalizedName);
}
-
diff --git a/backend/src/main/resources/db/migration/V4__add_task_assignee.sql b/backend/src/main/resources/db/migration/V4__add_task_assignee.sql
new file mode 100644
index 0000000..d04d187
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V4__add_task_assignee.sql
@@ -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);
diff --git a/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java b/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java
index 3247c7e..10d961a 100644
--- a/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java
+++ b/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java
@@ -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));
+ }
}
diff --git a/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java b/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java
index cbeeee7..785545d 100644
--- a/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java
+++ b/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java
@@ -22,6 +22,7 @@ class TaskTest {
null,
TaskStatus.WAITING,
points,
+ null,
Instant.parse("2026-07-26T12:00:00Z"));
}
}
diff --git a/docs/architecture.md b/docs/architecture.md
index 7ce4974..8aae574 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -29,6 +29,7 @@ Den ansvarar för:
- hämtning och presentation av användare och uppgifter;
- lokalt val av aktiv användare;
- formulär för att skapa användare och uppgifter;
+- val och visning av ansvarig användare på uppgifter;
- klientnära validering och begripliga felmeddelanden;
- uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart.
@@ -60,6 +61,7 @@ Aktuella endpoints:
- `POST /api/users`
- `GET /api/tasks`
- `POST /api/tasks`
+- `PUT /api/tasks/{taskId}/assignee`
### Databas och migreringar
@@ -77,6 +79,7 @@ Flyway kör migreringarna:
- `V1__create_users.sql`
- `V2__create_tasks.sql`
- `V3__add_task_points.sql`
+- `V4__add_task_assignee.sql`
Hibernate är konfigurerat med `ddl-auto=validate`; Flyway skapar schemat och
Hibernate validerar entiteterna mot det.
@@ -104,12 +107,19 @@ En uppgift lagras i tabellen `task` med:
- `description`: valfri beskrivning, högst 500 tecken;
- `status`: `WAITING`, `IN_PROGRESS` eller `COMPLETED`;
- `points`: obligatoriskt heltal mellan 1 och 99;
+- `assignee_id`: nullable främmande nyckel till `app_user`;
- `created_at`: en `Instant`, lagrad som `TIMESTAMP WITH TIME ZONE`.
Status lagras som enumens textvärde genom `EnumType.STRING`. Nya uppgifter får
alltid status `WAITING`. Poängintervallet skyddas i backend och med en
-databasconstraint. Det finns ingen relation mellan uppgifter och användare;
-alla aktiva användare ser samma uppgiftslista.
+databasconstraint. En uppgift kan vara otilldelad eller referera till exakt en
+ansvarig användare. Relationen hämtas tillsammans med uppgifterna när de listas,
+så API-responsen kan innehålla ansvarigs `id` och `name` utan separata
+frontend-anrop. Alla aktiva användare ser samma uppgiftslista.
+
+Ansvarig är valfri vid skapande. Endast väntande uppgifter kan få ändrad
+ansvarig genom det särskilda tilldelnings-API:t. Tilldelning ändrar aldrig
+uppgiftens status.
### Aktiv användare
@@ -124,8 +134,9 @@ lokalt per browser och utgör inte autentisering eller behörighetskontroll.
### Felhantering
Backend använder ett litet gemensamt JSON-format med `code` och `message`.
-`ApiExceptionHandler` översätter kända valideringsfel till `400 Bad Request`
-och dubbletter av användarnamn till `409 Conflict`.
+`ApiExceptionHandler` översätter kända valideringsfel till `400 Bad Request`,
+saknade uppgifter eller användare till `404 Not Found` och dubbletter eller
+otillåtna tilldelningsändringar till `409 Conflict`.
Frontend skiljer mellan fel vid hämtning och skapande. Hämtfel kan
återförsökas. Formulärfel visas nära formuläret och inmatningen behålls vid
diff --git a/docs/features/004-task-assignment.md b/docs/features/004-task-assignment.md
new file mode 100644
index 0000000..9f41741
--- /dev/null
+++ b/docs/features/004-task-assignment.md
@@ -0,0 +1,122 @@
+# Feature 4 – Tilldelning av uppgifter
+
+## Status
+
+Pågående.
+
+## Bakgrund
+
+HemHub har centralt lagrade användare och gemensamma uppgifter. För att senare
+kunna införa regler för pågående arbete behöver en uppgift kunna ha en ansvarig
+användare, utan att tilldelning samtidigt ändrar uppgiftens status.
+
+## Mål
+
+- välja en valfri ansvarig när en uppgift skapas;
+- visa ansvarig på uppgiftskortet;
+- tilldela, byta eller ta bort ansvarig på en väntande uppgift;
+- lagra tilldelningen centralt så att alla användare ser samma värde.
+
+## Omfattning
+
+En uppgift kan vara otilldelad eller tilldelad exakt en befintlig användare.
+`Ingen` är standard vid skapande och den aktiva browseranvändaren förväljs
+inte. Frontend återanvänder användarlistan som redan hämtas vid appstart.
+
+På ett otilldelat väntande kort öppnar `Ta uppgift` ett användarval. Ett
+tilldelat väntande kort visar namnet och öppnar samma val. Ändringen skickas
+direkt till backend och kortet uppdateras först med den bekräftade responsen.
+Vid fel behålls den tidigare tilldelningen och ett lokalt felmeddelande visas.
+
+För `IN_PROGRESS` och `COMPLETED` visas ansvarig eller `Otilldelad` utan
+redigerbar kontroll.
+
+## Produktregler
+
+- En uppgift har högst en ansvarig.
+- Ansvarig är valfri och måste motsvara en befintlig användare.
+- Endast uppgifter med status `WAITING` får få ändrad ansvarig.
+- Tilldelning ändrar aldrig status, titel, beskrivning eller poäng.
+- Vem som helst kan välja valfri ansvarig; aktiv användare är inte
+ autentisering eller behörighetskontroll.
+
+## API-förändringar
+
+`POST /api/tasks` accepterar det valfria fältet `assigneeId`. Saknat fält eller
+`null` skapar en otilldelad uppgift. Ett UUID som inte motsvarar en användare
+ger `404 Not Found`.
+
+`PUT /api/tasks/{taskId}/assignee` ändrar endast ansvarig:
+
+```json
+{"assigneeId": "d56b54dd-31b0-4d71-8a10-82464be59a61"}
+```
+
+`{"assigneeId": null}` tar bort tilldelningen. Fältet måste finnas i requesten.
+Responsen är den uppdaterade uppgiften. Task-responser innehåller:
+
+```json
+{"assignee": {"id": "d56b54dd-31b0-4d71-8a10-82464be59a61", "name": "Anna"}}
+```
+
+Otilldelade uppgifter har `"assignee": null`. Ogiltigt UUID eller saknat fält
+ger `400`, okänd uppgift eller användare ger `404` och ändring av en uppgift
+som inte väntar ger `409`. Felen använder det befintliga formatet med `code`
+och `message`.
+
+## Databasförändringar
+
+`V4__add_task_assignee.sql` lägger till `task.assignee_id UUID NULL` med en
+främmande nyckel till `app_user.id`. Befintliga uppgifter blir otilldelade.
+Migreringen använder varken `ON DELETE CASCADE` eller `ON DELETE SET NULL`.
+
+JPA-modellen använder en lazy `ManyToOne`. Repositoryts listning och
+id-hämtning använder en entity graph för att hämta ansvarig tillsammans med
+uppgiften och undvika N+1-frågor när responsen byggs.
+
+## Frontendförändringar
+
+Skapandedialogen innehåller ett tilldelningsval med `Ingen` och samtliga
+användare. Valet bevaras tillsammans med övriga formulärvärden vid fel.
+
+Väntande kort har en separat tilldelningskontroll. Kontrollen är inaktiverad
+medan just det kortets request pågår; övriga delar av brädan förblir
+interaktiva. Serverns task-respons ersätter motsvarande uppgift i den befintliga
+listan utan att ändra ordningen.
+
+## Tester och verifiering
+
+Backendens integrationstester täcker skapande med och utan ansvarig,
+responsformat, okända id:n, tilldelning, byte, av-tilldelning, statuskonflikt
+och att övriga uppgiftsfält inte ändras.
+
+Frontendtesterna täcker standardval och användarlista i skapandedialogen,
+create-requestens `assigneeId`, kortens redigerbara och statiska lägen,
+tilldelningsrequest, vänteläge, serverbekräftad uppdatering, av-tilldelning och
+fel utan optimistisk ändring.
+
+Manuell verifiering ska omfatta skapande med och utan ansvarig, tilldelning,
+byte, av-tilldelning, bevarad status, omladdning, statiska kontroller för andra
+statusar, felrespons och projektets normala desktop- och mobilbredder.
+
+## Avgränsning mot Feature 5
+
+Feature 4 inför inget API eller UI för statusändring och inte regeln att
+`IN_PROGRESS` måste ha en ansvarig. Tilldelning leder inte automatiskt till
+`IN_PROGRESS`, och av-tilldelning leder inte automatiskt till `WAITING`.
+
+## Ingår inte
+
+Flera ansvariga, statusändring, drag-and-drop, generell redigering, radering,
+deadlines, återkommande uppgifter, poänghistorik, användaradministration,
+autentisering, behörighetskontroll och automatisk tilldelning ingår inte.
+
+## Kända begränsningar
+
+Användare kan ännu inte raderas, så relationens framtida beteende vid
+användarradering är inte beslutat. Frontend har ingen optimistisk uppdatering;
+det tidigare värdet ligger kvar tills backend svarar.
+
+## Relaterade commits
+
+Fylls i när featuren har committats.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 16a4024..9213b35 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -43,11 +43,12 @@ Feature 0–2 är klara. Den aktuella applikationen har:
- en bräda med Väntande, Pågående och Klart;
- nya uppgifter som alltid skapas med status `WAITING`.
-Det finns ännu inga uppgiftstilldelningar, statusändringar, drag-and-drop,
-redigeringar, raderingar, deadlines eller återkommande uppgifter.
+Tilldelning av högst en ansvarig användare per uppgift är under utveckling. Det
+finns ännu inga statusändringar, drag-and-drop, redigeringar, raderingar,
+deadlines eller återkommande uppgifter.
Nuvarande användarval är inte autentisering.
-**Feature 3 – Uppgiftspoäng är pågående.**
+**Feature 4 – Tilldelning av uppgifter är pågående.**
## Featureöversikt
@@ -57,7 +58,7 @@ Nuvarande användarval är inte autentisering.
| 1 – Användarval | Klar | 0 | Centrala användare och lokalt aktivt användar-id |
| 2 – Skapa uppgifter | Klar | 0–1 | Gemensamma uppgifter och trekolumnsbräda |
| 3 – Uppgiftspoäng | Pågående | 2 | Poäng på uppgifter |
-| 4 – Tilldelning | Planerad | 1–2 | Valfri ansvarig användare |
+| 4 – Tilldelning | Pågående | 1–2 | Valfri ansvarig användare |
| 5 – Statusändring | Planerad | 4 | Backendstyrda statusövergångar |
| 6 – Drag-and-drop | Planerad | 5 | Kortflytt via status-API |
| 7 – Radera uppgift | Planerad | 2 | Bekräftad radering |
@@ -130,7 +131,7 @@ databasen har inget permanent defaultvärde.
### Feature 4 – Tilldelning av uppgifter
-**Status:** Planerad
+**Status:** Pågående
**Beroenden:** Feature 1 och Feature 2
@@ -141,13 +142,10 @@ databasen har inget permanent defaultvärde.
- visa ansvarig på uppgiftskort;
- kunna ändra ansvarig på en befintlig uppgift.
-En väntande uppgift får vara tilldelad eller otilldelad. Tilldelning införs före
-statusändring eftersom en pågående uppgift senare måste ha en ansvarig.
-
-**Öppna frågor:**
-
-- om en uppgift ska ha endast en ansvarig;
-- hur borttagna användare ska hanteras när användarradering införs.
+En väntande uppgift får vara tilldelad eller otilldelad och har högst en
+ansvarig. Tilldelning införs före statusändring eftersom en pågående uppgift
+senare måste ha en ansvarig. Hur borttagna användare ska hanteras är fortsatt
+öppet tills användarradering införs.
### Feature 5 – Statusändring och statusregler
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 22a77a6..e8f4546 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -22,6 +22,7 @@ const tasks = [
description: 'Bottenvåningen',
status: 'WAITING',
points: 7,
+ assignee: null,
createdAt: '2026-07-24T10:00:00Z',
},
{
@@ -30,6 +31,7 @@ const tasks = [
description: null,
status: 'IN_PROGRESS',
points: 3,
+ assignee: { id: users[1].id, name: users[1].name },
createdAt: '2026-07-24T10:01:00Z',
},
{
@@ -38,6 +40,7 @@ const tasks = [
description: null,
status: 'COMPLETED',
points: 5,
+ assignee: null,
createdAt: '2026-07-24T10:02:00Z',
},
]
@@ -193,6 +196,13 @@ test('Ny uppgift öppnar modalen med fokus i titelfältet', async () => {
expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument()
expect(screen.getByLabelText('Titel')).toHaveFocus()
expect(screen.getByLabelText('Poäng')).toHaveValue(1)
+ expect(screen.getByLabelText('Tilldela')).toHaveValue('')
+ expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Ingen' }))
+ .toBeInTheDocument()
+ expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Urban' }))
+ .toBeInTheDocument()
+ expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Anna' }))
+ .toBeInTheDocument()
})
test.each([
@@ -246,6 +256,7 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
description: 'Köket',
status: 'WAITING',
points: 7,
+ assignee: { id: users[1].id, name: users[1].name },
createdAt: '2026-07-24T10:03:00Z',
}
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
@@ -263,6 +274,7 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
target: { value: ' Köket ' },
})
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } })
+ fireEvent.change(screen.getByLabelText('Tilldela'), { target: { value: users[1].id } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
await waitFor(() =>
@@ -270,17 +282,148 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
)
const waiting = screen.getByRole('region', { name: 'Väntande' })
expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([
- 'Dammsuga7 pBottenvåningen',
- 'Putsa fönster7 pKöket',
+ 'Dammsuga7 pBottenvåningenTa uppgift',
+ 'Putsa fönster7 pKöketAnna',
])
expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ title: 'Putsa fönster', description: 'Köket', points: 7 }),
+ body: JSON.stringify({
+ title: 'Putsa fönster',
+ description: 'Köket',
+ points: 7,
+ assigneeId: users[1].id,
+ }),
})
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
expect(screen.getByLabelText('Poäng')).toHaveValue(1)
+ expect(screen.getByLabelText('Tilldela')).toHaveValue('')
+})
+
+test('Ingen skickas som null när en uppgift skapas', async () => {
+ const createdTask = {
+ ...tasks[0],
+ id: '00000000-0000-0000-0000-000000000010',
+ title: 'Torka bordet',
+ }
+ window.localStorage.setItem('hemhub.activeUserId', users[0].id)
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ fetchMock.mockResolvedValueOnce(jsonResponse(users))
+ fetchMock.mockResolvedValueOnce(jsonResponse([]))
+ fetchMock.mockResolvedValueOnce(jsonResponse(createdTask, 201))
+ render()
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
+ fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
+ fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Torka bordet' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
+ expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: 'Torka bordet',
+ description: null,
+ points: 1,
+ assigneeId: null,
+ }),
+ })
+})
+
+test('kort visar ansvarig och endast väntande uppgifter har redigerbar kontroll', async () => {
+ window.localStorage.setItem('hemhub.activeUserId', users[0].id)
+ mockUsersAndTasks(users, tasks)
+ render()
+
+ const waitingCard = (await screen.findByText('Dammsuga')).closest('article')
+ const inProgressCard = screen.getByText('Diska').closest('article')
+ const completedCard = screen.getByText('Vattna blommor').closest('article')
+
+ expect(waitingCard).not.toBeNull()
+ expect(inProgressCard).not.toBeNull()
+ expect(completedCard).not.toBeNull()
+ expect(within(waitingCard!).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
+ .toHaveTextContent('Ta uppgift')
+ expect(within(inProgressCard!).getByText('Anna')).toBeInTheDocument()
+ expect(within(inProgressCard!).queryByRole('button', { name: /ansvarig/i })).not.toBeInTheDocument()
+ expect(within(completedCard!).getByText('Otilldelad')).toBeInTheDocument()
+})
+
+test('val av ansvarig anropar endpointen och uppdaterar kortet efter svar', async () => {
+ const updatedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } }
+ let resolveAssignment!: (response: Response) => void
+ const assignmentResponse = new Promise((resolve) => {
+ resolveAssignment = resolve
+ })
+ window.localStorage.setItem('hemhub.activeUserId', users[0].id)
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ fetchMock.mockResolvedValueOnce(jsonResponse(users))
+ fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
+ fetchMock.mockReturnValueOnce(assignmentResponse)
+ render()
+
+ fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
+ const select = screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' })
+ fireEvent.change(select, { target: { value: users[1].id } })
+
+ expect(select).toBeDisabled()
+ expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ assigneeId: users[1].id }),
+ })
+
+ resolveAssignment(jsonResponse(updatedTask))
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
+ .toHaveTextContent('Anna'),
+ )
+ expect(screen.queryByRole('combobox', { name: 'Ansvarig för Dammsuga' })).not.toBeInTheDocument()
+})
+
+test('val av Ingen av-tilldelar en väntande uppgift', async () => {
+ const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } }
+ const unassignedTask = { ...assignedTask, assignee: null }
+ window.localStorage.setItem('hemhub.activeUserId', users[0].id)
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ fetchMock.mockResolvedValueOnce(jsonResponse(users))
+ fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask]))
+ fetchMock.mockResolvedValueOnce(jsonResponse(unassignedTask))
+ render()
+
+ fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
+ fireEvent.change(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }), {
+ target: { value: '' },
+ })
+
+ await screen.findByText('Ta uppgift')
+ expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ assigneeId: null }),
+ })
+})
+
+test('misslyckad tilldelning behåller ansvarig och visar fel', async () => {
+ const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } }
+ window.localStorage.setItem('hemhub.activeUserId', users[0].id)
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ fetchMock.mockResolvedValueOnce(jsonResponse(users))
+ fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask]))
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ code: 'USER_NOT_FOUND', message: 'Användaren finns inte.' }, 404),
+ )
+ render()
+
+ fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
+ fireEvent.change(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }), {
+ target: { value: users[0].id },
+ })
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Användaren finns inte.')
+ expect(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' })).toHaveValue(users[1].id)
})
test.each([
@@ -321,9 +464,11 @@ test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
const title = screen.getByLabelText('Titel')
const description = screen.getByLabelText('Beskrivning (valfri)')
const points = screen.getByLabelText('Poäng')
+ const assignee = screen.getByLabelText('Tilldela')
fireEvent.change(title, { target: { value: 'Dammsuga' } })
fireEvent.change(description, { target: { value: 'Bottenvåningen' } })
fireEvent.change(points, { target: { value: '7' } })
+ fireEvent.change(assignee, { target: { value: users[1].id } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.')
@@ -331,6 +476,7 @@ test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
expect(title).toHaveValue('Dammsuga')
expect(description).toHaveValue('Bottenvåningen')
expect(points).toHaveValue(7)
+ expect(assignee).toHaveValue(users[1].id)
})
function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d81b2cb..38d853b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -79,7 +79,7 @@ function App() {
}
if (activeUser) {
- return
+ return
}
if (showCreateUser) {
diff --git a/frontend/src/TaskBoard.tsx b/frontend/src/TaskBoard.tsx
index c3d6947..54bf38d 100644
--- a/frontend/src/TaskBoard.tsx
+++ b/frontend/src/TaskBoard.tsx
@@ -2,12 +2,20 @@ import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react'
type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED'
+type UserSummary = {
+ id: string
+ name: string
+}
+
+type Assignee = UserSummary
+
type Task = {
id: string
title: string
description: string | null
status: TaskStatus
points: number
+ assignee: Assignee | null
createdAt: string
}
@@ -17,6 +25,7 @@ type ApiError = {
type TaskBoardProps = {
activeUserName: string
+ users: UserSummary[]
onLogOut: () => void
}
@@ -26,10 +35,13 @@ const columns: { status: TaskStatus; title: string }[] = [
{ status: 'COMPLETED', title: 'Klart' },
]
-function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
+function TaskBoard({ activeUserName, users, onLogOut }: TaskBoardProps) {
const [tasks, setTasks] = useState([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [showCreateTask, setShowCreateTask] = useState(false)
+ const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState(null)
+ const [savingAssigneeTaskIds, setSavingAssigneeTaskIds] = useState>(new Set())
+ const [assignmentErrors, setAssignmentErrors] = useState>({})
const loadTasks = async () => {
setLoadState('loading')
@@ -52,6 +64,49 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
void loadTasks()
}, [])
+ const updateAssignee = async (task: Task, assigneeId: string) => {
+ if (savingAssigneeTaskIds.has(task.id)) {
+ return
+ }
+
+ setSavingAssigneeTaskIds((current) => new Set(current).add(task.id))
+ setAssignmentErrors((current) => ({ ...current, [task.id]: '' }))
+
+ try {
+ const response = await fetch(`/api/tasks/${task.id}/assignee`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ assigneeId: assigneeId || null }),
+ })
+
+ if (!response.ok) {
+ const apiError = (await response.json().catch(() => ({}))) as ApiError
+ setAssignmentErrors((current) => ({
+ ...current,
+ [task.id]: apiError.message ?? 'Det gick inte att ändra ansvarig. Försök igen.',
+ }))
+ return
+ }
+
+ const updatedTask = (await response.json()) as Task
+ setTasks((current) =>
+ current.map((currentTask) => (currentTask.id === updatedTask.id ? updatedTask : currentTask)),
+ )
+ setEditingAssigneeTaskId(null)
+ } catch {
+ setAssignmentErrors((current) => ({
+ ...current,
+ [task.id]: 'Det gick inte att ändra ansvarig. Försök igen.',
+ }))
+ } finally {
+ setSavingAssigneeTaskIds((current) => {
+ const next = new Set(current)
+ next.delete(task.id)
+ return next
+ })
+ }
+ }
+
return (
@@ -98,6 +153,15 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
{task.points} p
{task.description && {task.description}
}
+ setEditingAssigneeTaskId(task.id)}
+ onChange={(assigneeId) => void updateAssignee(task, assigneeId)}
+ />
))}
@@ -107,6 +171,7 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
{showCreateTask && (
setShowCreateTask(false)}
onCreated={(task) => {
setTasks((currentTasks) => [...currentTasks, task])
@@ -118,15 +183,110 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
)
}
+type AssigneeControlProps = {
+ task: Task
+ users: UserSummary[]
+ editing: boolean
+ saving: boolean
+ error?: string
+ onEdit: () => void
+ onChange: (assigneeId: string) => void
+}
+
+function UserIcon() {
+ return (
+
+ )
+}
+
+function AssigneeControl({
+ task,
+ users,
+ editing,
+ saving,
+ error,
+ onEdit,
+ onChange,
+}: AssigneeControlProps) {
+ const displayName = task.assignee?.name ?? (task.status === 'WAITING' ? 'Ta uppgift' : 'Otilldelad')
+
+ if (task.status !== 'WAITING') {
+ return (
+
+
+ {displayName}
+
+ )
+ }
+
+ return (
+
+ {editing ? (
+
+ ) : (
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
+
type CreateTaskModalProps = {
+ users: UserSummary[]
onClose: () => void
onCreated: (task: Task) => void
}
-function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
+function CreateTaskModal({ users, onClose, onCreated }: CreateTaskModalProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [points, setPoints] = useState('1')
+ const [assigneeId, setAssigneeId] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const isSubmittingRef = useRef(false)
@@ -191,6 +351,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
title: trimmedTitle,
description: trimmedDescription || null,
points: numericPoints,
+ assigneeId: assigneeId || null,
}),
})
@@ -264,6 +425,23 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
1–99 poäng beroende på hur tidskrävande, besvärlig eller viktig uppgiften är.
+
+
+
{error && (
{error}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index a9cff6c..476d10f 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -23,6 +23,7 @@ h1 {
button,
input,
+select,
textarea {
font: inherit;
}
@@ -38,6 +39,7 @@ button {
button:disabled,
input:disabled,
+select:disabled,
textarea:disabled {
cursor: not-allowed;
opacity: 0.65;
@@ -70,6 +72,15 @@ input {
border-radius: 0.4rem;
}
+select {
+ box-sizing: border-box;
+ width: 100%;
+ padding: 0.6rem;
+ border: 1px solid #9ca3af;
+ border-radius: 0.4rem;
+ background: white;
+}
+
textarea {
box-sizing: border-box;
width: 100%;
@@ -174,6 +185,68 @@ textarea {
margin: 0;
}
+.task-assignment {
+ margin-top: 0.9rem;
+}
+
+.task-assignee {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ color: #475569;
+ font-size: 0.9rem;
+}
+
+.task-assignee-button {
+ padding: 0.25rem 0;
+ color: #2563eb;
+ background: transparent;
+}
+
+.task-assignee-static {
+ margin-top: 0.9rem;
+}
+
+.user-icon {
+ flex: 0 0 auto;
+}
+
+.assignee-select-label {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.assignee-select-label select {
+ width: auto;
+ min-width: 9rem;
+}
+
+.assignment-error {
+ margin-top: 0.4rem;
+ font-size: 0.85rem;
+}
+
+.field-label-uppercase {
+ color: #64748b;
+ font-size: 0.8rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
.task-card-header {
display: flex;
align-items: flex-start;
--
2.49.0