From fb0ed7f12c9d89235c102b67f2b13f786011c9ee Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:17:26 +0700 Subject: [PATCH] fix(task): harden task forms and document contracts --- .../task/controller/TaskController.java | 46 +++++++-- .../task/exception/TaskNotFoundException.java | 7 ++ .../exception/TaskValidationException.java | 12 +++ .../feature/task/model/TaskProgress.java | 30 ++++++ .../feature/task/model/TaskStatus.java | 17 ++++ .../task/model/dto/CreateTaskCommand.java | 9 ++ .../task/model/dto/TaskAssigneeChoice.java | 6 ++ .../task/model/dto/TaskCommentView.java | 9 ++ .../task/model/dto/TaskCreateForm.java | 8 ++ .../task/model/dto/TaskDashboardView.java | 8 ++ .../feature/task/model/dto/TaskDetails.java | 9 ++ .../feature/task/model/dto/TaskListView.java | 8 ++ .../task/model/dto/TaskPriorityView.java | 8 ++ .../feature/task/model/dto/TaskView.java | 16 +++ .../feature/task/model/entity/Task.java | 86 ++++++++++++++++ .../task/model/entity/TaskComment.java | 40 ++++++++ .../repository/TaskCommentRepository.java | 7 ++ .../task/repository/TaskRepository.java | 68 +++++++++++++ .../task/service/TaskDashboardService.java | 23 +++++ .../task/service/TaskQueryService.java | 20 ++++ .../feature/task/service/TaskService.java | 99 +++++++++++++++++++ src/main/resources/templates/tasks/form.html | 1 + .../task/controller/TaskControllerTest.java | 70 ++++++++++++- 23 files changed, 596 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java b/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java index 6838216..572a623 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java @@ -1,13 +1,16 @@ package com.lab.labtimesheet.feature.task.controller; +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.TaskCreateForm; +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 jakarta.validation.Valid; +import java.util.Arrays; import java.util.Locale; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; @@ -19,11 +22,24 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; +/** + * Serves authenticated Task list, create, detail, status, and comment pages. + * + *
The controller delegates record visibility and every mutation authorization decision to + * {@link TaskService}. A denied or guessed Project/Task identifier therefore retains the service's + * non-disclosing HTTP 404 contract. Bean and due-date validation failures return the create form; + * successful mutations use redirects to prevent duplicate submissions. + */ @Controller public class TaskController { private final TaskService taskService; + /** + * Creates the MVC adapter for the Task application service. + * + * @param taskService authorized Task use cases + */ public TaskController(TaskService taskService) { this.taskService = taskService; } @@ -55,14 +71,21 @@ public class TaskController { populateForm(authentication.getName(), projectId, model); return "tasks/form"; } - TaskView task = taskService.create( - authentication.getName(), - new CreateTaskCommand( - projectId, - form.assigneeMembershipId(), - form.title(), - form.description(), - form.dueDate())); + TaskView task; + try { + task = taskService.create( + authentication.getName(), + new CreateTaskCommand( + projectId, + form.assigneeMembershipId(), + form.title(), + form.description(), + form.dueDate())); + } catch (TaskValidationException exception) { + bindingResult.rejectValue("dueDate", "task.dueDate", exception.getMessage()); + populateForm(authentication.getName(), projectId, model); + return "tasks/form"; + } return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id()); } @@ -72,9 +95,12 @@ public class TaskController { @PathVariable long projectId, @PathVariable long taskId, Model model) { + TaskDetails details = taskService.details(authentication.getName(), projectId, taskId); model.addAttribute("projectId", projectId); - model.addAttribute("details", taskService.details(authentication.getName(), projectId, taskId)); - model.addAttribute("statuses", TaskStatus.values()); + model.addAttribute("details", details); + model.addAttribute("statuses", Arrays.stream(TaskStatus.values()) + .filter(details.task().status()::canTransitionTo) + .toList()); return "tasks/detail"; } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java index 00a77bc..db00e32 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java @@ -3,9 +3,16 @@ package com.lab.labtimesheet.feature.task.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; +/** + * Signals a non-disclosing Task or Project lookup/authorization failure. + * + *
MVC maps this exception to HTTP 404 so guessed identifiers do not reveal whether the record + * exists or merely falls outside the authenticated actor's current or historical scope. + */ @ResponseStatus(HttpStatus.NOT_FOUND) public final class TaskNotFoundException extends RuntimeException { + /** Creates the fixed, non-identifying HTTP 404 failure. */ public TaskNotFoundException() { super("Task or Project was not found"); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java index 0c2b66e..0e73821 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java @@ -3,9 +3,21 @@ package com.lab.labtimesheet.feature.task.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; +/** + * Signals that an authorized Task request violates a Task business rule. + * + *
Unadapted MVC uses map this exception to HTTP 400. The create-form controller handles this
+ * exact type locally to associate due-date failures with the field while allowing access failures
+ * to retain their separate HTTP 404 contract.
+ */
@ResponseStatus(HttpStatus.BAD_REQUEST)
public final class TaskValidationException extends RuntimeException {
+ /**
+ * Creates a client-visible validation failure.
+ *
+ * @param message actionable rule violation without protected record details
+ */
public TaskValidationException(String message) {
super(message);
}
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java
index 80fefbe..7b90f79 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java
@@ -3,8 +3,22 @@ package com.lab.labtimesheet.feature.task.model;
import java.util.Collection;
import java.util.OptionalDouble;
+/**
+ * Counts current non-deleted Tasks by fixed status for a single Project.
+ *
+ * @param todo Tasks not yet started
+ * @param inProgress Tasks actively in progress
+ * @param blocked Tasks currently blocked
+ * @param done completed Tasks
+ */
public record TaskProgress(int todo, int inProgress, int blocked, int done) {
+ /**
+ * Counts the supplied current Task statuses.
+ *
+ * @param statuses statuses already filtered to the caller's current Task scope
+ * @return immutable counts for all four statuses
+ */
public static TaskProgress from(Collection The graph is intentionally not configurable: TODO can move to IN_PROGRESS or BLOCKED;
+ * IN_PROGRESS can move to DONE or BLOCKED; BLOCKED can move to TODO or IN_PROGRESS; and DONE can
+ * only reopen to IN_PROGRESS.
+ */
public enum TaskStatus {
+ /** Work has not started. */
TODO,
+ /** Work is actively progressing. */
IN_PROGRESS,
+ /** Work cannot currently proceed. */
BLOCKED,
+ /** Work is complete and may only be reopened to IN_PROGRESS. */
DONE;
+ /**
+ * Tests whether the fixed workflow permits a direct edge to {@code target}.
+ *
+ * @param target requested next state
+ * @return {@code true} only for one of the specified v1 edges
+ */
public boolean canTransitionTo(TaskStatus target) {
return switch (this) {
case TODO -> target == IN_PROGRESS || target == BLOCKED;
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java
index 7ebbd28..a232b91 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java
@@ -2,6 +2,15 @@ package com.lab.labtimesheet.feature.task.model.dto;
import java.time.LocalDate;
+/**
+ * Authenticated request to create one Task in a Project.
+ *
+ * @param projectId Project aggregate identifier
+ * @param assigneeMembershipId same-Project active membership identifier, not a user identifier
+ * @param title required Task title
+ * @param description optional Task description
+ * @param dueDate optional business date constrained by Project dates and the current global calendar
+ */
public record CreateTaskCommand(
long projectId,
long assigneeMembershipId,
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java
index d67e530..a482600 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java
@@ -1,3 +1,9 @@
package com.lab.labtimesheet.feature.task.model.dto;
+/**
+ * One authorized Task assignee option for the create form.
+ *
+ * @param membershipId active same-Project membership identifier, never a user identifier
+ * @param displayName safe display label supplied by the Project feature
+ */
public record TaskAssigneeChoice(long membershipId, String displayName) {}
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java
index 1d3f800..b448b9f 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java
@@ -2,4 +2,13 @@ package com.lab.labtimesheet.feature.task.model.dto;
import java.time.Instant;
+/**
+ * Historical append-only Task comment exposed to authorized Task readers.
+ *
+ * @param id comment identifier
+ * @param taskId owning Task identifier
+ * @param authorUserId historical author user identifier
+ * @param body normalized comment text
+ * @param createdAt persisted creation instant
+ */
public record TaskCommentView(long id, long taskId, long authorUserId, String body, Instant createdAt) {}
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java
index efe9815..6aa2cde 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java
@@ -6,6 +6,14 @@ import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
+/**
+ * Browser-bound Task creation fields.
+ *
+ * @param title required title, limited to 200 characters before service normalization
+ * @param description optional safe text retained after validation
+ * @param assigneeMembershipId selected same-Project membership identifier
+ * @param dueDate optional ISO date; service validation applies Project and calendar rules
+ */
public record TaskCreateForm(
@NotBlank @Size(max = 200) String title,
String description,
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java
index d7ac141..3604350 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java
@@ -2,11 +2,19 @@ package com.lab.labtimesheet.feature.task.model.dto;
import java.util.List;
+/**
+ * Role-scoped Task contribution to the shared dashboard.
+ *
+ * @param blockedTaskCount blocked Tasks in active Projects owned by a Mentor; otherwise zero
+ * @param assignedTaskCount current non-deleted Tasks assigned to an Intern; otherwise zero
+ * @param priorityTasks at most five Intern assignments ordered by due date, null last, then Task ID
+ */
public record TaskDashboardView(
long blockedTaskCount,
long assignedTaskCount,
List Creator attribution never changes. Assignment actor/time describe the current assignment,
+ * deletion is soft and historical, and the JPA version detects conflicting updates. Newly created
+ * Tasks always begin in TODO; status changes must follow the fixed {@link TaskStatus} graph.
+ */
@Entity
@Table(name = "tasks")
public class Task {
@@ -64,8 +71,20 @@ public class Task {
@Version
private long version;
+ /** Constructor reserved for JPA materialization. */
protected Task() {}
+ /**
+ * Creates a TODO Task and records the creating membership as both creator and assigner.
+ *
+ * @param projectId owning Project identifier
+ * @param assigneeMembershipId active membership identifier in the same Project
+ * @param title normalized required title
+ * @param description optional normalized description
+ * @param dueDate optional validated business due date
+ * @param actorMembershipId authenticated creating membership identifier
+ * @param now server-controlled creation and assignment instant
+ */
public Task(
long projectId,
long assigneeMembershipId,
@@ -87,6 +106,13 @@ public class Task {
this.updatedAt = now;
}
+ /**
+ * Applies one permitted fixed-graph status transition and advances the update timestamp.
+ *
+ * @param target next Task status
+ * @param now server-controlled mutation instant
+ * @throws IllegalArgumentException when the requested direct transition is forbidden
+ */
public void changeStatus(TaskStatus target, Instant now) {
if (!status.canTransitionTo(target)) {
throw new IllegalArgumentException("Task status transition is not allowed");
@@ -95,50 +121,110 @@ public class Task {
updatedAt = now;
}
+ /**
+ * Returns the persistence identity.
+ *
+ * @return Task identifier, or {@code null} before insertion
+ */
public Long getId() {
return id;
}
+ /**
+ * Returns the aggregate identity.
+ *
+ * @return owning Project identifier
+ */
public long getProjectId() {
return projectId;
}
+ /**
+ * Returns the current assignment identity.
+ *
+ * @return current same-Project assignee membership identifier
+ */
public long getAssigneeMembershipId() {
return assigneeMembershipId;
}
+ /**
+ * Returns the display title.
+ *
+ * @return normalized Task title
+ */
public String getTitle() {
return title;
}
+ /**
+ * Returns the descriptive text.
+ *
+ * @return optional normalized description
+ */
public String getDescription() {
return description;
}
+ /**
+ * Returns the workflow state.
+ *
+ * @return current fixed workflow status
+ */
public TaskStatus getStatus() {
return status;
}
+ /**
+ * Returns the business deadline.
+ *
+ * @return optional validated due date
+ */
public LocalDate getDueDate() {
return dueDate;
}
+ /**
+ * Returns current assignment timing.
+ *
+ * @return instant when the current assignment was established
+ */
public Instant getAssignedAt() {
return assignedAt;
}
+ /**
+ * Returns original creator attribution.
+ *
+ * @return immutable creating membership identifier
+ */
public long getCreatorMembershipId() {
return creatorMembershipId;
}
+ /**
+ * Returns current assignment attribution.
+ *
+ * @return membership identifier responsible for the current assignment
+ */
public long getAssignerMembershipId() {
return assignerMembershipId;
}
+ /**
+ * Returns lifecycle visibility state.
+ *
+ * @return soft-deletion instant, or {@code null} while current
+ */
public Instant getDeletedAt() {
return deletedAt;
}
+ /**
+ * Returns creation timing.
+ *
+ * @return immutable creation instant
+ */
public Instant getCreatedAt() {
return createdAt;
}
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java
index 6e3b15e..5de025b 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java
@@ -8,6 +8,12 @@ import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+/**
+ * Persisted append-only Task comment.
+ *
+ * The author is retained as a user identifier so membership or leadership changes do not move
+ * historical attribution. This entity intentionally exposes no edit or delete operation.
+ */
@Entity
@Table(name = "task_comments")
public class TaskComment {
@@ -28,8 +34,17 @@ public class TaskComment {
@Column(name = "created_at", nullable = false)
private Instant createdAt;
+ /** Constructor reserved for JPA materialization. */
protected TaskComment() {}
+ /**
+ * Creates an immutable comment from server-authorized values.
+ *
+ * @param taskId owning Task identifier
+ * @param authorUserId authenticated historical author user identifier
+ * @param body normalized non-blank comment text
+ * @param createdAt server-controlled creation instant
+ */
public TaskComment(long taskId, long authorUserId, String body, Instant createdAt) {
this.taskId = taskId;
this.authorUserId = authorUserId;
@@ -37,22 +52,47 @@ public class TaskComment {
this.createdAt = createdAt;
}
+ /**
+ * Returns the persistence identity.
+ *
+ * @return comment identifier, or {@code null} before insertion
+ */
public Long getId() {
return id;
}
+ /**
+ * Returns the owning record identity.
+ *
+ * @return owning Task identifier
+ */
public long getTaskId() {
return taskId;
}
+ /**
+ * Returns historical authorship.
+ *
+ * @return immutable historical author user identifier
+ */
public long getAuthorUserId() {
return authorUserId;
}
+ /**
+ * Returns comment content.
+ *
+ * @return normalized comment text
+ */
public String getBody() {
return body;
}
+ /**
+ * Returns creation timing.
+ *
+ * @return immutable creation instant
+ */
public Instant getCreatedAt() {
return createdAt;
}
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java
index f0073fc..b9ebcb8 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java
@@ -4,7 +4,14 @@ import com.lab.labtimesheet.feature.task.model.entity.TaskComment;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
+/** JPA persistence boundary for append-only Task comments. */
public interface TaskCommentRepository extends JpaRepository Normal reads consistently exclude soft-deleted rows. Mutation callers acquire the owning
+ * Project lock before requesting the Task row lock so aggregate and Task lock order remains stable.
+ */
public interface TaskRepository extends JpaRepository Ordering is due date ascending, null due dates last, then Task identifier ascending. The
+ * supplied page bounds how many rows are returned.
+ *
+ * @param projectIds authorized active Project identifiers
+ * @param assigneeMembershipIds actor's current memberships in those Projects
+ * @param pageable result limit
+ * @return ordered non-deleted Tasks
+ */
@Query("""
select task
from Task task
@@ -43,6 +100,17 @@ public interface TaskRepository extends JpaRepository The Project activation caller is responsible for holding the Project write lock through
+ * its decision. Empty membership sets are handled by {@code TaskQueryService} rather than this
+ * {@code NOT IN} query.
+ *
+ * @param projectId locked Project identifier
+ * @param activeMembershipIds non-empty active same-Project membership identifiers
+ * @return non-deleted Tasks assigned outside the supplied set
+ */
@Query("""
select count(task)
from Task task
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java
index 9540de2..3a06dd8 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java
@@ -15,6 +15,13 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+/**
+ * Supplies the Task-owned portion of the shared role dashboard.
+ *
+ * Project visibility, role, and active membership facts come only from the Project service DTO
+ * boundary. Mentor counts cover blocked current Tasks in visible active Projects. Intern counts and
+ * priorities cover current assignments only where the actor still has an active membership.
+ */
@Service
public class TaskDashboardService {
@@ -23,11 +30,27 @@ public class TaskDashboardService {
private final TaskRepository tasks;
private final ProjectQueryService projects;
+ /**
+ * Creates the dashboard query service.
+ *
+ * @param tasks Task persistence boundary
+ * @param projects authorized Project query boundary
+ */
public TaskDashboardService(TaskRepository tasks, ProjectQueryService projects) {
this.tasks = tasks;
this.projects = projects;
}
+ /**
+ * Builds the role-scoped Task dashboard for one authenticated account.
+ *
+ * Mentors receive the blocked count for active Projects they can see. Interns receive their
+ * non-deleted assignment count and at most five priority Tasks, ordered by due date ascending,
+ * null due dates last, and Task identifier ascending. Other roles receive zero/empty values.
+ *
+ * @param actorEmail authenticated account email
+ * @return immutable role-appropriate Task dashboard data
+ */
@Transactional(readOnly = true)
public TaskDashboardView dashboard(String actorEmail) {
var actor = projects.authenticatedActor(actorEmail);
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java
index e469cf3..a50f814 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java
@@ -5,15 +5,35 @@ import java.util.Set;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+/**
+ * Public Task query boundary used by other features without exposing Task entities or repositories.
+ */
@Service
public class TaskQueryService {
private final TaskRepository tasks;
+ /**
+ * Creates the cross-feature Task query service.
+ *
+ * @param tasks Task persistence boundary
+ */
public TaskQueryService(TaskRepository tasks) {
this.tasks = tasks;
}
+ /**
+ * Counts current Tasks assigned outside the supplied active membership set.
+ *
+ * Only non-deleted Tasks are considered. An empty set means every current Task is invalid
+ * and avoids an empty {@code NOT IN} predicate. This method joins an existing transaction; a
+ * Project lifecycle caller must acquire and retain the Project write lock before calling it so
+ * the assignee guard remains stable through the Project decision and commit.
+ *
+ * @param projectId Project whose current Task assignments are being validated
+ * @param activeMembershipIds current eligible same-Project membership identifiers
+ * @return number of current Tasks whose assignee is not in the supplied set
+ */
@Transactional(readOnly = true)
public long countCurrentTasksAssignedOutside(long projectId, Set Project identity, lifecycle, ownership, leadership, and membership are obtained through
+ * Project service DTOs. Mutations lock and re-evaluate the Project first; status/comment mutations
+ * then lock the Task row, preserving the Project-to-Task lock order. Access failures are translated
+ * to a non-disclosing Task 404, while authenticated business-rule failures use Task validation.
+ */
@Service
public class TaskService {
@@ -43,6 +51,16 @@ public class TaskService {
private final CalendarApplicationService calendar;
private final Clock clock;
+ /**
+ * Creates the Task application service and its feature boundaries.
+ *
+ * @param tasks Task persistence boundary
+ * @param comments append-only comment persistence boundary
+ * @param projects authorized Project read boundary
+ * @param projectMutations Project-first locking mutation boundary
+ * @param calendar authoritative global day-off query boundary
+ * @param clock server time source for persisted instants
+ */
public TaskService(
TaskRepository tasks,
TaskCommentRepository comments,
@@ -58,6 +76,20 @@ public class TaskService {
this.clock = clock;
}
+ /**
+ * Creates a TODO Task in a PLANNED or ACTIVE Project.
+ *
+ * The Project is write-locked before membership and lifecycle checks. A current Leader may
+ * choose any active same-Project member; another active member may choose only themselves.
+ * Creator, assigner, and assignment time are stored from authenticated current context. An
+ * optional due date must be within Project dates and not a current global day off.
+ *
+ * @param actorEmail authenticated account email
+ * @param command requested Project, membership, and Task fields
+ * @return created Task projection
+ * @throws TaskNotFoundException when current authorization/context is absent
+ * @throws TaskValidationException when title or due date violates a business rule
+ */
@Transactional
public TaskView create(String actorEmail, CreateTaskCommand command) {
String title = requireTitle(command.title());
@@ -84,6 +116,21 @@ public class TaskService {
return view(tasks.saveAndFlush(task), assignee.displayName());
}
+ /**
+ * Changes an ACTIVE Project Task through one fixed workflow edge.
+ *
+ * The transaction locks the Project before the Task row and permits only the current
+ * assignee membership to mutate status. Neither leadership nor a global role substitutes for
+ * assignment authority.
+ *
+ * @param actorEmail authenticated account email
+ * @param projectId owning Project identifier
+ * @param taskId Task identifier within that Project
+ * @param target requested next status
+ * @return updated Task projection
+ * @throws TaskNotFoundException when scope, lifecycle, assignment, or identifiers are invalid
+ * @throws TaskValidationException when the requested status edge is forbidden
+ */
@Transactional
public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) {
TaskAccess access = requireMutationAccess(actorEmail, projectId);
@@ -103,6 +150,21 @@ public class TaskService {
return view(tasks.saveAndFlush(task), actorMembership.displayName());
}
+ /**
+ * Appends a comment to a current Task before Project completion.
+ *
+ * The transaction locks the Project before the Task row. The owning Mentor or any active
+ * member may comment; the persisted author is the authenticated user so later membership or
+ * leadership changes do not alter history.
+ *
+ * @param actorEmail authenticated account email
+ * @param projectId owning Project identifier
+ * @param taskId Task identifier within that Project
+ * @param body required comment text
+ * @return created append-only comment projection
+ * @throws TaskNotFoundException when current authorization, lifecycle, or identifiers are invalid
+ * @throws TaskValidationException when the normalized body is empty
+ */
@Transactional
public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) {
String normalizedBody = requireCommentBody(body);
@@ -123,6 +185,18 @@ public class TaskService {
return view(comments.saveAndFlush(comment));
}
+ /**
+ * Lists current Tasks and progress for an authorized Project reader.
+ *
+ * Soft-deleted Tasks are excluded. Active members may read open Projects; former members may
+ * read only a completed Project in which they historically participated. Empty current Task
+ * sets produce empty completion percentage semantics. The create capability is server-derived.
+ *
+ * @param actorEmail authenticated account email
+ * @param projectId Project identifier
+ * @return visible Tasks, progress counts, and create capability
+ * @throws TaskNotFoundException when the Project is outside the actor's authorized scope
+ */
@Transactional(readOnly = true)
public TaskListView list(String actorEmail, long projectId) {
TaskAccess access = requireReadableProject(actorEmail, projectId);
@@ -137,6 +211,19 @@ public class TaskService {
isOpen(access.project()) && activeMembership(access) != null);
}
+ /**
+ * Loads one current Task, append-only comments, and server-derived action capabilities.
+ *
+ * Status capability requires the ACTIVE Project's current assignee. Comment capability
+ * requires an active member or owning Mentor before completion. Historical completed-Project
+ * readers receive details with no mutation capability.
+ *
+ * @param actorEmail authenticated account email
+ * @param projectId owning Project identifier
+ * @param taskId Task identifier within that Project
+ * @return authorized detail projection
+ * @throws TaskNotFoundException when scope or identifiers are invalid
+ */
@Transactional(readOnly = true)
public TaskDetails details(String actorEmail, long projectId, long taskId) {
TaskAccess access = requireReadableProject(actorEmail, projectId);
@@ -157,6 +244,18 @@ public class TaskService {
return new TaskDetails(task, taskComments, canChangeStatus, canComment);
}
+ /**
+ * Returns assignee options for an authorized Task create form.
+ *
+ * A current Leader receives every active same-Project membership; another active member
+ * receives only their own membership. Completed Projects and non-members are denied without
+ * disclosing Project membership data.
+ *
+ * @param actorEmail authenticated account email
+ * @param projectId Project identifier
+ * @return authorized membership choices
+ * @throws TaskNotFoundException when current authorization or lifecycle is invalid
+ */
@Transactional(readOnly = true)
public List Due date error