diff --git a/docs/tests/unit/task-api-documentation.md b/docs/tests/unit/task-api-documentation.md new file mode 100644 index 0000000..5d34b29 --- /dev/null +++ b/docs/tests/unit/task-api-documentation.md @@ -0,0 +1,84 @@ +# Test Evidence: Task public API documentation retrofit + +- **Test type:** Unit +- **Requirement IDs:** `TST-009` +- **Scenario IDs:** `Iteration 1 Task Javadoc retrofit` +- **Test class/method:** `Maven compiler and Javadoc doclint (no synthetic test)` +- **Implementation commit:** `fb0ed7f12c9d89235c102b67f2b13f786011c9ee` + +## Protected behavior + +Every Task-owned production type and declared public or protected API carries meaningful Javadoc for its business contract. The documented contracts include authorization and lifecycle scope, Project-first/Task-row lock order, non-disclosing HTTP behavior, fixed status transitions, empty progress, actor/history/version invariants, repository filtering and locks, DTO identifier domains and capability flags, the cross-feature activation guard, and dashboard scope/order/limit. + +## Test method + +This is prose and API documentation, so `TST-009` forbids an artificial unit test. Java 25 compilation checks source validity. The Maven Javadoc plugin runs standard doclint against only `com.lab.labtimesheet.feature.task`, making missing or malformed Task API documentation directly observable without treating unrelated feature retrofit work as Task-owned. + +## Hand-derived expected result + +The Task package contains 21 production Java types. Each type has a main description. Every declared public/protected constructor and method has a contract comment; record components document their identifier domains, null/empty meanings, and capability semantics. Task-scoped Javadoc generation completes with no warnings. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -DskipTests -Ddoclint=all -Dsubpackages=com.lab.labtimesheet.feature.task javadoc:javadoc +``` + +**Observed result** + +```text +[WARNING] Javadoc Warnings +[WARNING] Task.java: warning: no main description (12 accessors) +[WARNING] TaskComment.java: warning: no main description (5 accessors) +[WARNING] TaskStatus.java: warning: no comment (4 enum constants) +[WARNING] 21 warnings +[INFO] BUILD SUCCESS +``` + +This was a diagnostic documentation baseline rather than a failing behavioral test. The parent instruction explicitly required doclint/compile instead of a fake test. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -DskipTests -Ddoclint=all -Dsubpackages=com.lab.labtimesheet.feature.task javadoc:javadoc +``` + +**Observed result** + +```text +[INFO] --- javadoc:3.12.0:javadoc (default-cli) @ labtimesheet --- +[INFO] BUILD SUCCESS +``` + +No Task-scoped Javadoc warning was emitted. + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -DskipTests compile + +[INFO] Compiling 112 source files with javac [debug parameters release 25] to target/classes +[INFO] BUILD SUCCESS + +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw clean test + +[INFO] Tests run: 113, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +Doclint validates Javadoc structure and references, not whether prose perfectly models runtime behavior. Contract accuracy was checked by a scoped adversarial diff review against the numbered Task, authorization, Project-lifecycle, UI, and database requirements. Other feature owners retain responsibility for their own Iteration 1 Javadoc retrofits. diff --git a/docs/tests/web/task-pages.md b/docs/tests/web/task-pages.md index 0bd59ae..51cf408 100644 --- a/docs/tests/web/task-pages.md +++ b/docs/tests/web/task-pages.md @@ -1,24 +1,24 @@ # Test Evidence: Task pages and server-side request boundaries - **Test type:** Web -- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-007`, `TSK-011`, `TSK-012` -- **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-005`, `TSK-007`–`TSK-008`, `TSK-011`, `TSK-012`, `UI-014` +- **Scenario IDs:** `I1-TSK-01`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` - **Test class/method:** `com.lab.labtimesheet.feature.task.controller.TaskControllerTest` -- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` +- **Implementation commit:** `fb0ed7f12c9d89235c102b67f2b13f786011c9ee` ## Protected behavior -Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, show `N/A` for an empty Project, display assignees, and expose create/status/comment controls only when the service-provided capability permits them. +Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, show `N/A` for an empty Project, display assignees, and expose create/status/comment controls only when the service-provided capability permits them. The status form exposes only direct edges from the current fixed status graph. An authorized create request with an invalid due date returns the form with the due-date field error, retained safe input, and refreshed authorized assignees; an access failure still returns non-disclosing HTTP 404. ## Test method -Nine `@WebMvcTest` MockMvc tests render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, exception-to-status mapping, assignee output, and capability-controlled actions. Only the PostgreSQL-backed Task service is replaced at the controller boundary. +Fifteen `@WebMvcTest` MockMvc invocations render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, exception-to-status mapping, assignee output, and capability-controlled actions. A four-case parameterized test independently specifies every permitted status choice set. Dedicated create tests distinguish a due-date business validation response from a guessed-Project access response. Only the PostgreSQL-backed Task service is replaced at the controller boundary. ## Hand-derived expected result -Unauthenticated list access returns 401 under the current platform security baseline. An authorized empty list returns 200 and contains `N/A`. A denied guessed Task returns 404. A valid create request passes Project 10, assignee membership 7, the supplied fields, and the authenticated email to the service, then redirects to Task 25. Blank title stays on the form with a field error and no write. Valid status/comment posts redirect to Task 25. +Unauthenticated list access returns 401 under the current platform security baseline. An authorized empty list returns 200 and contains `N/A`. A denied guessed Task or Project returns 404. A valid create request passes Project 10, assignee membership 7, the supplied fields, and the authenticated email to the service, then redirects to Task 25. Blank title stays on the form with a field error and no write. An invalid due date returns 200 with the message attached to `dueDate`, keeps title, description, assignee, and date, and reloads the permitted choices. Valid status/comment posts redirect to Task 25. -When `canCreate`, `canChangeStatus`, or `canComment` is false, the corresponding control is absent. When true, it is rendered. Both list and detail output the assignee display name. +When `canCreate`, `canChangeStatus`, or `canComment` is false, the corresponding control is absent. When true, it is rendered. Both list and detail output the assignee display name. The hand-derived status choices are TODO to IN_PROGRESS/BLOCKED; IN_PROGRESS to BLOCKED/DONE; BLOCKED to TODO/IN_PROGRESS; and DONE to IN_PROGRESS. ## RED @@ -42,6 +42,15 @@ The first sandboxed GREEN attempt then exposed an environment boundary, not an a The later view-capability increment was observed RED at test compilation because the Task DTOs did not yet provide the required capability and assignee fields. +The review-fix increment used the same command and observed these additional production-shaped failures before the controller/form change: + +```text +[ERROR] Tests run: 14, Failures: 5, Errors: 0, Skipped: 0 +[ERROR] invalidDueDateRendersFieldErrorAndRetainsSafeInput: Status expected:<200> but was:<400> +[ERROR] taskDetailsExposeOnlyAllowedStatusTransitions: expected permitted subsets but was:<{TODO, IN_PROGRESS, BLOCKED, DONE}> for all four source states +[INFO] BUILD FAILURE +``` + ## GREEN **Command** @@ -57,7 +66,7 @@ Run with approved sandbox escalation for Mockito Java 25 self-attach. **Observed result** ```text -[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 15, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -69,9 +78,14 @@ Run with approved sandbox escalation for Mockito Java 25 self-attach. export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock -./mvnw test +./mvnw -Dtest=TaskDomainRulesTest,TaskPersistenceStructureTest,TaskMutationBoundaryTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskControllerTest,TaskCreationIntegrationTest test -[INFO] Tests run: 107, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 57, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS + +./mvnw clean test + +[INFO] Tests run: 113, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -79,4 +93,4 @@ The suite ran with approved escalation for OrbStack and Mockito self-attach. ## External-test boundaries -This slice test does not prove PostgreSQL state changes; those are covered by `TaskCreationIntegrationTest`. Shared shell styling/navigation remains owned by `work/reports-ui`. Browser journeys, notifications, Iteration 2 workflows, and narrow-screen behavior are outside this Iteration 1 Task evidence. +This slice test does not prove PostgreSQL state changes; those are covered by `TaskCreationIntegrationTest` in the affected/full commands. Shared shell styling/navigation remains owned by `work/reports-ui`. Browser journeys, notifications, Iteration 2 workflows, and narrow-screen behavior are outside this Iteration 1 Task evidence. diff --git a/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java b/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java index 6838216..572a623 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java @@ -1,13 +1,16 @@ package com.lab.labtimesheet.feature.task.controller; +import com.lab.labtimesheet.feature.task.exception.TaskValidationException; import com.lab.labtimesheet.feature.task.model.TaskProgress; import com.lab.labtimesheet.feature.task.model.TaskStatus; import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand; import com.lab.labtimesheet.feature.task.model.dto.TaskCreateForm; +import com.lab.labtimesheet.feature.task.model.dto.TaskDetails; import com.lab.labtimesheet.feature.task.model.dto.TaskListView; import com.lab.labtimesheet.feature.task.model.dto.TaskView; import com.lab.labtimesheet.feature.task.service.TaskService; import jakarta.validation.Valid; +import java.util.Arrays; import java.util.Locale; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; @@ -19,11 +22,24 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; +/** + * Serves authenticated Task list, create, detail, status, and comment pages. + * + *
The controller delegates record visibility and every mutation authorization decision to + * {@link TaskService}. A denied or guessed Project/Task identifier therefore retains the service's + * non-disclosing HTTP 404 contract. Bean and due-date validation failures return the create form; + * successful mutations use redirects to prevent duplicate submissions. + */ @Controller public class TaskController { private final TaskService taskService; + /** + * Creates the MVC adapter for the Task application service. + * + * @param taskService authorized Task use cases + */ public TaskController(TaskService taskService) { this.taskService = taskService; } @@ -55,14 +71,21 @@ public class TaskController { populateForm(authentication.getName(), projectId, model); return "tasks/form"; } - TaskView task = taskService.create( - authentication.getName(), - new CreateTaskCommand( - projectId, - form.assigneeMembershipId(), - form.title(), - form.description(), - form.dueDate())); + TaskView task; + try { + task = taskService.create( + authentication.getName(), + new CreateTaskCommand( + projectId, + form.assigneeMembershipId(), + form.title(), + form.description(), + form.dueDate())); + } catch (TaskValidationException exception) { + bindingResult.rejectValue("dueDate", "task.dueDate", exception.getMessage()); + populateForm(authentication.getName(), projectId, model); + return "tasks/form"; + } return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id()); } @@ -72,9 +95,12 @@ public class TaskController { @PathVariable long projectId, @PathVariable long taskId, Model model) { + TaskDetails details = taskService.details(authentication.getName(), projectId, taskId); model.addAttribute("projectId", projectId); - model.addAttribute("details", taskService.details(authentication.getName(), projectId, taskId)); - model.addAttribute("statuses", TaskStatus.values()); + model.addAttribute("details", details); + model.addAttribute("statuses", Arrays.stream(TaskStatus.values()) + .filter(details.task().status()::canTransitionTo) + .toList()); return "tasks/detail"; } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java index 00a77bc..db00e32 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java @@ -3,9 +3,16 @@ package com.lab.labtimesheet.feature.task.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; +/** + * Signals a non-disclosing Task or Project lookup/authorization failure. + * + *
MVC maps this exception to HTTP 404 so guessed identifiers do not reveal whether the record + * exists or merely falls outside the authenticated actor's current or historical scope. + */ @ResponseStatus(HttpStatus.NOT_FOUND) public final class TaskNotFoundException extends RuntimeException { + /** Creates the fixed, non-identifying HTTP 404 failure. */ public TaskNotFoundException() { super("Task or Project was not found"); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java index 0c2b66e..0e73821 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java @@ -3,9 +3,21 @@ package com.lab.labtimesheet.feature.task.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; +/** + * Signals that an authorized Task request violates a Task business rule. + * + *
Unadapted MVC uses map this exception to HTTP 400. The create-form controller handles this
+ * exact type locally to associate due-date failures with the field while allowing access failures
+ * to retain their separate HTTP 404 contract.
+ */
@ResponseStatus(HttpStatus.BAD_REQUEST)
public final class TaskValidationException extends RuntimeException {
+ /**
+ * Creates a client-visible validation failure.
+ *
+ * @param message actionable rule violation without protected record details
+ */
public TaskValidationException(String message) {
super(message);
}
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java
index 80fefbe..7b90f79 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java
@@ -3,8 +3,22 @@ package com.lab.labtimesheet.feature.task.model;
import java.util.Collection;
import java.util.OptionalDouble;
+/**
+ * Counts current non-deleted Tasks by fixed status for a single Project.
+ *
+ * @param todo Tasks not yet started
+ * @param inProgress Tasks actively in progress
+ * @param blocked Tasks currently blocked
+ * @param done completed Tasks
+ */
public record TaskProgress(int todo, int inProgress, int blocked, int done) {
+ /**
+ * Counts the supplied current Task statuses.
+ *
+ * @param statuses statuses already filtered to the caller's current Task scope
+ * @return immutable counts for all four statuses
+ */
public static TaskProgress from(Collection The graph is intentionally not configurable: TODO can move to IN_PROGRESS or BLOCKED;
+ * IN_PROGRESS can move to DONE or BLOCKED; BLOCKED can move to TODO or IN_PROGRESS; and DONE can
+ * only reopen to IN_PROGRESS.
+ */
public enum TaskStatus {
+ /** Work has not started. */
TODO,
+ /** Work is actively progressing. */
IN_PROGRESS,
+ /** Work cannot currently proceed. */
BLOCKED,
+ /** Work is complete and may only be reopened to IN_PROGRESS. */
DONE;
+ /**
+ * Tests whether the fixed workflow permits a direct edge to {@code target}.
+ *
+ * @param target requested next state
+ * @return {@code true} only for one of the specified v1 edges
+ */
public boolean canTransitionTo(TaskStatus target) {
return switch (this) {
case TODO -> target == IN_PROGRESS || target == BLOCKED;
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java
index 7ebbd28..a232b91 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java
@@ -2,6 +2,15 @@ package com.lab.labtimesheet.feature.task.model.dto;
import java.time.LocalDate;
+/**
+ * Authenticated request to create one Task in a Project.
+ *
+ * @param projectId Project aggregate identifier
+ * @param assigneeMembershipId same-Project active membership identifier, not a user identifier
+ * @param title required Task title
+ * @param description optional Task description
+ * @param dueDate optional business date constrained by Project dates and the current global calendar
+ */
public record CreateTaskCommand(
long projectId,
long assigneeMembershipId,
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java
index d67e530..a482600 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java
@@ -1,3 +1,9 @@
package com.lab.labtimesheet.feature.task.model.dto;
+/**
+ * One authorized Task assignee option for the create form.
+ *
+ * @param membershipId active same-Project membership identifier, never a user identifier
+ * @param displayName safe display label supplied by the Project feature
+ */
public record TaskAssigneeChoice(long membershipId, String displayName) {}
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java
index 1d3f800..b448b9f 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java
@@ -2,4 +2,13 @@ package com.lab.labtimesheet.feature.task.model.dto;
import java.time.Instant;
+/**
+ * Historical append-only Task comment exposed to authorized Task readers.
+ *
+ * @param id comment identifier
+ * @param taskId owning Task identifier
+ * @param authorUserId historical author user identifier
+ * @param body normalized comment text
+ * @param createdAt persisted creation instant
+ */
public record TaskCommentView(long id, long taskId, long authorUserId, String body, Instant createdAt) {}
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java
index efe9815..6aa2cde 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java
@@ -6,6 +6,14 @@ import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
+/**
+ * Browser-bound Task creation fields.
+ *
+ * @param title required title, limited to 200 characters before service normalization
+ * @param description optional safe text retained after validation
+ * @param assigneeMembershipId selected same-Project membership identifier
+ * @param dueDate optional ISO date; service validation applies Project and calendar rules
+ */
public record TaskCreateForm(
@NotBlank @Size(max = 200) String title,
String description,
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java
index d7ac141..3604350 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java
@@ -2,11 +2,19 @@ package com.lab.labtimesheet.feature.task.model.dto;
import java.util.List;
+/**
+ * Role-scoped Task contribution to the shared dashboard.
+ *
+ * @param blockedTaskCount blocked Tasks in active Projects owned by a Mentor; otherwise zero
+ * @param assignedTaskCount current non-deleted Tasks assigned to an Intern; otherwise zero
+ * @param priorityTasks at most five Intern assignments ordered by due date, null last, then Task ID
+ */
public record TaskDashboardView(
long blockedTaskCount,
long assignedTaskCount,
List Creator attribution never changes. Assignment actor/time describe the current assignment,
+ * deletion is soft and historical, and the JPA version detects conflicting updates. Newly created
+ * Tasks always begin in TODO; status changes must follow the fixed {@link TaskStatus} graph.
+ */
@Entity
@Table(name = "tasks")
public class Task {
@@ -64,8 +71,20 @@ public class Task {
@Version
private long version;
+ /** Constructor reserved for JPA materialization. */
protected Task() {}
+ /**
+ * Creates a TODO Task and records the creating membership as both creator and assigner.
+ *
+ * @param projectId owning Project identifier
+ * @param assigneeMembershipId active membership identifier in the same Project
+ * @param title normalized required title
+ * @param description optional normalized description
+ * @param dueDate optional validated business due date
+ * @param actorMembershipId authenticated creating membership identifier
+ * @param now server-controlled creation and assignment instant
+ */
public Task(
long projectId,
long assigneeMembershipId,
@@ -87,6 +106,13 @@ public class Task {
this.updatedAt = now;
}
+ /**
+ * Applies one permitted fixed-graph status transition and advances the update timestamp.
+ *
+ * @param target next Task status
+ * @param now server-controlled mutation instant
+ * @throws IllegalArgumentException when the requested direct transition is forbidden
+ */
public void changeStatus(TaskStatus target, Instant now) {
if (!status.canTransitionTo(target)) {
throw new IllegalArgumentException("Task status transition is not allowed");
@@ -95,50 +121,110 @@ public class Task {
updatedAt = now;
}
+ /**
+ * Returns the persistence identity.
+ *
+ * @return Task identifier, or {@code null} before insertion
+ */
public Long getId() {
return id;
}
+ /**
+ * Returns the aggregate identity.
+ *
+ * @return owning Project identifier
+ */
public long getProjectId() {
return projectId;
}
+ /**
+ * Returns the current assignment identity.
+ *
+ * @return current same-Project assignee membership identifier
+ */
public long getAssigneeMembershipId() {
return assigneeMembershipId;
}
+ /**
+ * Returns the display title.
+ *
+ * @return normalized Task title
+ */
public String getTitle() {
return title;
}
+ /**
+ * Returns the descriptive text.
+ *
+ * @return optional normalized description
+ */
public String getDescription() {
return description;
}
+ /**
+ * Returns the workflow state.
+ *
+ * @return current fixed workflow status
+ */
public TaskStatus getStatus() {
return status;
}
+ /**
+ * Returns the business deadline.
+ *
+ * @return optional validated due date
+ */
public LocalDate getDueDate() {
return dueDate;
}
+ /**
+ * Returns current assignment timing.
+ *
+ * @return instant when the current assignment was established
+ */
public Instant getAssignedAt() {
return assignedAt;
}
+ /**
+ * Returns original creator attribution.
+ *
+ * @return immutable creating membership identifier
+ */
public long getCreatorMembershipId() {
return creatorMembershipId;
}
+ /**
+ * Returns current assignment attribution.
+ *
+ * @return membership identifier responsible for the current assignment
+ */
public long getAssignerMembershipId() {
return assignerMembershipId;
}
+ /**
+ * Returns lifecycle visibility state.
+ *
+ * @return soft-deletion instant, or {@code null} while current
+ */
public Instant getDeletedAt() {
return deletedAt;
}
+ /**
+ * Returns creation timing.
+ *
+ * @return immutable creation instant
+ */
public Instant getCreatedAt() {
return createdAt;
}
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java
index 6e3b15e..5de025b 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java
@@ -8,6 +8,12 @@ import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+/**
+ * Persisted append-only Task comment.
+ *
+ * The author is retained as a user identifier so membership or leadership changes do not move
+ * historical attribution. This entity intentionally exposes no edit or delete operation.
+ */
@Entity
@Table(name = "task_comments")
public class TaskComment {
@@ -28,8 +34,17 @@ public class TaskComment {
@Column(name = "created_at", nullable = false)
private Instant createdAt;
+ /** Constructor reserved for JPA materialization. */
protected TaskComment() {}
+ /**
+ * Creates an immutable comment from server-authorized values.
+ *
+ * @param taskId owning Task identifier
+ * @param authorUserId authenticated historical author user identifier
+ * @param body normalized non-blank comment text
+ * @param createdAt server-controlled creation instant
+ */
public TaskComment(long taskId, long authorUserId, String body, Instant createdAt) {
this.taskId = taskId;
this.authorUserId = authorUserId;
@@ -37,22 +52,47 @@ public class TaskComment {
this.createdAt = createdAt;
}
+ /**
+ * Returns the persistence identity.
+ *
+ * @return comment identifier, or {@code null} before insertion
+ */
public Long getId() {
return id;
}
+ /**
+ * Returns the owning record identity.
+ *
+ * @return owning Task identifier
+ */
public long getTaskId() {
return taskId;
}
+ /**
+ * Returns historical authorship.
+ *
+ * @return immutable historical author user identifier
+ */
public long getAuthorUserId() {
return authorUserId;
}
+ /**
+ * Returns comment content.
+ *
+ * @return normalized comment text
+ */
public String getBody() {
return body;
}
+ /**
+ * Returns creation timing.
+ *
+ * @return immutable creation instant
+ */
public Instant getCreatedAt() {
return createdAt;
}
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java
index f0073fc..b9ebcb8 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java
@@ -4,7 +4,14 @@ import com.lab.labtimesheet.feature.task.model.entity.TaskComment;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
+/** JPA persistence boundary for append-only Task comments. */
public interface TaskCommentRepository extends JpaRepository Normal reads consistently exclude soft-deleted rows. Mutation callers acquire the owning
+ * Project lock before requesting the Task row lock so aggregate and Task lock order remains stable.
+ */
public interface TaskRepository extends JpaRepository Ordering is due date ascending, null due dates last, then Task identifier ascending. The
+ * supplied page bounds how many rows are returned.
+ *
+ * @param projectIds authorized active Project identifiers
+ * @param assigneeMembershipIds actor's current memberships in those Projects
+ * @param pageable result limit
+ * @return ordered non-deleted Tasks
+ */
@Query("""
select task
from Task task
@@ -43,6 +100,17 @@ public interface TaskRepository extends JpaRepository The Project activation caller is responsible for holding the Project write lock through
+ * its decision. Empty membership sets are handled by {@code TaskQueryService} rather than this
+ * {@code NOT IN} query.
+ *
+ * @param projectId locked Project identifier
+ * @param activeMembershipIds non-empty active same-Project membership identifiers
+ * @return non-deleted Tasks assigned outside the supplied set
+ */
@Query("""
select count(task)
from Task task
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java
index 9540de2..3a06dd8 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java
@@ -15,6 +15,13 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+/**
+ * Supplies the Task-owned portion of the shared role dashboard.
+ *
+ * Project visibility, role, and active membership facts come only from the Project service DTO
+ * boundary. Mentor counts cover blocked current Tasks in visible active Projects. Intern counts and
+ * priorities cover current assignments only where the actor still has an active membership.
+ */
@Service
public class TaskDashboardService {
@@ -23,11 +30,27 @@ public class TaskDashboardService {
private final TaskRepository tasks;
private final ProjectQueryService projects;
+ /**
+ * Creates the dashboard query service.
+ *
+ * @param tasks Task persistence boundary
+ * @param projects authorized Project query boundary
+ */
public TaskDashboardService(TaskRepository tasks, ProjectQueryService projects) {
this.tasks = tasks;
this.projects = projects;
}
+ /**
+ * Builds the role-scoped Task dashboard for one authenticated account.
+ *
+ * Mentors receive the blocked count for active Projects they can see. Interns receive their
+ * non-deleted assignment count and at most five priority Tasks, ordered by due date ascending,
+ * null due dates last, and Task identifier ascending. Other roles receive zero/empty values.
+ *
+ * @param actorEmail authenticated account email
+ * @return immutable role-appropriate Task dashboard data
+ */
@Transactional(readOnly = true)
public TaskDashboardView dashboard(String actorEmail) {
var actor = projects.authenticatedActor(actorEmail);
diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java
index e469cf3..a50f814 100644
--- a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java
+++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java
@@ -5,15 +5,35 @@ import java.util.Set;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+/**
+ * Public Task query boundary used by other features without exposing Task entities or repositories.
+ */
@Service
public class TaskQueryService {
private final TaskRepository tasks;
+ /**
+ * Creates the cross-feature Task query service.
+ *
+ * @param tasks Task persistence boundary
+ */
public TaskQueryService(TaskRepository tasks) {
this.tasks = tasks;
}
+ /**
+ * Counts current Tasks assigned outside the supplied active membership set.
+ *
+ * Only non-deleted Tasks are considered. An empty set means every current Task is invalid
+ * and avoids an empty {@code NOT IN} predicate. This method joins an existing transaction; a
+ * Project lifecycle caller must acquire and retain the Project write lock before calling it so
+ * the assignee guard remains stable through the Project decision and commit.
+ *
+ * @param projectId Project whose current Task assignments are being validated
+ * @param activeMembershipIds current eligible same-Project membership identifiers
+ * @return number of current Tasks whose assignee is not in the supplied set
+ */
@Transactional(readOnly = true)
public long countCurrentTasksAssignedOutside(long projectId, Set Project identity, lifecycle, ownership, leadership, and membership are obtained through
+ * Project service DTOs. Mutations lock and re-evaluate the Project first; status/comment mutations
+ * then lock the Task row, preserving the Project-to-Task lock order. Access failures are translated
+ * to a non-disclosing Task 404, while authenticated business-rule failures use Task validation.
+ */
@Service
public class TaskService {
@@ -43,6 +51,16 @@ public class TaskService {
private final CalendarApplicationService calendar;
private final Clock clock;
+ /**
+ * Creates the Task application service and its feature boundaries.
+ *
+ * @param tasks Task persistence boundary
+ * @param comments append-only comment persistence boundary
+ * @param projects authorized Project read boundary
+ * @param projectMutations Project-first locking mutation boundary
+ * @param calendar authoritative global day-off query boundary
+ * @param clock server time source for persisted instants
+ */
public TaskService(
TaskRepository tasks,
TaskCommentRepository comments,
@@ -58,6 +76,20 @@ public class TaskService {
this.clock = clock;
}
+ /**
+ * Creates a TODO Task in a PLANNED or ACTIVE Project.
+ *
+ * The Project is write-locked before membership and lifecycle checks. A current Leader may
+ * choose any active same-Project member; another active member may choose only themselves.
+ * Creator, assigner, and assignment time are stored from authenticated current context. An
+ * optional due date must be within Project dates and not a current global day off.
+ *
+ * @param actorEmail authenticated account email
+ * @param command requested Project, membership, and Task fields
+ * @return created Task projection
+ * @throws TaskNotFoundException when current authorization/context is absent
+ * @throws TaskValidationException when title or due date violates a business rule
+ */
@Transactional
public TaskView create(String actorEmail, CreateTaskCommand command) {
String title = requireTitle(command.title());
@@ -84,6 +116,21 @@ public class TaskService {
return view(tasks.saveAndFlush(task), assignee.displayName());
}
+ /**
+ * Changes an ACTIVE Project Task through one fixed workflow edge.
+ *
+ * The transaction locks the Project before the Task row and permits only the current
+ * assignee membership to mutate status. Neither leadership nor a global role substitutes for
+ * assignment authority.
+ *
+ * @param actorEmail authenticated account email
+ * @param projectId owning Project identifier
+ * @param taskId Task identifier within that Project
+ * @param target requested next status
+ * @return updated Task projection
+ * @throws TaskNotFoundException when scope, lifecycle, assignment, or identifiers are invalid
+ * @throws TaskValidationException when the requested status edge is forbidden
+ */
@Transactional
public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) {
TaskAccess access = requireMutationAccess(actorEmail, projectId);
@@ -103,6 +150,21 @@ public class TaskService {
return view(tasks.saveAndFlush(task), actorMembership.displayName());
}
+ /**
+ * Appends a comment to a current Task before Project completion.
+ *
+ * The transaction locks the Project before the Task row. The owning Mentor or any active
+ * member may comment; the persisted author is the authenticated user so later membership or
+ * leadership changes do not alter history.
+ *
+ * @param actorEmail authenticated account email
+ * @param projectId owning Project identifier
+ * @param taskId Task identifier within that Project
+ * @param body required comment text
+ * @return created append-only comment projection
+ * @throws TaskNotFoundException when current authorization, lifecycle, or identifiers are invalid
+ * @throws TaskValidationException when the normalized body is empty
+ */
@Transactional
public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) {
String normalizedBody = requireCommentBody(body);
@@ -123,6 +185,18 @@ public class TaskService {
return view(comments.saveAndFlush(comment));
}
+ /**
+ * Lists current Tasks and progress for an authorized Project reader.
+ *
+ * Soft-deleted Tasks are excluded. Active members may read open Projects; former members may
+ * read only a completed Project in which they historically participated. Empty current Task
+ * sets produce empty completion percentage semantics. The create capability is server-derived.
+ *
+ * @param actorEmail authenticated account email
+ * @param projectId Project identifier
+ * @return visible Tasks, progress counts, and create capability
+ * @throws TaskNotFoundException when the Project is outside the actor's authorized scope
+ */
@Transactional(readOnly = true)
public TaskListView list(String actorEmail, long projectId) {
TaskAccess access = requireReadableProject(actorEmail, projectId);
@@ -137,6 +211,19 @@ public class TaskService {
isOpen(access.project()) && activeMembership(access) != null);
}
+ /**
+ * Loads one current Task, append-only comments, and server-derived action capabilities.
+ *
+ * Status capability requires the ACTIVE Project's current assignee. Comment capability
+ * requires an active member or owning Mentor before completion. Historical completed-Project
+ * readers receive details with no mutation capability.
+ *
+ * @param actorEmail authenticated account email
+ * @param projectId owning Project identifier
+ * @param taskId Task identifier within that Project
+ * @return authorized detail projection
+ * @throws TaskNotFoundException when scope or identifiers are invalid
+ */
@Transactional(readOnly = true)
public TaskDetails details(String actorEmail, long projectId, long taskId) {
TaskAccess access = requireReadableProject(actorEmail, projectId);
@@ -157,6 +244,18 @@ public class TaskService {
return new TaskDetails(task, taskComments, canChangeStatus, canComment);
}
+ /**
+ * Returns assignee options for an authorized Task create form.
+ *
+ * A current Leader receives every active same-Project membership; another active member
+ * receives only their own membership. Completed Projects and non-members are denied without
+ * disclosing Project membership data.
+ *
+ * @param actorEmail authenticated account email
+ * @param projectId Project identifier
+ * @return authorized membership choices
+ * @throws TaskNotFoundException when current authorization or lifecycle is invalid
+ */
@Transactional(readOnly = true)
public List