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 statuses) { int todo = 0; int inProgress = 0; @@ -21,10 +35,21 @@ public record TaskProgress(int todo, int inProgress, int blocked, int done) { return new TaskProgress(todo, inProgress, blocked, done); } + /** + * Returns the denominator used for Project completion progress. + * + * @return total number of counted Tasks + */ public int total() { return todo + inProgress + blocked + done; } + /** + * Returns the count for one fixed status. + * + * @param status status to inspect + * @return number of Tasks in that status + */ public int count(TaskStatus status) { return switch (status) { case TODO -> todo; @@ -34,6 +59,11 @@ public record TaskProgress(int todo, int inProgress, int blocked, int done) { }; } + /** + * Computes DONE Tasks as a percentage of all counted Tasks. + * + * @return an empty value when the Project has no current Tasks, otherwise a value from 0 to 100 + */ public OptionalDouble completionPercentage() { return total() == 0 ? OptionalDouble.empty() : OptionalDouble.of(done * 100.0 / total()); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java index 363accf..809ea9e 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java @@ -1,11 +1,28 @@ package com.lab.labtimesheet.feature.task.model; +/** + * Fixed v1 Task workflow states. + * + *

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 priorityTasks) { + /** Copies the priority list so downstream UI code cannot mutate the service result. */ public TaskDashboardView { priorityTasks = List.copyOf(priorityTasks); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java index 9f9a91d..ba4e352 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java @@ -2,12 +2,21 @@ package com.lab.labtimesheet.feature.task.model.dto; import java.util.List; +/** + * Authorized Task detail view and server-derived action capabilities. + * + * @param task visible non-deleted Task + * @param comments append-only comment history in creation order + * @param canChangeStatus true only for the current assignee of an ACTIVE Project + * @param canComment true only for an eligible active member or owning Mentor before completion + */ public record TaskDetails( TaskView task, List comments, boolean canChangeStatus, boolean canComment) { + /** Copies the comment list so historical output cannot be modified by a view consumer. */ public TaskDetails { comments = List.copyOf(comments); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java index 8049554..bc6a64d 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java @@ -3,8 +3,16 @@ package com.lab.labtimesheet.feature.task.model.dto; import com.lab.labtimesheet.feature.task.model.TaskProgress; import java.util.List; +/** + * Authorized current Task list with aggregate progress and create capability. + * + * @param tasks non-deleted Tasks ordered by Task identifier + * @param progress status counts derived from exactly {@code tasks}; empty lists produce N/A progress + * @param canCreate true only for an active member while the Project is PLANNED or ACTIVE + */ public record TaskListView(List tasks, TaskProgress progress, boolean canCreate) { + /** Copies the Task list so view consumers cannot alter the authorized result. */ public TaskListView { tasks = List.copyOf(tasks); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java index 30d09a7..374d555 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java @@ -3,6 +3,14 @@ package com.lab.labtimesheet.feature.task.model.dto; import com.lab.labtimesheet.feature.task.model.TaskStatus; import java.time.LocalDate; +/** + * Compact current assignment rendered on an Intern dashboard. + * + * @param title Task title + * @param projectName Project display name from the Project service boundary + * @param status current fixed workflow status + * @param dueDate optional Task due date + */ public record TaskPriorityView( String title, String projectName, diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java index a466eee..0ed999d 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java @@ -4,6 +4,22 @@ import com.lab.labtimesheet.feature.task.model.TaskStatus; import java.time.Instant; import java.time.LocalDate; +/** + * Authorized current Task projection with immutable creator and assignment attribution. + * + * @param id Task identifier + * @param projectId owning Project identifier + * @param assigneeMembershipId current same-Project assignee membership identifier + * @param assigneeName current or historical assignee name supplied by the Project feature + * @param title Task title + * @param description optional description + * @param status current fixed workflow status + * @param dueDate optional due date + * @param creatorMembershipId immutable creating membership identifier + * @param assignerMembershipId membership identifier responsible for the current assignment + * @param assignedAt instant the current assignment was established + * @param createdAt immutable Task creation instant + */ public record TaskView( long id, long projectId, diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java index 59564d3..9d4c8cc 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java @@ -13,6 +13,13 @@ import jakarta.persistence.Version; import java.time.Instant; import java.time.LocalDate; +/** + * Persisted Task aggregate row with one current same-Project assignee. + * + *

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 { + /** + * Loads a Task's complete comment history deterministically. + * + * @param taskId owning Task identifier + * @return comments ordered by creation instant and then identifier + */ List findAllByTaskIdOrderByCreatedAtAscIdAsc(long taskId); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java index 1a8708e..43cbce1 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java @@ -12,22 +12,79 @@ import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +/** + * JPA persistence and current-read queries for Task rows. + * + *

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 { + /** + * Finds a current Task only when its identifier belongs to the supplied Project. + * + * @param id Task identifier + * @param projectId owning Project identifier + * @return matching non-deleted Task, if visible in that aggregate + */ Optional findByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId); + /** + * Locks one current Task for a mutation after the caller has locked its Project. + * + * @param id Task identifier + * @param projectId owning Project identifier + * @return matching non-deleted Task under a pessimistic write lock + */ @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findLockedByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId); + /** + * Lists current Tasks for one Project in deterministic identifier order. + * + * @param projectId owning Project identifier + * @return non-deleted Tasks + */ List findAllByProjectIdAndDeletedAtIsNullOrderById(long projectId); + /** + * Counts all current Tasks in one Project. + * + * @param projectId owning Project identifier + * @return non-deleted Task count + */ long countByProjectIdAndDeletedAtIsNull(long projectId); + /** + * Counts current Tasks in a status across the supplied Projects. + * + * @param projectIds authorized Project identifiers + * @param status status to count + * @return matching non-deleted Task count + */ long countByProjectIdInAndStatusAndDeletedAtIsNull(List projectIds, TaskStatus status); + /** + * Counts current Tasks assigned through the supplied Project memberships. + * + * @param projectIds authorized Project identifiers + * @param assigneeMembershipIds actor memberships scoped to those Projects + * @return matching non-deleted Task count + */ long countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull( List projectIds, List assigneeMembershipIds); + /** + * Loads the highest-priority current assignments within authorized Project/membership pairs. + * + *

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 { @Param("assigneeMembershipIds") List assigneeMembershipIds, Pageable pageable); + /** + * Counts current Tasks whose assignee is outside a non-empty active-membership set. + * + *

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 activeMembershipIds) { if (activeMembershipIds.isEmpty()) { diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java index c8a44a8..302ef27 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java @@ -33,6 +33,14 @@ import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Executes authorized Task creation, status, comment, and current/historical read use cases. + * + *

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 assignmentChoices(String actorEmail, long projectId) { TaskAccess access = requireProjectAccess(actorEmail, projectId); diff --git a/src/main/resources/templates/tasks/form.html b/src/main/resources/templates/tasks/form.html index c604e00..4df9b1a 100644 --- a/src/main/resources/templates/tasks/form.html +++ b/src/main/resources/templates/tasks/form.html @@ -29,6 +29,7 @@

+

Due date error

diff --git a/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java index c3ec13e..023d1e3 100644 --- a/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java @@ -16,6 +16,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; 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; @@ -28,7 +29,11 @@ import com.lab.labtimesheet.feature.task.service.TaskService; import java.time.Instant; import java.time.LocalDate; import java.util.List; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; @@ -125,6 +130,46 @@ class TaskControllerTest { .create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class)); } + @Test + void invalidDueDateRendersFieldErrorAndRetainsSafeInput() throws Exception { + TaskAssigneeChoice assignee = new TaskAssigneeChoice(7L, "Member Name"); + given(taskService.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class))) + .willThrow(new TaskValidationException("Due date must fall within the Project dates")); + given(taskService.assignmentChoices(ACTOR_EMAIL, 10L)).willReturn(List.of(assignee)); + + mockMvc.perform(post("/projects/10/tasks") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("title", "Draft") + .param("description", "Safe notes") + .param("assigneeMembershipId", "7") + .param("dueDate", "2026-09-01")) + .andExpect(status().isOk()) + .andExpect(view().name("tasks/form")) + .andExpect(model().attributeHasFieldErrors("taskForm", "dueDate")) + .andExpect(model().attribute("assignees", List.of(assignee))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Draft"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Safe notes"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("2026-09-01"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "Due date must fall within the Project dates"))); + } + + @Test + void guessedProjectDuringCreateRemainsNotFound() throws Exception { + given(taskService.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class))) + .willThrow(new TaskNotFoundException()); + + mockMvc.perform(post("/projects/999/tasks") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("title", "Draft") + .param("assigneeMembershipId", "7")) + .andExpect(status().isNotFound()); + + verify(taskService, org.mockito.Mockito.never()).assignmentChoices(ACTOR_EMAIL, 999L); + } + @Test void statusAndCommentPostsUseAuthenticatedIdentityAndCsrf() throws Exception { given(taskService.changeStatus(ACTOR_EMAIL, 10L, 25L, TaskStatus.IN_PROGRESS)) @@ -171,10 +216,33 @@ class TaskControllerTest { .andExpect(content().string(org.hamcrest.Matchers.containsString("Add comment"))); } + @ParameterizedTest(name = "{0} exposes only {1}") + @MethodSource("allowedStatusChoices") + void taskDetailsExposeOnlyAllowedStatusTransitions(TaskStatus current, List expected) throws Exception { + given(taskService.details(ACTOR_EMAIL, 10L, 25L)) + .willReturn(new TaskDetails(task(25L, current), List.of(), true, true)); + + mockMvc.perform(get("/projects/10/tasks/25").with(user(ACTOR_EMAIL))) + .andExpect(status().isOk()) + .andExpect(model().attribute("statuses", expected)); + } + + private static Stream allowedStatusChoices() { + return Stream.of( + Arguments.of(TaskStatus.TODO, List.of(TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED)), + Arguments.of(TaskStatus.IN_PROGRESS, List.of(TaskStatus.BLOCKED, TaskStatus.DONE)), + Arguments.of(TaskStatus.BLOCKED, List.of(TaskStatus.TODO, TaskStatus.IN_PROGRESS)), + Arguments.of(TaskStatus.DONE, List.of(TaskStatus.IN_PROGRESS))); + } + private static TaskView task(long id) { + return task(id, TaskStatus.TODO); + } + + private static TaskView task(long id, TaskStatus status) { Instant instant = Instant.parse("2026-08-14T10:00:00Z"); return new TaskView( - id, 10L, 7L, "Member Name", "Draft", "Notes", TaskStatus.TODO, + id, 10L, 7L, "Member Name", "Draft", "Notes", status, LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant); } }