Merge commit 'e38e2cdea912160b183c65398c4e8d5682c1b00e' into work/reports-ui
# Conflicts: # src/main/resources/templates/tasks/form.html
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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
|
||||
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,7 +71,9 @@ public class TaskController {
|
||||
populateForm(authentication.getName(), projectId, model);
|
||||
return "tasks/form";
|
||||
}
|
||||
TaskView task = taskService.create(
|
||||
TaskView task;
|
||||
try {
|
||||
task = taskService.create(
|
||||
authentication.getName(),
|
||||
new CreateTaskCommand(
|
||||
projectId,
|
||||
@@ -63,6 +81,11 @@ public class TaskController {
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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)
|
||||
public final class TaskNotFoundException extends RuntimeException {
|
||||
|
||||
/** Creates the fixed, non-identifying HTTP 404 failure. */
|
||||
public TaskNotFoundException() {
|
||||
super("Task or Project was not found");
|
||||
}
|
||||
|
||||
+12
@@ -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.
|
||||
*
|
||||
* <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)
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<TaskStatus> 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());
|
||||
}
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
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 {
|
||||
/** 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TaskPriorityView> priorityTasks) {
|
||||
|
||||
/** Copies the priority list so downstream UI code cannot mutate the service result. */
|
||||
public TaskDashboardView {
|
||||
priorityTasks = List.copyOf(priorityTasks);
|
||||
}
|
||||
|
||||
@@ -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<TaskCommentView> 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);
|
||||
}
|
||||
|
||||
@@ -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<TaskView> tasks, TaskProgress progress, boolean canCreate) {
|
||||
|
||||
/** Copies the Task list so view consumers cannot alter the authorized result. */
|
||||
public TaskListView {
|
||||
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 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
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
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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> {
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* 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<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);
|
||||
|
||||
/**
|
||||
* 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<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(
|
||||
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("""
|
||||
select task
|
||||
from Task task
|
||||
@@ -43,6 +100,17 @@ public interface TaskRepository extends JpaRepository<Task, Long> {
|
||||
@Param("assigneeMembershipIds") List<Long> assigneeMembershipIds,
|
||||
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("""
|
||||
select count(task)
|
||||
from Task task
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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
|
||||
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.
|
||||
*
|
||||
* <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)
|
||||
public TaskDashboardView dashboard(String actorEmail) {
|
||||
var actor = projects.authenticatedActor(actorEmail);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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)
|
||||
public long countCurrentTasksAssignedOutside(long projectId, Set<Long> activeMembershipIds) {
|
||||
if (activeMembershipIds.isEmpty()) {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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
|
||||
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.
|
||||
*
|
||||
* <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
|
||||
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.
|
||||
*
|
||||
* <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
|
||||
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.
|
||||
*
|
||||
* <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
|
||||
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.
|
||||
*
|
||||
* <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)
|
||||
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.
|
||||
*
|
||||
* <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)
|
||||
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.
|
||||
*
|
||||
* <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)
|
||||
public List<TaskAssigneeChoice> assignmentChoices(String actorEmail, long projectId) {
|
||||
TaskAccess access = requireProjectAccess(actorEmail, projectId);
|
||||
|
||||
@@ -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<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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user