feat(tasks): add task pages and request boundaries

This commit is contained in:
sechmachine
2026-08-14 23:48:16 +07:00
parent 597ebf1b49
commit 1ed23f4de9
11 changed files with 479 additions and 3 deletions
@@ -0,0 +1,3 @@
package com.lab.labtimesheet.tasks;
public record TaskAssigneeChoice(long membershipId, String displayName) {}
@@ -0,0 +1,108 @@
package com.lab.labtimesheet.tasks;
import jakarta.validation.Valid;
import java.util.Locale;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping("/projects/{projectId}/tasks")
String list(Authentication authentication, @PathVariable long projectId, Model model) {
TaskListView taskList = taskService.list(authentication.getName(), projectId);
model.addAttribute("projectId", projectId);
model.addAttribute("taskList", taskList);
model.addAttribute("progressLabel", progressLabel(taskList.progress()));
return "tasks/list";
}
@GetMapping("/projects/{projectId}/tasks/new")
String createForm(Authentication authentication, @PathVariable long projectId, Model model) {
model.addAttribute("taskForm", new TaskCreateForm("", "", null, null));
populateForm(authentication.getName(), projectId, model);
return "tasks/form";
}
@PostMapping("/projects/{projectId}/tasks")
String create(
Authentication authentication,
@PathVariable long projectId,
@Valid @ModelAttribute("taskForm") TaskCreateForm form,
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
populateForm(authentication.getName(), projectId, model);
return "tasks/form";
}
TaskView task = taskService.create(
authentication.getName(),
new CreateTaskCommand(
projectId,
form.assigneeMembershipId(),
form.title(),
form.description(),
form.dueDate()));
return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id());
}
@GetMapping("/projects/{projectId}/tasks/{taskId}")
String details(
Authentication authentication,
@PathVariable long projectId,
@PathVariable long taskId,
Model model) {
model.addAttribute("projectId", projectId);
model.addAttribute("details", taskService.details(authentication.getName(), projectId, taskId));
model.addAttribute("statuses", TaskStatus.values());
return "tasks/detail";
}
@PostMapping("/projects/{projectId}/tasks/{taskId}/status")
String changeStatus(
Authentication authentication,
@PathVariable long projectId,
@PathVariable long taskId,
@RequestParam TaskStatus status) {
taskService.changeStatus(authentication.getName(), projectId, taskId, status);
return detailsRedirect(projectId, taskId);
}
@PostMapping("/projects/{projectId}/tasks/{taskId}/comments")
String addComment(
Authentication authentication,
@PathVariable long projectId,
@PathVariable long taskId,
@RequestParam String body) {
taskService.addComment(authentication.getName(), projectId, taskId, body);
return detailsRedirect(projectId, taskId);
}
private void populateForm(String actorEmail, long projectId, Model model) {
model.addAttribute("projectId", projectId);
model.addAttribute("assignees", taskService.assignmentChoices(actorEmail, projectId));
}
private static String detailsRedirect(long projectId, long taskId) {
return "redirect:/projects/%d/tasks/%d".formatted(projectId, taskId);
}
private static String progressLabel(TaskProgress progress) {
return progress.completionPercentage().isEmpty()
? "N/A"
: String.format(Locale.ROOT, "%.1f%%", progress.completionPercentage().getAsDouble());
}
}
@@ -0,0 +1,13 @@
package com.lab.labtimesheet.tasks;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
public record TaskCreateForm(
@NotBlank @Size(max = 200) String title,
String description,
@NotNull Long assigneeMembershipId,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueDate) {}
@@ -153,6 +153,32 @@ public class TaskService {
return new TaskDetails(task, comments);
}
@Transactional(readOnly = true)
public List<TaskAssigneeChoice> assignmentChoices(String actorEmail, long projectId) {
Actor actor = requireActiveActor(actorEmail);
requireOpenProject(projectId);
long actorMembershipId = requireActorMembership(projectId, actor.id());
boolean leader = isCurrentLeader(projectId, actorMembershipId);
return jdbc.sql("""
select m.id, u.display_name
from project_memberships m
join app_users u on u.id = m.intern_user_id
join intern_profiles i on i.user_id = m.intern_user_id
where m.project_id = :projectId
and m.left_at is null
and u.account_status = 'ACTIVE'
and i.internship_status = 'ACTIVE'
and (:leader or m.id = :actorMembershipId)
order by m.id
""")
.param("projectId", projectId)
.param("leader", leader)
.param("actorMembershipId", actorMembershipId)
.query((rs, rowNum) -> new TaskAssigneeChoice(
rs.getLong("id"), rs.getString("display_name")))
.list();
}
private Actor requireActiveActor(String email) {
Actor actor = requireReadableActor(email);
if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) {
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${details.task.title}">Task</title>
</head>
<body>
<main>
<h1 th:text="${details.task.title}">Task</h1>
<p th:text="${details.task.description ?: 'No description'}">No description</p>
<p>Status: <strong th:text="${details.task.status}">TODO</strong></p>
<p>Due date: <span th:text="${details.task.dueDate ?: '—'}"></span></p>
<form method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/status(projectId=${projectId},taskId=${details.task.id})}">
<label for="status">New status</label>
<select id="status" name="status" required>
<option th:each="status : ${statuses}" th:value="${status}" th:text="${status}">TODO</option>
</select>
<button type="submit">Change status</button>
</form>
<section aria-labelledby="comments-heading">
<h2 id="comments-heading">Comments</h2>
<ol>
<li th:each="comment : ${details.comments}" th:text="${comment.body}">Comment</li>
</ol>
<form method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/comments(projectId=${projectId},taskId=${details.task.id})}">
<label for="body">Comment</label>
<textarea id="body" name="body" required></textarea>
<button type="submit">Add comment</button>
</form>
</section>
</main>
</body>
</html>
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create Task</title>
</head>
<body>
<main>
<h1>Create Task</h1>
<form method="post" th:action="@{/projects/{projectId}/tasks(projectId=${projectId})}" th:object="${taskForm}">
<div>
<label for="title">Title</label>
<input id="title" type="text" maxlength="200" required th:field="*{title}">
<p role="alert" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</p>
</div>
<div>
<label for="description">Description</label>
<textarea id="description" th:field="*{description}"></textarea>
</div>
<div>
<label for="assigneeMembershipId">Assignee</label>
<select id="assigneeMembershipId" required th:field="*{assigneeMembershipId}">
<option value="">Select an assignee</option>
<option th:each="assignee : ${assignees}" th:value="${assignee.membershipId}" th:text="${assignee.displayName}">Member</option>
</select>
<p role="alert" th:if="${#fields.hasErrors('assigneeMembershipId')}" th:errors="*{assigneeMembershipId}">Assignee error</p>
</div>
<div>
<label for="dueDate">Due date</label>
<input id="dueDate" type="date" th:field="*{dueDate}">
</div>
<button type="submit">Create Task</button>
</form>
</main>
</body>
</html>
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project tasks</title>
</head>
<body>
<main>
<h1>Project tasks</h1>
<p>Progress: <strong th:text="${progressLabel}">N/A</strong></p>
<dl>
<dt>TODO</dt><dd th:text="${taskList.progress.todo}">0</dd>
<dt>IN_PROGRESS</dt><dd th:text="${taskList.progress.inProgress}">0</dd>
<dt>BLOCKED</dt><dd th:text="${taskList.progress.blocked}">0</dd>
<dt>DONE</dt><dd th:text="${taskList.progress.done}">0</dd>
</dl>
<p><a th:href="@{/projects/{projectId}/tasks/new(projectId=${projectId})}">Create Task</a></p>
<table>
<caption>Current non-deleted Tasks</caption>
<thead><tr><th scope="col">Title</th><th scope="col">Status</th><th scope="col">Due date</th></tr></thead>
<tbody>
<tr th:each="task : ${taskList.tasks}">
<td><a th:href="@{/projects/{projectId}/tasks/{taskId}(projectId=${projectId},taskId=${task.id})}" th:text="${task.title}">Task</a></td>
<td th:text="${task.status}">TODO</td>
<td th:text="${task.dueDate ?: '—'}"></td>
</tr>
</tbody>
</table>
</main>
</body>
</html>