feat(task): adopt JPA feature boundaries
This commit is contained in:
+8
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
package com.lab.labtimesheet.feature.task.model;
|
||||
|
||||
public enum TaskStatus {
|
||||
TODO,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
package com.lab.labtimesheet.feature.task.model.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
package com.lab.labtimesheet.feature.task.model.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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) {}
|
||||
+3
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user