Merge commit '213a889c8f0a475abfdb06082065320379d9bc7a' into work/reports-ui

This commit is contained in:
sechmachine
2026-08-15 01:24:54 +07:00
54 changed files with 3417 additions and 24 deletions
@@ -1,5 +1,6 @@
package com.lab.labtimesheet.feature.project.controller;
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm;
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
@@ -29,12 +30,17 @@ public class ProjectController {
@GetMapping
public String list(Principal principal, Model model) {
model.addAttribute("projects", pages.listVisible(actorId(principal)));
var actor = pages.authenticatedActor(principal.getName());
model.addAttribute("projects", pages.listVisible(actor.userId()));
model.addAttribute("canCreateProject", "MENTOR".equals(actor.role()));
return "projects/list";
}
@GetMapping("/new")
public String createForm(Model model) {
public String createForm(Principal principal, Model model) {
if (!"MENTOR".equals(pages.authenticatedActor(principal.getName()).role())) {
throw new ProjectAccessDeniedException();
}
model.addAttribute("projectForm", new ProjectCreateForm());
return "projects/form";
}
@@ -10,5 +10,6 @@ public record ProjectDetail(
LocalDate startDate,
LocalDate endDate,
String mentorName,
String leaderName) {
String leaderName,
boolean canManage) {
}
@@ -64,13 +64,16 @@ public class ProjectQueryService {
project.startDate(),
project.endDate(),
displayName(project.mentorUserId()),
displayName(project.currentLeader().internUserId()));
displayName(project.currentLeader().internUserId()),
project.mentorUserId() == actorUserId);
}
@Transactional(readOnly = true)
public List<ProjectMemberView> members(long actorUserId, long projectId) {
var project = visibleProject(actorUserId, projectId);
var leaderUserId = project.currentLeader().internUserId();
Long leaderUserId = project.status() == ProjectStatus.COMPLETED
? null
: project.currentLeader().internUserId();
return project.memberships().stream()
.map(membership -> new ProjectMemberView(
membership.id(),
@@ -78,7 +81,9 @@ public class ProjectQueryService {
displayName(membership.internUserId()),
membership.joinedAt(),
membership.leftAt(),
membership.isCurrent() && membership.internUserId() == leaderUserId))
membership.isCurrent()
&& leaderUserId != null
&& membership.internUserId() == leaderUserId))
.toList();
}
@@ -96,7 +101,22 @@ public class ProjectQueryService {
@Transactional(readOnly = true)
public ProjectTaskContext taskContext(long actorUserId, long projectId) {
var project = visibleProject(actorUserId, projectId);
var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new);
return taskContext(actorUserId, project);
}
ProjectTaskContext taskContext(long actorUserId, ProjectEntity project) {
requireVisibleProject(actorUserId, project);
if (project.status() == ProjectStatus.COMPLETED) {
return new ProjectTaskContext(
project.id(),
project.mentorUserId(),
project.status().name(),
project.startDate(),
project.endDate(),
null,
List.of());
}
var activeMembers = project.memberships().stream()
.filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId()))
.map(membership -> new ProjectTaskMemberView(
@@ -141,15 +161,19 @@ public class ProjectQueryService {
}
private ProjectEntity visibleProject(long actorUserId, long projectId) {
var actor = activeActor(actorUserId);
var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new);
requireVisibleProject(actorUserId, project);
return project;
}
private void requireVisibleProject(long actorUserId, ProjectEntity project) {
var actor = activeActor(actorUserId);
var visible = "ADMIN".equals(actor.role().name())
|| ("MENTOR".equals(actor.role().name()) && project.mentorUserId() == actorUserId)
|| ("INTERN".equals(actor.role().name()) && project.hasEverHadMember(actorUserId));
if (!visible) {
throw new ProjectAccessDeniedException();
}
return project;
}
private List<ProjectEntity> visibleProjects(AccountIdentity actor, long actorUserId) {
@@ -4,6 +4,7 @@ import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedExcepti
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
import java.time.Clock;
@@ -15,14 +16,17 @@ public class ProjectService {
private final ProjectRepository projects;
private final AccountService accounts;
private final ProjectQueryService queries;
private final Clock clock;
public ProjectService(
ProjectRepository projects,
AccountService accounts,
ProjectQueryService queries,
Clock clock) {
this.projects = projects;
this.accounts = accounts;
this.queries = queries;
this.clock = clock;
}
@@ -61,6 +65,11 @@ public class ProjectService {
projects.flush();
}
@Transactional
public ProjectTaskContext taskMutationContext(long actorUserId, long projectId) {
return queries.taskContext(actorUserId, lockedProject(projectId));
}
private ProjectEntity lockedProject(long projectId) {
return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new);
}
@@ -0,0 +1,115 @@
package com.lab.labtimesheet.feature.task.controller;
import com.lab.labtimesheet.feature.task.model.TaskProgress;
import com.lab.labtimesheet.feature.task.model.TaskStatus;
import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
import com.lab.labtimesheet.feature.task.model.dto.TaskCreateForm;
import com.lab.labtimesheet.feature.task.model.dto.TaskListView;
import com.lab.labtimesheet.feature.task.model.dto.TaskView;
import com.lab.labtimesheet.feature.task.service.TaskService;
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,12 @@
package com.lab.labtimesheet.feature.task.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public final class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException() {
super("Task or Project was not found");
}
}
@@ -0,0 +1,12 @@
package com.lab.labtimesheet.feature.task.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.BAD_REQUEST)
public final class TaskValidationException extends RuntimeException {
public TaskValidationException(String message) {
super(message);
}
}
@@ -0,0 +1,40 @@
package com.lab.labtimesheet.feature.task.model;
import java.util.Collection;
import java.util.OptionalDouble;
public record TaskProgress(int todo, int inProgress, int blocked, int done) {
public static TaskProgress from(Collection<TaskStatus> statuses) {
int todo = 0;
int inProgress = 0;
int blocked = 0;
int done = 0;
for (TaskStatus status : statuses) {
switch (status) {
case TODO -> todo++;
case IN_PROGRESS -> inProgress++;
case BLOCKED -> blocked++;
case DONE -> done++;
}
}
return new TaskProgress(todo, inProgress, blocked, done);
}
public int total() {
return todo + inProgress + blocked + done;
}
public int count(TaskStatus status) {
return switch (status) {
case TODO -> todo;
case IN_PROGRESS -> inProgress;
case BLOCKED -> blocked;
case DONE -> done;
};
}
public OptionalDouble completionPercentage() {
return total() == 0 ? OptionalDouble.empty() : OptionalDouble.of(done * 100.0 / total());
}
}
@@ -0,0 +1,17 @@
package com.lab.labtimesheet.feature.task.model;
public enum TaskStatus {
TODO,
IN_PROGRESS,
BLOCKED,
DONE;
public boolean canTransitionTo(TaskStatus target) {
return switch (this) {
case TODO -> target == IN_PROGRESS || target == BLOCKED;
case IN_PROGRESS -> target == DONE || target == BLOCKED;
case BLOCKED -> target == TODO || target == IN_PROGRESS;
case DONE -> target == IN_PROGRESS;
};
}
}
@@ -0,0 +1,10 @@
package com.lab.labtimesheet.feature.task.model.dto;
import java.time.LocalDate;
public record CreateTaskCommand(
long projectId,
long assigneeMembershipId,
String title,
String description,
LocalDate dueDate) {}
@@ -0,0 +1,3 @@
package com.lab.labtimesheet.feature.task.model.dto;
public record TaskAssigneeChoice(long membershipId, String displayName) {}
@@ -0,0 +1,5 @@
package com.lab.labtimesheet.feature.task.model.dto;
import java.time.Instant;
public record TaskCommentView(long id, long taskId, long authorUserId, String body, Instant createdAt) {}
@@ -0,0 +1,13 @@
package com.lab.labtimesheet.feature.task.model.dto;
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) {}
@@ -0,0 +1,13 @@
package com.lab.labtimesheet.feature.task.model.dto;
import java.util.List;
public record TaskDashboardView(
long blockedTaskCount,
long assignedTaskCount,
List<TaskPriorityView> priorityTasks) {
public TaskDashboardView {
priorityTasks = List.copyOf(priorityTasks);
}
}
@@ -0,0 +1,14 @@
package com.lab.labtimesheet.feature.task.model.dto;
import java.util.List;
public record TaskDetails(
TaskView task,
List<TaskCommentView> comments,
boolean canChangeStatus,
boolean canComment) {
public TaskDetails {
comments = List.copyOf(comments);
}
}
@@ -0,0 +1,11 @@
package com.lab.labtimesheet.feature.task.model.dto;
import com.lab.labtimesheet.feature.task.model.TaskProgress;
import java.util.List;
public record TaskListView(List<TaskView> tasks, TaskProgress progress, boolean canCreate) {
public TaskListView {
tasks = List.copyOf(tasks);
}
}
@@ -0,0 +1,10 @@
package com.lab.labtimesheet.feature.task.model.dto;
import com.lab.labtimesheet.feature.task.model.TaskStatus;
import java.time.LocalDate;
public record TaskPriorityView(
String title,
String projectName,
TaskStatus status,
LocalDate dueDate) {}
@@ -0,0 +1,19 @@
package com.lab.labtimesheet.feature.task.model.dto;
import com.lab.labtimesheet.feature.task.model.TaskStatus;
import java.time.Instant;
import java.time.LocalDate;
public record TaskView(
long id,
long projectId,
long assigneeMembershipId,
String assigneeName,
String title,
String description,
TaskStatus status,
LocalDate dueDate,
long creatorMembershipId,
long assignerMembershipId,
Instant assignedAt,
Instant createdAt) {}
@@ -0,0 +1,145 @@
package com.lab.labtimesheet.feature.task.model.entity;
import com.lab.labtimesheet.feature.task.model.TaskStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "project_id", nullable = false)
private long projectId;
@Column(name = "assignee_membership_id", nullable = false)
private long assigneeMembershipId;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "text")
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 24)
private TaskStatus status;
@Column(name = "due_date")
private LocalDate dueDate;
@Column(name = "assigned_at", nullable = false)
private Instant assignedAt;
@Column(name = "created_by_membership_id", nullable = false)
private long creatorMembershipId;
@Column(name = "assigned_by_membership_id", nullable = false)
private long assignerMembershipId;
@Column(name = "deleted_at")
private Instant deletedAt;
@Column(name = "deleted_by_membership_id")
private Long deletedByMembershipId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected Task() {}
public Task(
long projectId,
long assigneeMembershipId,
String title,
String description,
LocalDate dueDate,
long actorMembershipId,
Instant now) {
this.projectId = projectId;
this.assigneeMembershipId = assigneeMembershipId;
this.title = title;
this.description = description;
this.status = TaskStatus.TODO;
this.dueDate = dueDate;
this.assignedAt = now;
this.creatorMembershipId = actorMembershipId;
this.assignerMembershipId = actorMembershipId;
this.createdAt = now;
this.updatedAt = now;
}
public void changeStatus(TaskStatus target, Instant now) {
if (!status.canTransitionTo(target)) {
throw new IllegalArgumentException("Task status transition is not allowed");
}
status = target;
updatedAt = now;
}
public Long getId() {
return id;
}
public long getProjectId() {
return projectId;
}
public long getAssigneeMembershipId() {
return assigneeMembershipId;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
public TaskStatus getStatus() {
return status;
}
public LocalDate getDueDate() {
return dueDate;
}
public Instant getAssignedAt() {
return assignedAt;
}
public long getCreatorMembershipId() {
return creatorMembershipId;
}
public long getAssignerMembershipId() {
return assignerMembershipId;
}
public Instant getDeletedAt() {
return deletedAt;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,59 @@
package com.lab.labtimesheet.feature.task.model.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "task_comments")
public class TaskComment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "task_id", nullable = false)
private long taskId;
@Column(name = "author_user_id", nullable = false)
private long authorUserId;
@Column(nullable = false, columnDefinition = "text")
private String body;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
protected TaskComment() {}
public TaskComment(long taskId, long authorUserId, String body, Instant createdAt) {
this.taskId = taskId;
this.authorUserId = authorUserId;
this.body = body;
this.createdAt = createdAt;
}
public Long getId() {
return id;
}
public long getTaskId() {
return taskId;
}
public long getAuthorUserId() {
return authorUserId;
}
public String getBody() {
return body;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,10 @@
package com.lab.labtimesheet.feature.task.repository;
import com.lab.labtimesheet.feature.task.model.entity.TaskComment;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskCommentRepository extends JpaRepository<TaskComment, Long> {
List<TaskComment> findAllByTaskIdOrderByCreatedAtAscIdAsc(long taskId);
}
@@ -0,0 +1,56 @@
package com.lab.labtimesheet.feature.task.repository;
import com.lab.labtimesheet.feature.task.model.TaskStatus;
import com.lab.labtimesheet.feature.task.model.entity.Task;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface TaskRepository extends JpaRepository<Task, Long> {
Optional<Task> findByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Task> findLockedByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId);
List<Task> findAllByProjectIdAndDeletedAtIsNullOrderById(long projectId);
long countByProjectIdAndDeletedAtIsNull(long projectId);
long countByProjectIdInAndStatusAndDeletedAtIsNull(List<Long> projectIds, TaskStatus status);
long countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull(
List<Long> projectIds, List<Long> assigneeMembershipIds);
@Query("""
select task
from Task task
where task.projectId in :projectIds
and task.assigneeMembershipId in :assigneeMembershipIds
and task.deletedAt is null
order by case when task.dueDate is null then 1 else 0 end,
task.dueDate,
task.id
""")
List<Task> findPriorityTasks(
@Param("projectIds") List<Long> projectIds,
@Param("assigneeMembershipIds") List<Long> assigneeMembershipIds,
Pageable pageable);
@Query("""
select count(task)
from Task task
where task.projectId = :projectId
and task.deletedAt is null
and task.assigneeMembershipId not in :activeMembershipIds
""")
long countCurrentTasksAssignedOutside(
@Param("projectId") long projectId,
@Param("activeMembershipIds") Set<Long> activeMembershipIds);
}
@@ -0,0 +1,86 @@
package com.lab.labtimesheet.feature.task.service;
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.task.model.TaskStatus;
import com.lab.labtimesheet.feature.task.model.dto.TaskDashboardView;
import com.lab.labtimesheet.feature.task.model.dto.TaskPriorityView;
import com.lab.labtimesheet.feature.task.model.entity.Task;
import com.lab.labtimesheet.feature.task.repository.TaskRepository;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class TaskDashboardService {
private static final TaskDashboardView EMPTY_DASHBOARD = new TaskDashboardView(0, 0, List.of());
private final TaskRepository tasks;
private final ProjectQueryService projects;
public TaskDashboardService(TaskRepository tasks, ProjectQueryService projects) {
this.tasks = tasks;
this.projects = projects;
}
@Transactional(readOnly = true)
public TaskDashboardView dashboard(String actorEmail) {
var actor = projects.authenticatedActor(actorEmail);
List<ProjectSummary> activeProjects = projects.listVisible(actor.userId()).stream()
.filter(project -> "ACTIVE".equals(project.status()))
.toList();
if ("MENTOR".equals(actor.role())) {
return mentorDashboard(activeProjects);
}
if ("INTERN".equals(actor.role())) {
return internDashboard(actor.userId(), activeProjects);
}
return EMPTY_DASHBOARD;
}
private TaskDashboardView mentorDashboard(List<ProjectSummary> activeProjects) {
List<Long> projectIds = activeProjects.stream().map(ProjectSummary::id).toList();
long blocked = projectIds.isEmpty()
? 0
: tasks.countByProjectIdInAndStatusAndDeletedAtIsNull(projectIds, TaskStatus.BLOCKED);
return new TaskDashboardView(blocked, 0, List.of());
}
private TaskDashboardView internDashboard(long actorUserId, List<ProjectSummary> activeProjects) {
Map<Long, ProjectSummary> currentProjects = new LinkedHashMap<>();
Map<Long, Long> currentMemberships = new LinkedHashMap<>();
for (ProjectSummary project : activeProjects) {
projects.taskContext(actorUserId, project.id()).activeMembers().stream()
.filter(member -> member.userId() == actorUserId)
.map(ProjectTaskMemberView::membershipId)
.findFirst()
.ifPresent(membershipId -> {
currentProjects.put(project.id(), project);
currentMemberships.put(project.id(), membershipId);
});
}
List<Long> projectIds = List.copyOf(currentProjects.keySet());
List<Long> membershipIds = List.copyOf(currentMemberships.values());
if (projectIds.isEmpty()) {
return EMPTY_DASHBOARD;
}
long assigned = tasks.countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull(
projectIds, membershipIds);
List<TaskPriorityView> priority = tasks.findPriorityTasks(
projectIds, membershipIds, PageRequest.of(0, 5))
.stream()
.map(task -> priorityView(task, currentProjects.get(task.getProjectId()).name()))
.toList();
return new TaskDashboardView(0, assigned, priority);
}
private static TaskPriorityView priorityView(Task task, String projectName) {
return new TaskPriorityView(task.getTitle(), projectName, task.getStatus(), task.getDueDate());
}
}
@@ -0,0 +1,24 @@
package com.lab.labtimesheet.feature.task.service;
import com.lab.labtimesheet.feature.task.repository.TaskRepository;
import java.util.Set;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class TaskQueryService {
private final TaskRepository tasks;
public TaskQueryService(TaskRepository tasks) {
this.tasks = tasks;
}
@Transactional(readOnly = true)
public long countCurrentTasksAssignedOutside(long projectId, Set<Long> activeMembershipIds) {
if (activeMembershipIds.isEmpty()) {
return tasks.countByProjectIdAndDeletedAtIsNull(projectId);
}
return tasks.countCurrentTasksAssignedOutside(projectId, Set.copyOf(activeMembershipIds));
}
}
@@ -0,0 +1,323 @@
package com.lab.labtimesheet.feature.task.service;
import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService;
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberView;
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import com.lab.labtimesheet.feature.task.exception.TaskNotFoundException;
import com.lab.labtimesheet.feature.task.exception.TaskValidationException;
import com.lab.labtimesheet.feature.task.model.TaskProgress;
import com.lab.labtimesheet.feature.task.model.TaskStatus;
import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice;
import com.lab.labtimesheet.feature.task.model.dto.TaskCommentView;
import com.lab.labtimesheet.feature.task.model.dto.TaskDetails;
import com.lab.labtimesheet.feature.task.model.dto.TaskListView;
import com.lab.labtimesheet.feature.task.model.dto.TaskView;
import com.lab.labtimesheet.feature.task.model.entity.Task;
import com.lab.labtimesheet.feature.task.model.entity.TaskComment;
import com.lab.labtimesheet.feature.task.repository.TaskCommentRepository;
import com.lab.labtimesheet.feature.task.repository.TaskRepository;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class TaskService {
private final TaskRepository tasks;
private final TaskCommentRepository comments;
private final ProjectQueryService projects;
private final ProjectService projectMutations;
private final CalendarApplicationService calendar;
private final Clock clock;
public TaskService(
TaskRepository tasks,
TaskCommentRepository comments,
ProjectQueryService projects,
ProjectService projectMutations,
CalendarApplicationService calendar,
Clock clock) {
this.tasks = tasks;
this.comments = comments;
this.projects = projects;
this.projectMutations = projectMutations;
this.calendar = calendar;
this.clock = clock;
}
@Transactional
public TaskView create(String actorEmail, CreateTaskCommand command) {
String title = requireTitle(command.title());
TaskAccess access = requireMutationAccess(actorEmail, command.projectId());
requireOpenProject(access.project());
ProjectTaskMemberView actorMembership = requireActorMembership(
access.project(), access.actor().userId());
ProjectTaskMemberView assignee = requireAssigneeMembership(
access.project(), command.assigneeMembershipId());
if (actorMembership.membershipId() != assignee.membershipId()
&& !Objects.equals(access.project().currentLeaderMembershipId(), actorMembership.membershipId())) {
throw new TaskNotFoundException();
}
validateDueDate(access.project(), command.dueDate());
Task task = new Task(
access.project().projectId(),
assignee.membershipId(),
title,
trimToNull(command.description()),
command.dueDate(),
actorMembership.membershipId(),
clock.instant());
return view(tasks.saveAndFlush(task), assignee.displayName());
}
@Transactional
public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) {
TaskAccess access = requireMutationAccess(actorEmail, projectId);
if (!"ACTIVE".equals(access.project().status())) {
throw new TaskNotFoundException();
}
ProjectTaskMemberView actorMembership = requireActorMembership(
access.project(), access.actor().userId());
Task task = requireLockedTask(projectId, taskId);
if (task.getAssigneeMembershipId() != actorMembership.membershipId()) {
throw new TaskNotFoundException();
}
if (!task.getStatus().canTransitionTo(target)) {
throw new TaskValidationException("Task status transition is not allowed");
}
task.changeStatus(target, clock.instant());
return view(tasks.saveAndFlush(task), actorMembership.displayName());
}
@Transactional
public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) {
String normalizedBody = requireCommentBody(body);
TaskAccess access = requireMutationAccess(actorEmail, projectId);
if ("COMPLETED".equals(access.project().status())) {
throw new TaskNotFoundException();
}
boolean owningMentor = access.actor().userId() == access.project().mentorUserId();
boolean activeMember = access.project().activeMembers().stream()
.anyMatch(member -> member.userId() == access.actor().userId());
if (!owningMentor && !activeMember) {
throw new TaskNotFoundException();
}
requireLockedTask(projectId, taskId);
TaskComment comment = new TaskComment(
taskId, access.actor().userId(), normalizedBody, clock.instant());
return view(comments.saveAndFlush(comment));
}
@Transactional(readOnly = true)
public TaskListView list(String actorEmail, long projectId) {
TaskAccess access = requireReadableProject(actorEmail, projectId);
Map<Long, ProjectMemberView> members = projectMembers(access);
List<TaskView> projectTasks = tasks.findAllByProjectIdAndDeletedAtIsNullOrderById(projectId)
.stream()
.map(task -> view(task, requireAssigneeName(members, task.getAssigneeMembershipId())))
.toList();
return new TaskListView(
projectTasks,
TaskProgress.from(projectTasks.stream().map(TaskView::status).toList()),
isOpen(access.project()) && activeMembership(access) != null);
}
@Transactional(readOnly = true)
public TaskDetails details(String actorEmail, long projectId, long taskId) {
TaskAccess access = requireReadableProject(actorEmail, projectId);
ProjectTaskMemberView actorMembership = activeMembership(access);
Task persistedTask = requireTask(projectId, taskId);
TaskView task = view(
persistedTask,
requireAssigneeName(projectMembers(access), persistedTask.getAssigneeMembershipId()));
List<TaskCommentView> taskComments = comments.findAllByTaskIdOrderByCreatedAtAscIdAsc(taskId)
.stream()
.map(TaskService::view)
.toList();
boolean canChangeStatus = "ACTIVE".equals(access.project().status())
&& actorMembership != null
&& persistedTask.getAssigneeMembershipId() == actorMembership.membershipId();
boolean canComment = !"COMPLETED".equals(access.project().status())
&& (access.actor().userId() == access.project().mentorUserId() || actorMembership != null);
return new TaskDetails(task, taskComments, canChangeStatus, canComment);
}
@Transactional(readOnly = true)
public List<TaskAssigneeChoice> assignmentChoices(String actorEmail, long projectId) {
TaskAccess access = requireProjectAccess(actorEmail, projectId);
requireOpenProject(access.project());
ProjectTaskMemberView actorMembership = requireActorMembership(
access.project(), access.actor().userId());
if (Objects.equals(access.project().currentLeaderMembershipId(), actorMembership.membershipId())) {
return access.project().activeMembers().stream()
.map(member -> new TaskAssigneeChoice(member.membershipId(), member.displayName()))
.toList();
}
return List.of(new TaskAssigneeChoice(
actorMembership.membershipId(), actorMembership.displayName()));
}
private TaskAccess requireProjectAccess(String actorEmail, long projectId) {
try {
ProjectActorView actor = projects.authenticatedActor(actorEmail);
return new TaskAccess(actor, projects.taskContext(actor.userId(), projectId));
} catch (ProjectAccessDeniedException | ProjectRuleViolationException exception) {
throw new TaskNotFoundException();
}
}
private TaskAccess requireMutationAccess(String actorEmail, long projectId) {
try {
ProjectActorView actor = projects.authenticatedActor(actorEmail);
return new TaskAccess(actor, projectMutations.taskMutationContext(actor.userId(), projectId));
} catch (ProjectAccessDeniedException | ProjectRuleViolationException exception) {
throw new TaskNotFoundException();
}
}
private TaskAccess requireReadableProject(String actorEmail, long projectId) {
TaskAccess access = requireProjectAccess(actorEmail, projectId);
boolean historicalIntern = "INTERN".equals(access.actor().role())
&& access.project().activeMembers().stream()
.noneMatch(member -> member.userId() == access.actor().userId());
if (historicalIntern && !"COMPLETED".equals(access.project().status())) {
throw new TaskNotFoundException();
}
return access;
}
private Map<Long, ProjectMemberView> projectMembers(TaskAccess access) {
try {
return projects.members(access.actor().userId(), access.project().projectId()).stream()
.collect(Collectors.toUnmodifiableMap(ProjectMemberView::membershipId, Function.identity()));
} catch (ProjectAccessDeniedException | ProjectRuleViolationException exception) {
throw new TaskNotFoundException();
}
}
private static String requireAssigneeName(Map<Long, ProjectMemberView> members, long membershipId) {
ProjectMemberView member = members.get(membershipId);
if (member == null) {
throw new TaskNotFoundException();
}
return member.displayName();
}
private static ProjectTaskMemberView activeMembership(TaskAccess access) {
return access.project().activeMembers().stream()
.filter(member -> member.userId() == access.actor().userId())
.findFirst()
.orElse(null);
}
private Task requireTask(long projectId, long taskId) {
return tasks.findByIdAndProjectIdAndDeletedAtIsNull(taskId, projectId)
.orElseThrow(TaskNotFoundException::new);
}
private Task requireLockedTask(long projectId, long taskId) {
return tasks.findLockedByIdAndProjectIdAndDeletedAtIsNull(taskId, projectId)
.orElseThrow(TaskNotFoundException::new);
}
private static void requireOpenProject(ProjectTaskContext project) {
if (!isOpen(project)) {
throw new TaskNotFoundException();
}
}
private static boolean isOpen(ProjectTaskContext project) {
return "PLANNED".equals(project.status()) || "ACTIVE".equals(project.status());
}
private static ProjectTaskMemberView requireActorMembership(ProjectTaskContext project, long userId) {
return project.activeMembers().stream()
.filter(member -> member.userId() == userId)
.findFirst()
.orElseThrow(TaskNotFoundException::new);
}
private static ProjectTaskMemberView requireAssigneeMembership(ProjectTaskContext project, long membershipId) {
return project.activeMembers().stream()
.filter(member -> member.membershipId() == membershipId)
.findFirst()
.orElseThrow(TaskNotFoundException::new);
}
private void validateDueDate(ProjectTaskContext project, LocalDate dueDate) {
if (dueDate == null) {
return;
}
if (dueDate.isBefore(project.startDate()) || dueDate.isAfter(project.endDate())) {
throw new TaskValidationException("Due date must be within Project dates");
}
if (calendar.isGlobalDayOff(dueDate)) {
throw new TaskValidationException("Due date cannot be a current global day off");
}
}
private static TaskView view(Task task, String assigneeName) {
return new TaskView(
task.getId(),
task.getProjectId(),
task.getAssigneeMembershipId(),
assigneeName,
task.getTitle(),
task.getDescription(),
task.getStatus(),
task.getDueDate(),
task.getCreatorMembershipId(),
task.getAssignerMembershipId(),
task.getAssignedAt(),
task.getCreatedAt());
}
private static TaskCommentView view(TaskComment comment) {
return new TaskCommentView(
comment.getId(),
comment.getTaskId(),
comment.getAuthorUserId(),
comment.getBody(),
comment.getCreatedAt());
}
private static String requireTitle(String title) {
String trimmed = trimToNull(title);
if (trimmed == null || trimmed.length() > 200) {
throw new TaskValidationException("Title is required and must not exceed 200 characters");
}
return trimmed;
}
private static String requireCommentBody(String body) {
String trimmed = trimToNull(body);
if (trimmed == null) {
throw new TaskValidationException("Comment body is required");
}
return trimmed;
}
private static String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private record TaskAccess(ProjectActorView actor, ProjectTaskContext project) {}
}
@@ -7,7 +7,7 @@
<table><caption>Leadership history</caption><thead><tr><th scope="col">Leader</th><th scope="col">Started</th><th scope="col">Ended</th></tr></thead>
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}"></td><td th:text="${term.startedAt}"></td><td th:text="${term.endedAt}"></td></tr></tbody>
</table>
<form method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}"><label for="leader">New Leader user ID</label><input id="leader" name="internUserId" type="number" min="1" required><button type="submit">Change Leader</button></form>
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}"><label for="leader">New Leader user ID</label><input id="leader" name="internUserId" type="number" min="1" required><button type="submit">Change Leader</button></form>
</main>
</body>
</html>
@@ -4,7 +4,7 @@
<body>
<main>
<h1>Projects</h1>
<a href="/projects/new">Create Project</a>
<a th:if="${canCreateProject}" href="/projects/new">Create Project</a>
<p th:if="${#lists.isEmpty(projects)}">No authorized Projects.</p>
<table th:unless="${#lists.isEmpty(projects)}">
<caption>Authorized Projects</caption>
@@ -7,7 +7,7 @@
<table><caption>Membership history</caption><thead><tr><th scope="col">Intern</th><th scope="col">Joined</th><th scope="col">Left</th><th scope="col">Role</th></tr></thead>
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}"></td><td th:text="${member.joinedAt}"></td><td th:text="${member.leftAt}"></td><td th:text="${member.currentLeader} ? 'Leader' : 'Member'"></td></tr></tbody>
</table>
<form method="post" th:action="@{/projects/{id}/members(id=${project.id})}"><label for="intern">Intern user ID</label><input id="intern" name="internUserId" type="number" min="1" required><button type="submit">Add member</button></form>
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}"><label for="intern">Intern user ID</label><input id="intern" name="internUserId" type="number" min="1" required><button type="submit">Add member</button></form>
</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 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>Assignee: <strong th:text="${details.task.assigneeName}">Assignee</strong></p>
<p>Status: <strong th:text="${details.task.status}">TODO</strong></p>
<p>Due date: <span th:text="${details.task.dueDate ?: '—'}"></span></p>
<form th:if="${details.canChangeStatus}" 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 th:if="${details.canComment}" 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,33 @@
<!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 th:if="${taskList.canCreate}"><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">Assignee</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.assigneeName}">Assignee</td>
<td th:text="${task.status}">TODO</td>
<td th:text="${task.dueDate ?: '—'}"></td>
</tr>
</tbody>
</table>
</main>
</body>
</html>