feat(task): adopt JPA feature boundaries

This commit is contained in:
sechmachine
2026-08-15 01:22:49 +07:00
parent 788d148d9c
commit 511ee81a91
39 changed files with 1722 additions and 494 deletions
@@ -1,5 +1,12 @@
package com.lab.labtimesheet.tasks;
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;
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks;
package com.lab.labtimesheet.feature.task.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks;
package com.lab.labtimesheet.feature.task.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks;
package com.lab.labtimesheet.feature.task.model;
import java.util.Collection;
import java.util.OptionalDouble;
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks;
package com.lab.labtimesheet.feature.task.model;
public enum TaskStatus {
TODO,
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks;
package com.lab.labtimesheet.feature.task.model.dto;
import java.time.LocalDate;
@@ -1,3 +1,3 @@
package com.lab.labtimesheet.tasks;
package com.lab.labtimesheet.feature.task.model.dto;
public record TaskAssigneeChoice(long membershipId, String displayName) {}
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks;
package com.lab.labtimesheet.feature.task.model.dto;
import java.time.Instant;
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks;
package com.lab.labtimesheet.feature.task.model.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@@ -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);
}
}
@@ -1,8 +1,9 @@
package com.lab.labtimesheet.tasks;
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) {
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) {}
@@ -1,5 +1,6 @@
package com.lab.labtimesheet.tasks;
package com.lab.labtimesheet.feature.task.model.dto;
import com.lab.labtimesheet.feature.task.model.TaskStatus;
import java.time.Instant;
import java.time.LocalDate;
@@ -7,6 +8,7 @@ public record TaskView(
long id,
long projectId,
long assigneeMembershipId,
String assigneeName,
String title,
String description,
TaskStatus status,
@@ -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) {}
}
@@ -1,10 +0,0 @@
package com.lab.labtimesheet.tasks;
import java.util.List;
public record TaskDetails(TaskView task, List<TaskCommentView> comments) {
public TaskDetails {
comments = List.copyOf(comments);
}
}
@@ -1,438 +0,0 @@
package com.lab.labtimesheet.tasks;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class TaskService {
private final JdbcClient jdbc;
public TaskService(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@Transactional
public TaskView create(String actorEmail, CreateTaskCommand command) {
String title = requireTitle(command.title());
Actor actor = requireActiveActor(actorEmail);
Project project = requireOpenProject(command.projectId());
long actorMembershipId = requireActorMembership(project.id(), actor.id());
requireAssigneeMembership(project.id(), command.assigneeMembershipId());
if (actorMembershipId != command.assigneeMembershipId()
&& !isCurrentLeader(project.id(), actorMembershipId)) {
throw new TaskNotFoundException();
}
validateDueDate(project, command.dueDate());
long taskId = jdbc.sql("""
insert into tasks
(project_id, assignee_membership_id, title, description, due_date,
created_by_membership_id, assigned_by_membership_id)
values (:projectId, :assigneeId, :title, :description, :dueDate,
:actorMembershipId, :actorMembershipId)
returning id
""")
.param("projectId", project.id())
.param("assigneeId", command.assigneeMembershipId())
.param("title", title)
.param("description", trimToNull(command.description()))
.param("dueDate", command.dueDate())
.param("actorMembershipId", actorMembershipId)
.query(Long.class)
.single();
return task(taskId);
}
@Transactional
public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) {
Actor actor = requireActiveActor(actorEmail);
TaskView task = jdbc.sql("""
select t.id, t.project_id, t.assignee_membership_id, t.title, t.description,
t.status, t.due_date, t.created_by_membership_id,
t.assigned_by_membership_id, t.assigned_at, t.created_at
from tasks t
join projects p on p.id = t.project_id
join project_memberships m on m.id = t.assignee_membership_id
and m.project_id = t.project_id
where t.id = :taskId
and t.project_id = :projectId
and t.deleted_at is null
and p.status = 'ACTIVE'
and m.intern_user_id = :actorId
and m.left_at is null
""")
.param("taskId", taskId)
.param("projectId", projectId)
.param("actorId", actor.id())
.query(TaskService::mapTask)
.optional()
.orElseThrow(TaskNotFoundException::new);
if (!task.status().canTransitionTo(target)) {
throw new TaskValidationException("Task status transition is not allowed");
}
jdbc.sql("update tasks set status = :status, updated_at = current_timestamp where id = :taskId")
.param("status", target.name())
.param("taskId", taskId)
.update();
return task(taskId);
}
@Transactional
public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) {
String normalizedBody = requireCommentBody(body);
Actor actor = requireActiveActor(actorEmail);
ProjectAccess project = requireProjectAccess(projectId);
if ("COMPLETED".equals(project.status()) || !taskExists(projectId, taskId)) {
throw new TaskNotFoundException();
}
if (actor.id() != project.mentorUserId() && !hasActiveMembership(projectId, actor.id())) {
throw new TaskNotFoundException();
}
long commentId = jdbc.sql("""
insert into task_comments (task_id, author_user_id, body)
values (:taskId, :actorId, :body)
returning id
""")
.param("taskId", taskId)
.param("actorId", actor.id())
.param("body", normalizedBody)
.query(Long.class)
.single();
return comment(commentId);
}
@Transactional(readOnly = true)
public TaskListView list(String actorEmail, long projectId) {
requireViewAccess(actorEmail, projectId);
List<TaskView> tasks = jdbc.sql("""
select id, project_id, assignee_membership_id, title, description, status,
due_date, created_by_membership_id, assigned_by_membership_id,
assigned_at, created_at
from tasks
where project_id = :projectId and deleted_at is null
order by id
""")
.param("projectId", projectId)
.query(TaskService::mapTask)
.list();
return new TaskListView(tasks, TaskProgress.from(tasks.stream().map(TaskView::status).toList()));
}
@Transactional(readOnly = true)
public TaskDetails details(String actorEmail, long projectId, long taskId) {
requireViewAccess(actorEmail, projectId);
TaskView task = jdbc.sql("""
select id, project_id, assignee_membership_id, title, description, status,
due_date, created_by_membership_id, assigned_by_membership_id,
assigned_at, created_at
from tasks
where id = :taskId and project_id = :projectId and deleted_at is null
""")
.param("taskId", taskId)
.param("projectId", projectId)
.query(TaskService::mapTask)
.optional()
.orElseThrow(TaskNotFoundException::new);
List<TaskCommentView> comments = jdbc.sql("""
select id, task_id, author_user_id, body, created_at
from task_comments
where task_id = :taskId
order by created_at, id
""")
.param("taskId", taskId)
.query(TaskService::mapComment)
.list();
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())) {
throw new TaskNotFoundException();
}
return actor;
}
private Actor requireReadableActor(String email) {
return jdbc.sql("""
select u.id, u.global_role, i.internship_status
from app_users u
left join intern_profiles i on i.user_id = u.id
where lower(btrim(u.email)) = lower(btrim(:email))
and u.account_status = 'ACTIVE'
""")
.param("email", email)
.query((rs, rowNum) -> new Actor(
rs.getLong("id"),
rs.getString("global_role"),
rs.getString("internship_status")))
.optional()
.orElseThrow(TaskNotFoundException::new);
}
private Project requireOpenProject(long projectId) {
return jdbc.sql("""
select id, status, start_date, end_date
from projects
where id = :projectId and status in ('PLANNED', 'ACTIVE')
""")
.param("projectId", projectId)
.query((rs, rowNum) -> new Project(
rs.getLong("id"),
rs.getString("status"),
rs.getObject("start_date", LocalDate.class),
rs.getObject("end_date", LocalDate.class)))
.optional()
.orElseThrow(TaskNotFoundException::new);
}
private long requireActorMembership(long projectId, long userId) {
return jdbc.sql("""
select m.id
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.intern_user_id = :userId
and m.left_at is null
and u.account_status = 'ACTIVE'
and i.internship_status = 'ACTIVE'
""")
.param("projectId", projectId)
.param("userId", userId)
.query(Long.class)
.optional()
.orElseThrow(TaskNotFoundException::new);
}
private void requireAssigneeMembership(long projectId, long membershipId) {
boolean exists = jdbc.sql("""
select exists (
select 1
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.id = :membershipId
and m.project_id = :projectId
and m.left_at is null
and u.account_status = 'ACTIVE'
and i.internship_status = 'ACTIVE'
)
""")
.param("membershipId", membershipId)
.param("projectId", projectId)
.query(Boolean.class)
.single();
if (!exists) {
throw new TaskNotFoundException();
}
}
private boolean isCurrentLeader(long projectId, long membershipId) {
return jdbc.sql("""
select exists (
select 1 from project_leadership_terms
where project_id = :projectId
and membership_id = :membershipId
and ended_at is null
)
""")
.param("projectId", projectId)
.param("membershipId", membershipId)
.query(Boolean.class)
.single();
}
private void requireViewAccess(String actorEmail, long projectId) {
Actor actor = requireReadableActor(actorEmail);
ProjectAccess project = requireProjectAccess(projectId);
if ("ADMIN".equals(actor.role()) || actor.id() == project.mentorUserId()) {
return;
}
boolean member = jdbc.sql("""
select exists (
select 1 from project_memberships
where project_id = :projectId
and intern_user_id = :actorId
and (:completed or left_at is null)
)
""")
.param("projectId", projectId)
.param("actorId", actor.id())
.param("completed", "COMPLETED".equals(project.status()))
.query(Boolean.class)
.single();
if (!member) {
throw new TaskNotFoundException();
}
}
private ProjectAccess requireProjectAccess(long projectId) {
return jdbc.sql("select status, mentor_user_id from projects where id = :projectId")
.param("projectId", projectId)
.query((rs, rowNum) -> new ProjectAccess(
rs.getString("status"), rs.getLong("mentor_user_id")))
.optional()
.orElseThrow(TaskNotFoundException::new);
}
private boolean hasActiveMembership(long projectId, long actorId) {
return jdbc.sql("""
select exists (
select 1 from project_memberships
where project_id = :projectId
and intern_user_id = :actorId
and left_at is null
)
""")
.param("projectId", projectId)
.param("actorId", actorId)
.query(Boolean.class)
.single();
}
private boolean taskExists(long projectId, long taskId) {
return jdbc.sql("""
select exists (
select 1 from tasks
where id = :taskId and project_id = :projectId and deleted_at is null
)
""")
.param("taskId", taskId)
.param("projectId", projectId)
.query(Boolean.class)
.single();
}
private void validateDueDate(Project 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");
}
boolean dayOff = jdbc.sql("""
select exists (
select 1 from global_calendar_events
where calendar_date = :dueDate and is_day_off = true
)
""")
.param("dueDate", dueDate)
.query(Boolean.class)
.single();
if (dayOff) {
throw new TaskValidationException("Due date cannot be a current global day off");
}
}
private TaskView task(long taskId) {
return jdbc.sql("""
select id, project_id, assignee_membership_id, title, description, status,
due_date, created_by_membership_id, assigned_by_membership_id,
assigned_at, created_at
from tasks
where id = :taskId
""")
.param("taskId", taskId)
.query(TaskService::mapTask)
.single();
}
private TaskCommentView comment(long commentId) {
return jdbc.sql("""
select id, task_id, author_user_id, body, created_at
from task_comments
where id = :commentId
""")
.param("commentId", commentId)
.query(TaskService::mapComment)
.single();
}
private static TaskView mapTask(ResultSet rs, int rowNum) throws SQLException {
return new TaskView(
rs.getLong("id"),
rs.getLong("project_id"),
rs.getLong("assignee_membership_id"),
rs.getString("title"),
rs.getString("description"),
TaskStatus.valueOf(rs.getString("status")),
rs.getObject("due_date", LocalDate.class),
rs.getLong("created_by_membership_id"),
rs.getLong("assigned_by_membership_id"),
rs.getTimestamp("assigned_at").toInstant(),
rs.getTimestamp("created_at").toInstant());
}
private static TaskCommentView mapComment(ResultSet rs, int rowNum) throws SQLException {
return new TaskCommentView(
rs.getLong("id"),
rs.getLong("task_id"),
rs.getLong("author_user_id"),
rs.getString("body"),
rs.getTimestamp("created_at").toInstant());
}
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 trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private static String requireCommentBody(String body) {
String trimmed = trimToNull(body);
if (trimmed == null) {
throw new TaskValidationException("Comment body is required");
}
return trimmed;
}
private record Actor(long id, String role, String internshipStatus) {}
private record Project(long id, String status, LocalDate startDate, LocalDate endDate) {}
private record ProjectAccess(String status, long mentorUserId) {}
}
@@ -9,10 +9,11 @@
<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 method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/status(projectId=${projectId},taskId=${details.task.id})}">
<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>
@@ -25,7 +26,7 @@
<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})}">
<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>
+3 -2
View File
@@ -15,13 +15,14 @@
<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>
<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">Status</th><th scope="col">Due date</th></tr></thead>
<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>
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks;
package com.lab.labtimesheet.feature.task.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -15,6 +15,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import com.lab.labtimesheet.feature.task.exception.TaskNotFoundException;
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.service.TaskService;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
@@ -47,12 +57,25 @@ class TaskControllerTest {
@Test
void emptyTaskListRendersNotApplicableProgress() throws Exception {
given(taskService.list(ACTOR_EMAIL, 10L))
.willReturn(new TaskListView(List.of(), TaskProgress.from(List.of())));
.willReturn(new TaskListView(List.of(), TaskProgress.from(List.of()), false));
mockMvc.perform(get("/projects/10/tasks").with(user(ACTOR_EMAIL)))
.andExpect(status().isOk())
.andExpect(view().name("tasks/list"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("N/A")));
.andExpect(content().string(org.hamcrest.Matchers.containsString("N/A")))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("Create Task"))));
}
@Test
void taskListShowsAssigneeAndCreateActionOnlyWhenAllowed() throws Exception {
given(taskService.list(ACTOR_EMAIL, 10L)).willReturn(new TaskListView(
List.of(task(25L)), TaskProgress.from(List.of(TaskStatus.TODO)), true));
mockMvc.perform(get("/projects/10/tasks").with(user(ACTOR_EMAIL)))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("Member Name")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Create Task")));
}
@Test
@@ -123,10 +146,35 @@ class TaskControllerTest {
.andExpect(redirectedUrl("/projects/10/tasks/25"));
}
@Test
void taskDetailsHideUnavailableActionsAndShowAssignee() throws Exception {
given(taskService.details(ACTOR_EMAIL, 10L, 25L))
.willReturn(new TaskDetails(task(25L), List.of(), false, false));
mockMvc.perform(get("/projects/10/tasks/25").with(user(ACTOR_EMAIL)))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("Member Name")))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("Change status"))))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("Add comment"))));
}
@Test
void taskDetailsRenderAvailableActions() throws Exception {
given(taskService.details(ACTOR_EMAIL, 10L, 25L))
.willReturn(new TaskDetails(task(25L), List.of(), true, true));
mockMvc.perform(get("/projects/10/tasks/25").with(user(ACTOR_EMAIL)))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("Change status")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Add comment")));
}
private static TaskView task(long id) {
Instant instant = Instant.parse("2026-08-14T10:00:00Z");
return new TaskView(
id, 10L, 7L, "Draft", "Notes", TaskStatus.TODO,
id, 10L, 7L, "Member Name", "Draft", "Notes", TaskStatus.TODO,
LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant);
}
}
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks;
package com.lab.labtimesheet.feature.task.model;
import static org.assertj.core.api.Assertions.assertThat;
@@ -0,0 +1,70 @@
package com.lab.labtimesheet.feature.task.repository;
import static org.assertj.core.api.Assertions.assertThat;
import com.lab.labtimesheet.feature.task.model.entity.Task;
import com.lab.labtimesheet.feature.task.model.entity.TaskComment;
import com.lab.labtimesheet.feature.task.service.TaskService;
import jakarta.persistence.Entity;
import jakarta.persistence.LockModeType;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.jdbc.core.simple.JdbcClient;
class TaskPersistenceStructureTest {
@Test
void taskPersistenceUsesJpaEntitiesAndSpringDataRepositories() {
assertThat(Task.class).hasAnnotation(Entity.class);
assertThat(TaskComment.class).hasAnnotation(Entity.class);
assertThat(JpaRepository.class).isAssignableFrom(TaskRepository.class);
assertThat(JpaRepository.class).isAssignableFrom(TaskCommentRepository.class);
}
@Test
void taskServiceUsesTaskRepositoriesInsteadOfDirectJdbcAccess() {
var constructorTypes = Arrays.stream(TaskService.class.getDeclaredConstructors())
.flatMap(constructor -> Arrays.stream(constructor.getParameterTypes()))
.toList();
assertThat(constructorTypes)
.contains(TaskRepository.class, TaskCommentRepository.class)
.doesNotContain(JdbcClient.class);
}
@Test
void taskMutationLookupUsesAPessimisticWriteLock() throws NoSuchMethodException {
var method = TaskRepository.class.getMethod(
"findLockedByIdAndProjectIdAndDeletedAtIsNull", long.class, long.class);
Lock lock = method.getAnnotation(Lock.class);
assertThat(lock).isNotNull();
assertThat(lock.value()).isEqualTo(LockModeType.PESSIMISTIC_WRITE);
}
@Test
void taskBusinessCodeContainsNoDirectJdbcOrSqlImports() throws IOException {
Path taskSource = Path.of("src/main/java/com/lab/labtimesheet/feature/task");
try (var sources = Files.walk(taskSource)) {
var directSqlSources = sources
.filter(path -> path.toString().endsWith(".java"))
.filter(path -> {
try {
String source = Files.readString(path);
return source.contains("import org.springframework.jdbc")
|| source.contains("import java.sql");
} catch (IOException exception) {
throw new IllegalStateException("Cannot inspect " + path, exception);
}
})
.toList();
assertThat(directSqlSources).isEmpty();
}
}
}
@@ -1,19 +1,21 @@
package com.lab.labtimesheet;
package com.lab.labtimesheet.feature.task.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.lab.labtimesheet.tasks.CreateTaskCommand;
import com.lab.labtimesheet.tasks.TaskCommentView;
import com.lab.labtimesheet.tasks.TaskDetails;
import com.lab.labtimesheet.tasks.TaskListView;
import com.lab.labtimesheet.tasks.TaskAssigneeChoice;
import com.lab.labtimesheet.tasks.TaskNotFoundException;
import com.lab.labtimesheet.tasks.TaskService;
import com.lab.labtimesheet.tasks.TaskStatus;
import com.lab.labtimesheet.tasks.TaskValidationException;
import com.lab.labtimesheet.tasks.TaskView;
import com.lab.labtimesheet.config.TestcontainersConfiguration;
import com.lab.labtimesheet.feature.task.exception.TaskNotFoundException;
import com.lab.labtimesheet.feature.task.exception.TaskValidationException;
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 jakarta.persistence.EntityManager;
import java.time.LocalDate;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -35,9 +37,18 @@ class TaskCreationIntegrationTest {
@Autowired
private JdbcClient jdbc;
@Autowired
private EntityManager entityManager;
@Autowired
private TaskService taskService;
@Autowired
private TaskQueryService taskQueries;
@Autowired
private TaskDashboardService taskDashboard;
private long projectId;
private long leaderMembershipId;
private long memberMembershipId;
@@ -73,6 +84,7 @@ class TaskCreationIntegrationTest {
assertThat(task.creatorMembershipId()).isEqualTo(memberMembershipId);
assertThat(task.assignerMembershipId()).isEqualTo(memberMembershipId);
assertThat(task.assigneeMembershipId()).isEqualTo(memberMembershipId);
assertThat(task.assigneeName()).isEqualTo("member@example.test");
assertThatThrownBy(() -> taskService.create(
"member@example.test",
@@ -208,6 +220,9 @@ class TaskCreationIntegrationTest {
assertThat(list.tasks()).extracting(TaskView::title)
.containsExactly("Todo", "Active", "Blocked", "Done");
assertThat(list.tasks()).extracting(TaskView::assigneeName)
.containsOnly("member@example.test");
assertThat(list.canCreate()).isTrue();
assertThat(list.progress().total()).isEqualTo(4);
assertThat(list.progress().count(TaskStatus.TODO)).isEqualTo(1);
assertThat(list.progress().count(TaskStatus.IN_PROGRESS)).isEqualTo(1);
@@ -215,8 +230,24 @@ class TaskCreationIntegrationTest {
assertThat(list.progress().count(TaskStatus.DONE)).isEqualTo(1);
assertThat(list.progress().completionPercentage()).hasValue(25.0);
assertThat(details.comments()).extracting(TaskCommentView::body).containsExactly("Visible comment");
assertThat(details.task().assigneeName()).isEqualTo("member@example.test");
assertThat(details.canChangeStatus()).isFalse();
assertThat(details.canComment()).isTrue();
long emptyProjectId = insertProject(userId("mentor@example.test"), "PLANNED");
long emptyLeaderMembershipId = insertMembership(
emptyProjectId,
userId("leader@example.test"),
userId("mentor@example.test"));
jdbc.sql("""
insert into project_leadership_terms
(project_id, membership_id, appointed_by_mentor_user_id)
values (:projectId, :membershipId, :mentorId)
""")
.param("projectId", emptyProjectId)
.param("membershipId", emptyLeaderMembershipId)
.param("mentorId", userId("mentor@example.test"))
.update();
assertThat(taskService.list("mentor@example.test", emptyProjectId).progress().completionPercentage())
.isEmpty();
}
@@ -234,6 +265,60 @@ class TaskCreationIntegrationTest {
.isInstanceOf(TaskNotFoundException.class);
}
@Test
void formerMemberReadsOnlyCompletedProjectTaskHistory() {
TaskView task = createMemberTask("Historical task");
closeMembership(memberMembershipId);
assertThatThrownBy(() -> taskService.list("member@example.test", projectId))
.isInstanceOf(TaskNotFoundException.class);
assertThatThrownBy(() -> taskService.details("member@example.test", projectId, task.id()))
.isInstanceOf(TaskNotFoundException.class);
completeProject();
assertThat(currentLeadershipCount()).isZero();
assertThat(currentMembershipCount()).isZero();
assertThat(taskService.list("member@example.test", projectId).tasks())
.extracting(TaskView::title)
.containsExactly("Historical task");
assertThat(taskService.details("member@example.test", projectId, task.id()).task().title())
.isEqualTo("Historical task");
}
@Test
void viewCapabilitiesFollowCurrentMembershipAssignmentAndProjectLifecycle() {
TaskView task = createMemberTask("Capability task");
assertThat(taskService.list("member@example.test", projectId).canCreate()).isTrue();
assertThat(taskService.list("mentor@example.test", projectId).canCreate()).isFalse();
assertThat(taskService.details("member@example.test", projectId, task.id()))
.satisfies(details -> {
assertThat(details.canChangeStatus()).isFalse();
assertThat(details.canComment()).isTrue();
});
activateProject();
assertThat(taskService.details("member@example.test", projectId, task.id()))
.satisfies(details -> {
assertThat(details.canChangeStatus()).isTrue();
assertThat(details.canComment()).isTrue();
});
assertThat(taskService.details("leader@example.test", projectId, task.id()).canChangeStatus())
.isFalse();
completeProject();
assertThat(taskService.list("member@example.test", projectId).canCreate()).isFalse();
assertThat(taskService.details("member@example.test", projectId, task.id()))
.satisfies(details -> {
assertThat(details.canChangeStatus()).isFalse();
assertThat(details.canComment()).isFalse();
});
}
@Test
void createFormChoicesAreSelfOnlyForMembersAndAllActiveMembersForLeader() {
assertThat(taskService.assignmentChoices("member@example.test", projectId))
@@ -244,6 +329,45 @@ class TaskCreationIntegrationTest {
.containsExactly(leaderMembershipId, memberMembershipId);
}
@Test
void projectActivationQueryCountsOnlyCurrentTasksOutsideActiveMemberships() {
createMemberTask("Member task");
TaskView leaderTask = taskService.create(
"leader@example.test",
new CreateTaskCommand(projectId, leaderMembershipId, "Leader task", null, null));
assertThat(taskQueries.countCurrentTasksAssignedOutside(projectId, Set.of(memberMembershipId)))
.isEqualTo(1L);
softDelete(leaderTask.id());
assertThat(taskQueries.countCurrentTasksAssignedOutside(projectId, Set.of(memberMembershipId)))
.isZero();
}
@Test
void internDashboardCountsAssignmentsAndOrdersFivePriorityTasks() {
createMemberTask("Late");
taskService.create("member@example.test", new CreateTaskCommand(
projectId, memberMembershipId, "No due date", null, null));
taskService.create("member@example.test", new CreateTaskCommand(
projectId, memberMembershipId, "Earliest A", null, LocalDate.of(2026, 8, 10)));
taskService.create("member@example.test", new CreateTaskCommand(
projectId, memberMembershipId, "Earliest B", null, LocalDate.of(2026, 8, 10)));
taskService.create("member@example.test", new CreateTaskCommand(
projectId, memberMembershipId, "Middle", null, LocalDate.of(2026, 8, 11)));
taskService.create("member@example.test", new CreateTaskCommand(
projectId, memberMembershipId, "Next", null, LocalDate.of(2026, 8, 13)));
setDueDateForTitle("Late", LocalDate.of(2026, 8, 12));
activateProject();
var dashboard = taskDashboard.dashboard("member@example.test");
assertThat(dashboard.assignedTaskCount()).isEqualTo(6L);
assertThat(dashboard.priorityTasks())
.extracting(task -> task.title())
.containsExactly("Earliest A", "Earliest B", "Middle", "Late", "Next");
}
private long insertUser(String email, String role) {
return jdbc.sql("""
insert into app_users
@@ -328,6 +452,26 @@ class TaskCreationIntegrationTest {
return jdbc.sql("select count(*) from task_comments").query(Long.class).single();
}
private long currentLeadershipCount() {
return jdbc.sql("""
select count(*) from project_leadership_terms
where project_id = :projectId and ended_at is null
""")
.param("projectId", projectId)
.query(Long.class)
.single();
}
private long currentMembershipCount() {
return jdbc.sql("""
select count(*) from project_memberships
where project_id = :projectId and left_at is null
""")
.param("projectId", projectId)
.query(Long.class)
.single();
}
private TaskView createMemberTask(String title) {
return taskService.create(
"member@example.test",
@@ -338,9 +482,34 @@ class TaskCreationIntegrationTest {
jdbc.sql("update projects set status = 'ACTIVE', activated_at = current_timestamp where id = :id")
.param("id", projectId)
.update();
entityManager.clear();
}
private void completeProject() {
long mentorId = userId("mentor@example.test");
jdbc.sql("""
update tasks
set status = 'DONE'
where project_id = :id and deleted_at is null
""")
.param("id", projectId)
.update();
jdbc.sql("""
update project_leadership_terms
set ended_at = started_at + interval '1 second', ended_by_mentor_user_id = :mentorId
where project_id = :id and ended_at is null
""")
.param("id", projectId)
.param("mentorId", mentorId)
.update();
jdbc.sql("""
update project_memberships
set left_at = joined_at + interval '1 second', removed_by_mentor_user_id = :mentorId
where project_id = :id and left_at is null
""")
.param("id", projectId)
.param("mentorId", mentorId)
.update();
jdbc.sql("""
update projects
set status = 'COMPLETED', activated_at = current_timestamp,
@@ -349,6 +518,7 @@ class TaskCreationIntegrationTest {
""")
.param("id", projectId)
.update();
entityManager.clear();
}
private void setStatus(long taskId, TaskStatus status) {
@@ -356,6 +526,7 @@ class TaskCreationIntegrationTest {
.param("status", status.name())
.param("id", taskId)
.update();
entityManager.clear();
}
private void softDelete(long taskId) {
@@ -367,5 +538,26 @@ class TaskCreationIntegrationTest {
.param("membershipId", memberMembershipId)
.param("id", taskId)
.update();
entityManager.clear();
}
private void closeMembership(long membershipId) {
jdbc.sql("""
update project_memberships
set left_at = joined_at + interval '1 second', removed_by_mentor_user_id = :mentorId
where id = :id
""")
.param("mentorId", userId("mentor@example.test"))
.param("id", membershipId)
.update();
entityManager.clear();
}
private void setDueDateForTitle(String title, LocalDate dueDate) {
jdbc.sql("update tasks set due_date = :dueDate where title = :title")
.param("dueDate", dueDate)
.param("title", title)
.update();
entityManager.clear();
}
}
@@ -0,0 +1,108 @@
package com.lab.labtimesheet.feature.task.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
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.task.model.TaskStatus;
import com.lab.labtimesheet.feature.task.model.entity.Task;
import com.lab.labtimesheet.feature.task.repository.TaskRepository;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Pageable;
@ExtendWith(MockitoExtension.class)
class TaskDashboardServiceTest {
@Mock
private TaskRepository tasks;
@Mock
private ProjectQueryService projects;
@InjectMocks
private TaskDashboardService dashboardService;
@Test
void mentorDashboardCountsBlockedTasksOnlyInOwnedActiveProjects() {
given(projects.authenticatedActor("mentor@example.test"))
.willReturn(new ProjectActorView(3L, "MENTOR"));
given(projects.listVisible(3L)).willReturn(List.of(
summary(10L, "Active", "ACTIVE"),
summary(11L, "Planned", "PLANNED")));
given(tasks.countByProjectIdInAndStatusAndDeletedAtIsNull(List.of(10L), TaskStatus.BLOCKED))
.willReturn(4L);
var dashboard = dashboardService.dashboard("mentor@example.test");
assertThat(dashboard.blockedTaskCount()).isEqualTo(4L);
assertThat(dashboard.assignedTaskCount()).isZero();
assertThat(dashboard.priorityTasks()).isEmpty();
}
@Test
void internDashboardExcludesFormerMembershipsAndReturnsFiveDueDatePriorities() {
given(projects.authenticatedActor("intern@example.test"))
.willReturn(new ProjectActorView(5L, "INTERN"));
given(projects.listVisible(5L)).willReturn(List.of(
summary(10L, "Current", "ACTIVE"),
summary(11L, "Former", "ACTIVE"),
summary(12L, "Completed", "COMPLETED")));
given(projects.taskContext(5L, 10L)).willReturn(context(
10L, List.of(new ProjectTaskMemberView(70L, 5L, "Intern"))));
given(projects.taskContext(5L, 11L)).willReturn(context(11L, List.of()));
given(tasks.countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull(
List.of(10L), List.of(70L)))
.willReturn(6L);
var priority = new Task(
10L,
70L,
"Due first",
null,
LocalDate.of(2026, 8, 16),
70L,
Instant.parse("2026-08-15T00:00:00Z"));
given(tasks.findPriorityTasks(
org.mockito.ArgumentMatchers.eq(List.of(10L)),
org.mockito.ArgumentMatchers.eq(List.of(70L)),
org.mockito.ArgumentMatchers.any(Pageable.class)))
.willReturn(List.of(priority));
var dashboard = dashboardService.dashboard("intern@example.test");
assertThat(dashboard.blockedTaskCount()).isZero();
assertThat(dashboard.assignedTaskCount()).isEqualTo(6L);
assertThat(dashboard.priorityTasks()).singleElement().satisfies(task -> {
assertThat(task.title()).isEqualTo("Due first");
assertThat(task.projectName()).isEqualTo("Current");
assertThat(task.status()).isEqualTo(TaskStatus.TODO);
assertThat(task.dueDate()).isEqualTo(LocalDate.of(2026, 8, 16));
});
}
private static ProjectSummary summary(long id, String name, String status) {
return new ProjectSummary(
id, name, status, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));
}
private static ProjectTaskContext context(long id, List<ProjectTaskMemberView> members) {
return new ProjectTaskContext(
id,
3L,
"ACTIVE",
LocalDate.of(2026, 8, 1),
LocalDate.of(2026, 8, 31),
null,
members);
}
}
@@ -0,0 +1,135 @@
package com.lab.labtimesheet.feature.task.service;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService;
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
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.model.TaskStatus;
import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
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.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class TaskMutationBoundaryTest {
private static final Instant NOW = Instant.parse("2026-08-15T00:00:00Z");
@Mock private TaskRepository tasks;
@Mock private TaskCommentRepository comments;
@Mock private ProjectQueryService projectQueries;
@Mock private ProjectService projectMutations;
@Mock private CalendarApplicationService calendar;
private TaskService service;
private ProjectTaskContext context;
@BeforeEach
void setUp() {
service = new TaskService(
tasks,
comments,
projectQueries,
projectMutations,
calendar,
Clock.fixed(NOW, ZoneOffset.UTC));
context = new ProjectTaskContext(
10L,
3L,
"ACTIVE",
LocalDate.of(2026, 8, 1),
LocalDate.of(2026, 8, 31),
70L,
List.of(new ProjectTaskMemberView(70L, 5L, "Member")));
when(projectQueries.authenticatedActor("member@example.test"))
.thenReturn(new ProjectActorView(5L, "INTERN"));
when(projectMutations.taskMutationContext(5L, 10L)).thenReturn(context);
}
@Test
void createLocksAndRechecksProjectBeforeWriting() {
Task saved = taskForView(TaskStatus.TODO);
when(tasks.saveAndFlush(any(Task.class))).thenReturn(saved);
service.create(
"member@example.test",
new CreateTaskCommand(10L, 70L, "Task", null, null));
InOrder order = inOrder(projectMutations, tasks);
order.verify(projectMutations).taskMutationContext(5L, 10L);
order.verify(tasks).saveAndFlush(any(Task.class));
verify(projectQueries, never()).taskContext(5L, 10L);
}
@Test
void statusChangeLocksProjectThenTaskBeforeMutation() {
Task task = taskForView(TaskStatus.TODO);
when(tasks.findLockedByIdAndProjectIdAndDeletedAtIsNull(25L, 10L))
.thenReturn(Optional.of(task));
when(tasks.saveAndFlush(task)).thenReturn(task);
service.changeStatus("member@example.test", 10L, 25L, TaskStatus.IN_PROGRESS);
InOrder order = inOrder(projectMutations, tasks, task);
order.verify(projectMutations).taskMutationContext(5L, 10L);
order.verify(tasks).findLockedByIdAndProjectIdAndDeletedAtIsNull(25L, 10L);
order.verify(task).changeStatus(TaskStatus.IN_PROGRESS, NOW);
}
@Test
void commentLocksProjectThenTaskBeforeWriting() {
Task task = mock(Task.class);
TaskComment saved = mock(TaskComment.class);
when(tasks.findLockedByIdAndProjectIdAndDeletedAtIsNull(25L, 10L))
.thenReturn(Optional.of(task));
when(comments.saveAndFlush(any(TaskComment.class))).thenReturn(saved);
when(saved.getId()).thenReturn(4L);
when(saved.getTaskId()).thenReturn(25L);
when(saved.getAuthorUserId()).thenReturn(5L);
when(saved.getBody()).thenReturn("Comment");
when(saved.getCreatedAt()).thenReturn(NOW);
service.addComment("member@example.test", 10L, 25L, "Comment");
InOrder order = inOrder(projectMutations, tasks, comments);
order.verify(projectMutations).taskMutationContext(5L, 10L);
order.verify(tasks).findLockedByIdAndProjectIdAndDeletedAtIsNull(25L, 10L);
order.verify(comments).saveAndFlush(any(TaskComment.class));
}
private static Task taskForView(TaskStatus status) {
Task task = mock(Task.class);
when(task.getId()).thenReturn(25L);
when(task.getProjectId()).thenReturn(10L);
when(task.getAssigneeMembershipId()).thenReturn(70L);
when(task.getTitle()).thenReturn("Task");
when(task.getStatus()).thenReturn(status);
when(task.getCreatorMembershipId()).thenReturn(70L);
when(task.getAssignerMembershipId()).thenReturn(70L);
when(task.getAssignedAt()).thenReturn(NOW);
when(task.getCreatedAt()).thenReturn(NOW);
return task;
}
}
@@ -0,0 +1,41 @@
package com.lab.labtimesheet.feature.task.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import com.lab.labtimesheet.feature.task.repository.TaskRepository;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class TaskQueryServiceTest {
@Mock
private TaskRepository tasks;
@InjectMocks
private TaskQueryService taskQueries;
@Test
void countsEveryCurrentTaskWhenProjectHasNoActiveMemberships() {
given(tasks.countByProjectIdAndDeletedAtIsNull(42L)).willReturn(3L);
assertThat(taskQueries.countCurrentTasksAssignedOutside(42L, Set.of())).isEqualTo(3L);
verify(tasks).countByProjectIdAndDeletedAtIsNull(42L);
}
@Test
void countsCurrentTasksWhoseAssigneeIsOutsideActiveMemberships() {
given(tasks.countCurrentTasksAssignedOutside(42L, Set.of(7L, 9L))).willReturn(2L);
assertThat(taskQueries.countCurrentTasksAssignedOutside(42L, Set.of(7L, 9L))).isEqualTo(2L);
verify(tasks).countCurrentTasksAssignedOutside(42L, Set.of(7L, 9L));
}
}