fix(task): harden task forms and document contracts

This commit is contained in:
sechmachine
2026-08-15 02:17:26 +07:00
parent 213a889c8f
commit fb0ed7f12c
23 changed files with 596 additions and 11 deletions
@@ -1,13 +1,16 @@
package com.lab.labtimesheet.feature.task.controller; 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.TaskProgress;
import com.lab.labtimesheet.feature.task.model.TaskStatus; 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.CreateTaskCommand;
import com.lab.labtimesheet.feature.task.model.dto.TaskCreateForm; 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.TaskListView;
import com.lab.labtimesheet.feature.task.model.dto.TaskView; import com.lab.labtimesheet.feature.task.model.dto.TaskView;
import com.lab.labtimesheet.feature.task.service.TaskService; import com.lab.labtimesheet.feature.task.service.TaskService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.Arrays;
import java.util.Locale; import java.util.Locale;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
/**
* Serves authenticated Task list, create, detail, status, and comment pages.
*
* <p>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 @Controller
public class TaskController { public class TaskController {
private final TaskService taskService; private final TaskService taskService;
/**
* Creates the MVC adapter for the Task application service.
*
* @param taskService authorized Task use cases
*/
public TaskController(TaskService taskService) { public TaskController(TaskService taskService) {
this.taskService = taskService; this.taskService = taskService;
} }
@@ -55,7 +71,9 @@ public class TaskController {
populateForm(authentication.getName(), projectId, model); populateForm(authentication.getName(), projectId, model);
return "tasks/form"; return "tasks/form";
} }
TaskView task = taskService.create( TaskView task;
try {
task = taskService.create(
authentication.getName(), authentication.getName(),
new CreateTaskCommand( new CreateTaskCommand(
projectId, projectId,
@@ -63,6 +81,11 @@ public class TaskController {
form.title(), form.title(),
form.description(), form.description(),
form.dueDate())); 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()); return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id());
} }
@@ -72,9 +95,12 @@ public class TaskController {
@PathVariable long projectId, @PathVariable long projectId,
@PathVariable long taskId, @PathVariable long taskId,
Model model) { Model model) {
TaskDetails details = taskService.details(authentication.getName(), projectId, taskId);
model.addAttribute("projectId", projectId); model.addAttribute("projectId", projectId);
model.addAttribute("details", taskService.details(authentication.getName(), projectId, taskId)); model.addAttribute("details", details);
model.addAttribute("statuses", TaskStatus.values()); model.addAttribute("statuses", Arrays.stream(TaskStatus.values())
.filter(details.task().status()::canTransitionTo)
.toList());
return "tasks/detail"; return "tasks/detail";
} }
@@ -3,9 +3,16 @@ package com.lab.labtimesheet.feature.task.exception;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
/**
* Signals a non-disclosing Task or Project lookup/authorization failure.
*
* <p>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) @ResponseStatus(HttpStatus.NOT_FOUND)
public final class TaskNotFoundException extends RuntimeException { public final class TaskNotFoundException extends RuntimeException {
/** Creates the fixed, non-identifying HTTP 404 failure. */
public TaskNotFoundException() { public TaskNotFoundException() {
super("Task or Project was not found"); super("Task or Project was not found");
} }
@@ -3,9 +3,21 @@ package com.lab.labtimesheet.feature.task.exception;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
/**
* Signals that an authorized Task request violates a Task business rule.
*
* <p>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) @ResponseStatus(HttpStatus.BAD_REQUEST)
public final class TaskValidationException extends RuntimeException { 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) { public TaskValidationException(String message) {
super(message); super(message);
} }
@@ -3,8 +3,22 @@ package com.lab.labtimesheet.feature.task.model;
import java.util.Collection; import java.util.Collection;
import java.util.OptionalDouble; 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) { 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<TaskStatus> statuses) { public static TaskProgress from(Collection<TaskStatus> statuses) {
int todo = 0; int todo = 0;
int inProgress = 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); return new TaskProgress(todo, inProgress, blocked, done);
} }
/**
* Returns the denominator used for Project completion progress.
*
* @return total number of counted Tasks
*/
public int total() { public int total() {
return todo + inProgress + blocked + done; 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) { public int count(TaskStatus status) {
return switch (status) { return switch (status) {
case TODO -> todo; 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() { public OptionalDouble completionPercentage() {
return total() == 0 ? OptionalDouble.empty() : OptionalDouble.of(done * 100.0 / total()); return total() == 0 ? OptionalDouble.empty() : OptionalDouble.of(done * 100.0 / total());
} }
@@ -1,11 +1,28 @@
package com.lab.labtimesheet.feature.task.model; package com.lab.labtimesheet.feature.task.model;
/**
* Fixed v1 Task workflow states.
*
* <p>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 { public enum TaskStatus {
/** Work has not started. */
TODO, TODO,
/** Work is actively progressing. */
IN_PROGRESS, IN_PROGRESS,
/** Work cannot currently proceed. */
BLOCKED, BLOCKED,
/** Work is complete and may only be reopened to IN_PROGRESS. */
DONE; 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) { public boolean canTransitionTo(TaskStatus target) {
return switch (this) { return switch (this) {
case TODO -> target == IN_PROGRESS || target == BLOCKED; case TODO -> target == IN_PROGRESS || target == BLOCKED;
@@ -2,6 +2,15 @@ package com.lab.labtimesheet.feature.task.model.dto;
import java.time.LocalDate; 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( public record CreateTaskCommand(
long projectId, long projectId,
long assigneeMembershipId, long assigneeMembershipId,
@@ -1,3 +1,9 @@
package com.lab.labtimesheet.feature.task.model.dto; 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) {} public record TaskAssigneeChoice(long membershipId, String displayName) {}
@@ -2,4 +2,13 @@ package com.lab.labtimesheet.feature.task.model.dto;
import java.time.Instant; 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) {} public record TaskCommentView(long id, long taskId, long authorUserId, String body, Instant createdAt) {}
@@ -6,6 +6,14 @@ import jakarta.validation.constraints.Size;
import java.time.LocalDate; import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat; 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( public record TaskCreateForm(
@NotBlank @Size(max = 200) String title, @NotBlank @Size(max = 200) String title,
String description, String description,
@@ -2,11 +2,19 @@ package com.lab.labtimesheet.feature.task.model.dto;
import java.util.List; 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( public record TaskDashboardView(
long blockedTaskCount, long blockedTaskCount,
long assignedTaskCount, long assignedTaskCount,
List<TaskPriorityView> priorityTasks) { List<TaskPriorityView> priorityTasks) {
/** Copies the priority list so downstream UI code cannot mutate the service result. */
public TaskDashboardView { public TaskDashboardView {
priorityTasks = List.copyOf(priorityTasks); priorityTasks = List.copyOf(priorityTasks);
} }
@@ -2,12 +2,21 @@ package com.lab.labtimesheet.feature.task.model.dto;
import java.util.List; 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( public record TaskDetails(
TaskView task, TaskView task,
List<TaskCommentView> comments, List<TaskCommentView> comments,
boolean canChangeStatus, boolean canChangeStatus,
boolean canComment) { boolean canComment) {
/** Copies the comment list so historical output cannot be modified by a view consumer. */
public TaskDetails { public TaskDetails {
comments = List.copyOf(comments); comments = List.copyOf(comments);
} }
@@ -3,8 +3,16 @@ package com.lab.labtimesheet.feature.task.model.dto;
import com.lab.labtimesheet.feature.task.model.TaskProgress; import com.lab.labtimesheet.feature.task.model.TaskProgress;
import java.util.List; 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<TaskView> tasks, TaskProgress progress, boolean canCreate) { public record TaskListView(List<TaskView> tasks, TaskProgress progress, boolean canCreate) {
/** Copies the Task list so view consumers cannot alter the authorized result. */
public TaskListView { public TaskListView {
tasks = List.copyOf(tasks); tasks = List.copyOf(tasks);
} }
@@ -3,6 +3,14 @@ package com.lab.labtimesheet.feature.task.model.dto;
import com.lab.labtimesheet.feature.task.model.TaskStatus; import com.lab.labtimesheet.feature.task.model.TaskStatus;
import java.time.LocalDate; 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( public record TaskPriorityView(
String title, String title,
String projectName, String projectName,
@@ -4,6 +4,22 @@ import com.lab.labtimesheet.feature.task.model.TaskStatus;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; 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( public record TaskView(
long id, long id,
long projectId, long projectId,
@@ -13,6 +13,13 @@ import jakarta.persistence.Version;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
/**
* Persisted Task aggregate row with one current same-Project assignee.
*
* <p>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 @Entity
@Table(name = "tasks") @Table(name = "tasks")
public class Task { public class Task {
@@ -64,8 +71,20 @@ public class Task {
@Version @Version
private long version; private long version;
/** Constructor reserved for JPA materialization. */
protected Task() {} 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( public Task(
long projectId, long projectId,
long assigneeMembershipId, long assigneeMembershipId,
@@ -87,6 +106,13 @@ public class Task {
this.updatedAt = now; 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) { public void changeStatus(TaskStatus target, Instant now) {
if (!status.canTransitionTo(target)) { if (!status.canTransitionTo(target)) {
throw new IllegalArgumentException("Task status transition is not allowed"); throw new IllegalArgumentException("Task status transition is not allowed");
@@ -95,50 +121,110 @@ public class Task {
updatedAt = now; updatedAt = now;
} }
/**
* Returns the persistence identity.
*
* @return Task identifier, or {@code null} before insertion
*/
public Long getId() { public Long getId() {
return id; return id;
} }
/**
* Returns the aggregate identity.
*
* @return owning Project identifier
*/
public long getProjectId() { public long getProjectId() {
return projectId; return projectId;
} }
/**
* Returns the current assignment identity.
*
* @return current same-Project assignee membership identifier
*/
public long getAssigneeMembershipId() { public long getAssigneeMembershipId() {
return assigneeMembershipId; return assigneeMembershipId;
} }
/**
* Returns the display title.
*
* @return normalized Task title
*/
public String getTitle() { public String getTitle() {
return title; return title;
} }
/**
* Returns the descriptive text.
*
* @return optional normalized description
*/
public String getDescription() { public String getDescription() {
return description; return description;
} }
/**
* Returns the workflow state.
*
* @return current fixed workflow status
*/
public TaskStatus getStatus() { public TaskStatus getStatus() {
return status; return status;
} }
/**
* Returns the business deadline.
*
* @return optional validated due date
*/
public LocalDate getDueDate() { public LocalDate getDueDate() {
return dueDate; return dueDate;
} }
/**
* Returns current assignment timing.
*
* @return instant when the current assignment was established
*/
public Instant getAssignedAt() { public Instant getAssignedAt() {
return assignedAt; return assignedAt;
} }
/**
* Returns original creator attribution.
*
* @return immutable creating membership identifier
*/
public long getCreatorMembershipId() { public long getCreatorMembershipId() {
return creatorMembershipId; return creatorMembershipId;
} }
/**
* Returns current assignment attribution.
*
* @return membership identifier responsible for the current assignment
*/
public long getAssignerMembershipId() { public long getAssignerMembershipId() {
return assignerMembershipId; return assignerMembershipId;
} }
/**
* Returns lifecycle visibility state.
*
* @return soft-deletion instant, or {@code null} while current
*/
public Instant getDeletedAt() { public Instant getDeletedAt() {
return deletedAt; return deletedAt;
} }
/**
* Returns creation timing.
*
* @return immutable creation instant
*/
public Instant getCreatedAt() { public Instant getCreatedAt() {
return createdAt; return createdAt;
} }
@@ -8,6 +8,12 @@ import jakarta.persistence.Id;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.time.Instant; import java.time.Instant;
/**
* Persisted append-only Task comment.
*
* <p>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 @Entity
@Table(name = "task_comments") @Table(name = "task_comments")
public class TaskComment { public class TaskComment {
@@ -28,8 +34,17 @@ public class TaskComment {
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private Instant createdAt; private Instant createdAt;
/** Constructor reserved for JPA materialization. */
protected TaskComment() {} 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) { public TaskComment(long taskId, long authorUserId, String body, Instant createdAt) {
this.taskId = taskId; this.taskId = taskId;
this.authorUserId = authorUserId; this.authorUserId = authorUserId;
@@ -37,22 +52,47 @@ public class TaskComment {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
/**
* Returns the persistence identity.
*
* @return comment identifier, or {@code null} before insertion
*/
public Long getId() { public Long getId() {
return id; return id;
} }
/**
* Returns the owning record identity.
*
* @return owning Task identifier
*/
public long getTaskId() { public long getTaskId() {
return taskId; return taskId;
} }
/**
* Returns historical authorship.
*
* @return immutable historical author user identifier
*/
public long getAuthorUserId() { public long getAuthorUserId() {
return authorUserId; return authorUserId;
} }
/**
* Returns comment content.
*
* @return normalized comment text
*/
public String getBody() { public String getBody() {
return body; return body;
} }
/**
* Returns creation timing.
*
* @return immutable creation instant
*/
public Instant getCreatedAt() { public Instant getCreatedAt() {
return createdAt; return createdAt;
} }
@@ -4,7 +4,14 @@ import com.lab.labtimesheet.feature.task.model.entity.TaskComment;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
/** JPA persistence boundary for append-only Task comments. */
public interface TaskCommentRepository extends JpaRepository<TaskComment, Long> { public interface TaskCommentRepository extends JpaRepository<TaskComment, Long> {
/**
* Loads a Task's complete comment history deterministically.
*
* @param taskId owning Task identifier
* @return comments ordered by creation instant and then identifier
*/
List<TaskComment> findAllByTaskIdOrderByCreatedAtAscIdAsc(long taskId); List<TaskComment> findAllByTaskIdOrderByCreatedAtAscIdAsc(long taskId);
} }
@@ -12,22 +12,79 @@ import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
/**
* JPA persistence and current-read queries for Task rows.
*
* <p>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<Task, Long> { public interface TaskRepository extends JpaRepository<Task, Long> {
/**
* 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<Task> findByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId); Optional<Task> 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) @Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Task> findLockedByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId); Optional<Task> 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<Task> findAllByProjectIdAndDeletedAtIsNullOrderById(long projectId); List<Task> findAllByProjectIdAndDeletedAtIsNullOrderById(long projectId);
/**
* Counts all current Tasks in one Project.
*
* @param projectId owning Project identifier
* @return non-deleted Task count
*/
long countByProjectIdAndDeletedAtIsNull(long projectId); 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<Long> projectIds, TaskStatus status); long countByProjectIdInAndStatusAndDeletedAtIsNull(List<Long> 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( long countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull(
List<Long> projectIds, List<Long> assigneeMembershipIds); List<Long> projectIds, List<Long> assigneeMembershipIds);
/**
* Loads the highest-priority current assignments within authorized Project/membership pairs.
*
* <p>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(""" @Query("""
select task select task
from Task task from Task task
@@ -43,6 +100,17 @@ public interface TaskRepository extends JpaRepository<Task, Long> {
@Param("assigneeMembershipIds") List<Long> assigneeMembershipIds, @Param("assigneeMembershipIds") List<Long> assigneeMembershipIds,
Pageable pageable); Pageable pageable);
/**
* Counts current Tasks whose assignee is outside a non-empty active-membership set.
*
* <p>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(""" @Query("""
select count(task) select count(task)
from Task task from Task task
@@ -15,6 +15,13 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
/**
* Supplies the Task-owned portion of the shared role dashboard.
*
* <p>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 @Service
public class TaskDashboardService { public class TaskDashboardService {
@@ -23,11 +30,27 @@ public class TaskDashboardService {
private final TaskRepository tasks; private final TaskRepository tasks;
private final ProjectQueryService projects; 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) { public TaskDashboardService(TaskRepository tasks, ProjectQueryService projects) {
this.tasks = tasks; this.tasks = tasks;
this.projects = projects; this.projects = projects;
} }
/**
* Builds the role-scoped Task dashboard for one authenticated account.
*
* <p>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) @Transactional(readOnly = true)
public TaskDashboardView dashboard(String actorEmail) { public TaskDashboardView dashboard(String actorEmail) {
var actor = projects.authenticatedActor(actorEmail); var actor = projects.authenticatedActor(actorEmail);
@@ -5,15 +5,35 @@ import java.util.Set;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
/**
* Public Task query boundary used by other features without exposing Task entities or repositories.
*/
@Service @Service
public class TaskQueryService { public class TaskQueryService {
private final TaskRepository tasks; private final TaskRepository tasks;
/**
* Creates the cross-feature Task query service.
*
* @param tasks Task persistence boundary
*/
public TaskQueryService(TaskRepository tasks) { public TaskQueryService(TaskRepository tasks) {
this.tasks = tasks; this.tasks = tasks;
} }
/**
* Counts current Tasks assigned outside the supplied active membership set.
*
* <p>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) @Transactional(readOnly = true)
public long countCurrentTasksAssignedOutside(long projectId, Set<Long> activeMembershipIds) { public long countCurrentTasksAssignedOutside(long projectId, Set<Long> activeMembershipIds) {
if (activeMembershipIds.isEmpty()) { if (activeMembershipIds.isEmpty()) {
@@ -33,6 +33,14 @@ import java.util.stream.Collectors;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
/**
* Executes authorized Task creation, status, comment, and current/historical read use cases.
*
* <p>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 @Service
public class TaskService { public class TaskService {
@@ -43,6 +51,16 @@ public class TaskService {
private final CalendarApplicationService calendar; private final CalendarApplicationService calendar;
private final Clock clock; 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( public TaskService(
TaskRepository tasks, TaskRepository tasks,
TaskCommentRepository comments, TaskCommentRepository comments,
@@ -58,6 +76,20 @@ public class TaskService {
this.clock = clock; this.clock = clock;
} }
/**
* Creates a TODO Task in a PLANNED or ACTIVE Project.
*
* <p>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 @Transactional
public TaskView create(String actorEmail, CreateTaskCommand command) { public TaskView create(String actorEmail, CreateTaskCommand command) {
String title = requireTitle(command.title()); String title = requireTitle(command.title());
@@ -84,6 +116,21 @@ public class TaskService {
return view(tasks.saveAndFlush(task), assignee.displayName()); return view(tasks.saveAndFlush(task), assignee.displayName());
} }
/**
* Changes an ACTIVE Project Task through one fixed workflow edge.
*
* <p>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 @Transactional
public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) { public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) {
TaskAccess access = requireMutationAccess(actorEmail, projectId); TaskAccess access = requireMutationAccess(actorEmail, projectId);
@@ -103,6 +150,21 @@ public class TaskService {
return view(tasks.saveAndFlush(task), actorMembership.displayName()); return view(tasks.saveAndFlush(task), actorMembership.displayName());
} }
/**
* Appends a comment to a current Task before Project completion.
*
* <p>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 @Transactional
public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) { public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) {
String normalizedBody = requireCommentBody(body); String normalizedBody = requireCommentBody(body);
@@ -123,6 +185,18 @@ public class TaskService {
return view(comments.saveAndFlush(comment)); return view(comments.saveAndFlush(comment));
} }
/**
* Lists current Tasks and progress for an authorized Project reader.
*
* <p>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) @Transactional(readOnly = true)
public TaskListView list(String actorEmail, long projectId) { public TaskListView list(String actorEmail, long projectId) {
TaskAccess access = requireReadableProject(actorEmail, projectId); TaskAccess access = requireReadableProject(actorEmail, projectId);
@@ -137,6 +211,19 @@ public class TaskService {
isOpen(access.project()) && activeMembership(access) != null); isOpen(access.project()) && activeMembership(access) != null);
} }
/**
* Loads one current Task, append-only comments, and server-derived action capabilities.
*
* <p>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) @Transactional(readOnly = true)
public TaskDetails details(String actorEmail, long projectId, long taskId) { public TaskDetails details(String actorEmail, long projectId, long taskId) {
TaskAccess access = requireReadableProject(actorEmail, projectId); TaskAccess access = requireReadableProject(actorEmail, projectId);
@@ -157,6 +244,18 @@ public class TaskService {
return new TaskDetails(task, taskComments, canChangeStatus, canComment); return new TaskDetails(task, taskComments, canChangeStatus, canComment);
} }
/**
* Returns assignee options for an authorized Task create form.
*
* <p>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) @Transactional(readOnly = true)
public List<TaskAssigneeChoice> assignmentChoices(String actorEmail, long projectId) { public List<TaskAssigneeChoice> assignmentChoices(String actorEmail, long projectId) {
TaskAccess access = requireProjectAccess(actorEmail, projectId); TaskAccess access = requireProjectAccess(actorEmail, projectId);
@@ -29,6 +29,7 @@
<div> <div>
<label for="dueDate">Due date</label> <label for="dueDate">Due date</label>
<input id="dueDate" type="date" th:field="*{dueDate}"> <input id="dueDate" type="date" th:field="*{dueDate}">
<p role="alert" th:if="${#fields.hasErrors('dueDate')}" th:errors="*{dueDate}">Due date error</p>
</div> </div>
<button type="submit">Create Task</button> <button type="submit">Create Task</button>
</form> </form>
@@ -16,6 +16,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; 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.TaskNotFoundException;
import com.lab.labtimesheet.feature.task.exception.TaskValidationException;
import com.lab.labtimesheet.feature.task.model.TaskProgress; import com.lab.labtimesheet.feature.task.model.TaskProgress;
import com.lab.labtimesheet.feature.task.model.TaskStatus; 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.CreateTaskCommand;
@@ -28,7 +29,11 @@ import com.lab.labtimesheet.feature.task.service.TaskService;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test; 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.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -125,6 +130,46 @@ class TaskControllerTest {
.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class)); .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 @Test
void statusAndCommentPostsUseAuthenticatedIdentityAndCsrf() throws Exception { void statusAndCommentPostsUseAuthenticatedIdentityAndCsrf() throws Exception {
given(taskService.changeStatus(ACTOR_EMAIL, 10L, 25L, TaskStatus.IN_PROGRESS)) 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"))); .andExpect(content().string(org.hamcrest.Matchers.containsString("Add comment")));
} }
@ParameterizedTest(name = "{0} exposes only {1}")
@MethodSource("allowedStatusChoices")
void taskDetailsExposeOnlyAllowedStatusTransitions(TaskStatus current, List<TaskStatus> 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<Arguments> 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) { 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"); Instant instant = Instant.parse("2026-08-14T10:00:00Z");
return new TaskView( 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); LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant);
} }
} }