fix(task): harden task forms and document contracts
This commit is contained in:
@@ -1,13 +1,16 @@
|
|||||||
package com.lab.labtimesheet.feature.task.controller;
|
package com.lab.labtimesheet.feature.task.controller;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.feature.task.exception.TaskValidationException;
|
||||||
import com.lab.labtimesheet.feature.task.model.TaskProgress;
|
import com.lab.labtimesheet.feature.task.model.TaskProgress;
|
||||||
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
||||||
import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
|
import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
|
||||||
import com.lab.labtimesheet.feature.task.model.dto.TaskCreateForm;
|
import com.lab.labtimesheet.feature.task.model.dto.TaskCreateForm;
|
||||||
|
import com.lab.labtimesheet.feature.task.model.dto.TaskDetails;
|
||||||
import com.lab.labtimesheet.feature.task.model.dto.TaskListView;
|
import com.lab.labtimesheet.feature.task.model.dto.TaskListView;
|
||||||
import com.lab.labtimesheet.feature.task.model.dto.TaskView;
|
import com.lab.labtimesheet.feature.task.model.dto.TaskView;
|
||||||
import com.lab.labtimesheet.feature.task.service.TaskService;
|
import com.lab.labtimesheet.feature.task.service.TaskService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
@@ -19,11 +22,24 @@ import org.springframework.web.bind.annotation.PathVariable;
|
|||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves authenticated Task list, create, detail, status, and comment pages.
|
||||||
|
*
|
||||||
|
* <p>The controller delegates record visibility and every mutation authorization decision to
|
||||||
|
* {@link TaskService}. A denied or guessed Project/Task identifier therefore retains the service's
|
||||||
|
* non-disclosing HTTP 404 contract. Bean and due-date validation failures return the create form;
|
||||||
|
* successful mutations use redirects to prevent duplicate submissions.
|
||||||
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
public class TaskController {
|
public class TaskController {
|
||||||
|
|
||||||
private final TaskService taskService;
|
private final TaskService taskService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the MVC adapter for the Task application service.
|
||||||
|
*
|
||||||
|
* @param taskService authorized Task use cases
|
||||||
|
*/
|
||||||
public TaskController(TaskService taskService) {
|
public TaskController(TaskService taskService) {
|
||||||
this.taskService = taskService;
|
this.taskService = taskService;
|
||||||
}
|
}
|
||||||
@@ -55,7 +71,9 @@ public class TaskController {
|
|||||||
populateForm(authentication.getName(), projectId, model);
|
populateForm(authentication.getName(), projectId, model);
|
||||||
return "tasks/form";
|
return "tasks/form";
|
||||||
}
|
}
|
||||||
TaskView task = taskService.create(
|
TaskView task;
|
||||||
|
try {
|
||||||
|
task = taskService.create(
|
||||||
authentication.getName(),
|
authentication.getName(),
|
||||||
new CreateTaskCommand(
|
new CreateTaskCommand(
|
||||||
projectId,
|
projectId,
|
||||||
@@ -63,6 +81,11 @@ public class TaskController {
|
|||||||
form.title(),
|
form.title(),
|
||||||
form.description(),
|
form.description(),
|
||||||
form.dueDate()));
|
form.dueDate()));
|
||||||
|
} catch (TaskValidationException exception) {
|
||||||
|
bindingResult.rejectValue("dueDate", "task.dueDate", exception.getMessage());
|
||||||
|
populateForm(authentication.getName(), projectId, model);
|
||||||
|
return "tasks/form";
|
||||||
|
}
|
||||||
return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id());
|
return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,9 +95,12 @@ public class TaskController {
|
|||||||
@PathVariable long projectId,
|
@PathVariable long projectId,
|
||||||
@PathVariable long taskId,
|
@PathVariable long taskId,
|
||||||
Model model) {
|
Model model) {
|
||||||
|
TaskDetails details = taskService.details(authentication.getName(), projectId, taskId);
|
||||||
model.addAttribute("projectId", projectId);
|
model.addAttribute("projectId", projectId);
|
||||||
model.addAttribute("details", taskService.details(authentication.getName(), projectId, taskId));
|
model.addAttribute("details", details);
|
||||||
model.addAttribute("statuses", TaskStatus.values());
|
model.addAttribute("statuses", Arrays.stream(TaskStatus.values())
|
||||||
|
.filter(details.task().status()::canTransitionTo)
|
||||||
|
.toList());
|
||||||
return "tasks/detail";
|
return "tasks/detail";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ package com.lab.labtimesheet.feature.task.exception;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals a non-disclosing Task or Project lookup/authorization failure.
|
||||||
|
*
|
||||||
|
* <p>MVC maps this exception to HTTP 404 so guessed identifiers do not reveal whether the record
|
||||||
|
* exists or merely falls outside the authenticated actor's current or historical scope.
|
||||||
|
*/
|
||||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||||
public final class TaskNotFoundException extends RuntimeException {
|
public final class TaskNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
/** Creates the fixed, non-identifying HTTP 404 failure. */
|
||||||
public TaskNotFoundException() {
|
public TaskNotFoundException() {
|
||||||
super("Task or Project was not found");
|
super("Task or Project was not found");
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -3,9 +3,21 @@ package com.lab.labtimesheet.feature.task.exception;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals that an authorized Task request violates a Task business rule.
|
||||||
|
*
|
||||||
|
* <p>Unadapted MVC uses map this exception to HTTP 400. The create-form controller handles this
|
||||||
|
* exact type locally to associate due-date failures with the field while allowing access failures
|
||||||
|
* to retain their separate HTTP 404 contract.
|
||||||
|
*/
|
||||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
public final class TaskValidationException extends RuntimeException {
|
public final class TaskValidationException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a client-visible validation failure.
|
||||||
|
*
|
||||||
|
* @param message actionable rule violation without protected record details
|
||||||
|
*/
|
||||||
public TaskValidationException(String message) {
|
public TaskValidationException(String message) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,22 @@ package com.lab.labtimesheet.feature.task.model;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.OptionalDouble;
|
import java.util.OptionalDouble;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts current non-deleted Tasks by fixed status for a single Project.
|
||||||
|
*
|
||||||
|
* @param todo Tasks not yet started
|
||||||
|
* @param inProgress Tasks actively in progress
|
||||||
|
* @param blocked Tasks currently blocked
|
||||||
|
* @param done completed Tasks
|
||||||
|
*/
|
||||||
public record TaskProgress(int todo, int inProgress, int blocked, int done) {
|
public record TaskProgress(int todo, int inProgress, int blocked, int done) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the supplied current Task statuses.
|
||||||
|
*
|
||||||
|
* @param statuses statuses already filtered to the caller's current Task scope
|
||||||
|
* @return immutable counts for all four statuses
|
||||||
|
*/
|
||||||
public static TaskProgress from(Collection<TaskStatus> statuses) {
|
public static TaskProgress from(Collection<TaskStatus> statuses) {
|
||||||
int todo = 0;
|
int todo = 0;
|
||||||
int inProgress = 0;
|
int inProgress = 0;
|
||||||
@@ -21,10 +35,21 @@ public record TaskProgress(int todo, int inProgress, int blocked, int done) {
|
|||||||
return new TaskProgress(todo, inProgress, blocked, done);
|
return new TaskProgress(todo, inProgress, blocked, done);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the denominator used for Project completion progress.
|
||||||
|
*
|
||||||
|
* @return total number of counted Tasks
|
||||||
|
*/
|
||||||
public int total() {
|
public int total() {
|
||||||
return todo + inProgress + blocked + done;
|
return todo + inProgress + blocked + done;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the count for one fixed status.
|
||||||
|
*
|
||||||
|
* @param status status to inspect
|
||||||
|
* @return number of Tasks in that status
|
||||||
|
*/
|
||||||
public int count(TaskStatus status) {
|
public int count(TaskStatus status) {
|
||||||
return switch (status) {
|
return switch (status) {
|
||||||
case TODO -> todo;
|
case TODO -> todo;
|
||||||
@@ -34,6 +59,11 @@ public record TaskProgress(int todo, int inProgress, int blocked, int done) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes DONE Tasks as a percentage of all counted Tasks.
|
||||||
|
*
|
||||||
|
* @return an empty value when the Project has no current Tasks, otherwise a value from 0 to 100
|
||||||
|
*/
|
||||||
public OptionalDouble completionPercentage() {
|
public OptionalDouble completionPercentage() {
|
||||||
return total() == 0 ? OptionalDouble.empty() : OptionalDouble.of(done * 100.0 / total());
|
return total() == 0 ? OptionalDouble.empty() : OptionalDouble.of(done * 100.0 / total());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
package com.lab.labtimesheet.feature.task.model;
|
package com.lab.labtimesheet.feature.task.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed v1 Task workflow states.
|
||||||
|
*
|
||||||
|
* <p>The graph is intentionally not configurable: TODO can move to IN_PROGRESS or BLOCKED;
|
||||||
|
* IN_PROGRESS can move to DONE or BLOCKED; BLOCKED can move to TODO or IN_PROGRESS; and DONE can
|
||||||
|
* only reopen to IN_PROGRESS.
|
||||||
|
*/
|
||||||
public enum TaskStatus {
|
public enum TaskStatus {
|
||||||
|
/** Work has not started. */
|
||||||
TODO,
|
TODO,
|
||||||
|
/** Work is actively progressing. */
|
||||||
IN_PROGRESS,
|
IN_PROGRESS,
|
||||||
|
/** Work cannot currently proceed. */
|
||||||
BLOCKED,
|
BLOCKED,
|
||||||
|
/** Work is complete and may only be reopened to IN_PROGRESS. */
|
||||||
DONE;
|
DONE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests whether the fixed workflow permits a direct edge to {@code target}.
|
||||||
|
*
|
||||||
|
* @param target requested next state
|
||||||
|
* @return {@code true} only for one of the specified v1 edges
|
||||||
|
*/
|
||||||
public boolean canTransitionTo(TaskStatus target) {
|
public boolean canTransitionTo(TaskStatus target) {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
case TODO -> target == IN_PROGRESS || target == BLOCKED;
|
case TODO -> target == IN_PROGRESS || target == BLOCKED;
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ package com.lab.labtimesheet.feature.task.model.dto;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated request to create one Task in a Project.
|
||||||
|
*
|
||||||
|
* @param projectId Project aggregate identifier
|
||||||
|
* @param assigneeMembershipId same-Project active membership identifier, not a user identifier
|
||||||
|
* @param title required Task title
|
||||||
|
* @param description optional Task description
|
||||||
|
* @param dueDate optional business date constrained by Project dates and the current global calendar
|
||||||
|
*/
|
||||||
public record CreateTaskCommand(
|
public record CreateTaskCommand(
|
||||||
long projectId,
|
long projectId,
|
||||||
long assigneeMembershipId,
|
long assigneeMembershipId,
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
package com.lab.labtimesheet.feature.task.model.dto;
|
package com.lab.labtimesheet.feature.task.model.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One authorized Task assignee option for the create form.
|
||||||
|
*
|
||||||
|
* @param membershipId active same-Project membership identifier, never a user identifier
|
||||||
|
* @param displayName safe display label supplied by the Project feature
|
||||||
|
*/
|
||||||
public record TaskAssigneeChoice(long membershipId, String displayName) {}
|
public record TaskAssigneeChoice(long membershipId, String displayName) {}
|
||||||
|
|||||||
@@ -2,4 +2,13 @@ package com.lab.labtimesheet.feature.task.model.dto;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Historical append-only Task comment exposed to authorized Task readers.
|
||||||
|
*
|
||||||
|
* @param id comment identifier
|
||||||
|
* @param taskId owning Task identifier
|
||||||
|
* @param authorUserId historical author user identifier
|
||||||
|
* @param body normalized comment text
|
||||||
|
* @param createdAt persisted creation instant
|
||||||
|
*/
|
||||||
public record TaskCommentView(long id, long taskId, long authorUserId, String body, Instant createdAt) {}
|
public record TaskCommentView(long id, long taskId, long authorUserId, String body, Instant createdAt) {}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import jakarta.validation.constraints.Size;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser-bound Task creation fields.
|
||||||
|
*
|
||||||
|
* @param title required title, limited to 200 characters before service normalization
|
||||||
|
* @param description optional safe text retained after validation
|
||||||
|
* @param assigneeMembershipId selected same-Project membership identifier
|
||||||
|
* @param dueDate optional ISO date; service validation applies Project and calendar rules
|
||||||
|
*/
|
||||||
public record TaskCreateForm(
|
public record TaskCreateForm(
|
||||||
@NotBlank @Size(max = 200) String title,
|
@NotBlank @Size(max = 200) String title,
|
||||||
String description,
|
String description,
|
||||||
|
|||||||
@@ -2,11 +2,19 @@ package com.lab.labtimesheet.feature.task.model.dto;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role-scoped Task contribution to the shared dashboard.
|
||||||
|
*
|
||||||
|
* @param blockedTaskCount blocked Tasks in active Projects owned by a Mentor; otherwise zero
|
||||||
|
* @param assignedTaskCount current non-deleted Tasks assigned to an Intern; otherwise zero
|
||||||
|
* @param priorityTasks at most five Intern assignments ordered by due date, null last, then Task ID
|
||||||
|
*/
|
||||||
public record TaskDashboardView(
|
public record TaskDashboardView(
|
||||||
long blockedTaskCount,
|
long blockedTaskCount,
|
||||||
long assignedTaskCount,
|
long assignedTaskCount,
|
||||||
List<TaskPriorityView> priorityTasks) {
|
List<TaskPriorityView> priorityTasks) {
|
||||||
|
|
||||||
|
/** Copies the priority list so downstream UI code cannot mutate the service result. */
|
||||||
public TaskDashboardView {
|
public TaskDashboardView {
|
||||||
priorityTasks = List.copyOf(priorityTasks);
|
priorityTasks = List.copyOf(priorityTasks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,21 @@ package com.lab.labtimesheet.feature.task.model.dto;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorized Task detail view and server-derived action capabilities.
|
||||||
|
*
|
||||||
|
* @param task visible non-deleted Task
|
||||||
|
* @param comments append-only comment history in creation order
|
||||||
|
* @param canChangeStatus true only for the current assignee of an ACTIVE Project
|
||||||
|
* @param canComment true only for an eligible active member or owning Mentor before completion
|
||||||
|
*/
|
||||||
public record TaskDetails(
|
public record TaskDetails(
|
||||||
TaskView task,
|
TaskView task,
|
||||||
List<TaskCommentView> comments,
|
List<TaskCommentView> comments,
|
||||||
boolean canChangeStatus,
|
boolean canChangeStatus,
|
||||||
boolean canComment) {
|
boolean canComment) {
|
||||||
|
|
||||||
|
/** Copies the comment list so historical output cannot be modified by a view consumer. */
|
||||||
public TaskDetails {
|
public TaskDetails {
|
||||||
comments = List.copyOf(comments);
|
comments = List.copyOf(comments);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,16 @@ package com.lab.labtimesheet.feature.task.model.dto;
|
|||||||
import com.lab.labtimesheet.feature.task.model.TaskProgress;
|
import com.lab.labtimesheet.feature.task.model.TaskProgress;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorized current Task list with aggregate progress and create capability.
|
||||||
|
*
|
||||||
|
* @param tasks non-deleted Tasks ordered by Task identifier
|
||||||
|
* @param progress status counts derived from exactly {@code tasks}; empty lists produce N/A progress
|
||||||
|
* @param canCreate true only for an active member while the Project is PLANNED or ACTIVE
|
||||||
|
*/
|
||||||
public record TaskListView(List<TaskView> tasks, TaskProgress progress, boolean canCreate) {
|
public record TaskListView(List<TaskView> tasks, TaskProgress progress, boolean canCreate) {
|
||||||
|
|
||||||
|
/** Copies the Task list so view consumers cannot alter the authorized result. */
|
||||||
public TaskListView {
|
public TaskListView {
|
||||||
tasks = List.copyOf(tasks);
|
tasks = List.copyOf(tasks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ package com.lab.labtimesheet.feature.task.model.dto;
|
|||||||
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact current assignment rendered on an Intern dashboard.
|
||||||
|
*
|
||||||
|
* @param title Task title
|
||||||
|
* @param projectName Project display name from the Project service boundary
|
||||||
|
* @param status current fixed workflow status
|
||||||
|
* @param dueDate optional Task due date
|
||||||
|
*/
|
||||||
public record TaskPriorityView(
|
public record TaskPriorityView(
|
||||||
String title,
|
String title,
|
||||||
String projectName,
|
String projectName,
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorized current Task projection with immutable creator and assignment attribution.
|
||||||
|
*
|
||||||
|
* @param id Task identifier
|
||||||
|
* @param projectId owning Project identifier
|
||||||
|
* @param assigneeMembershipId current same-Project assignee membership identifier
|
||||||
|
* @param assigneeName current or historical assignee name supplied by the Project feature
|
||||||
|
* @param title Task title
|
||||||
|
* @param description optional description
|
||||||
|
* @param status current fixed workflow status
|
||||||
|
* @param dueDate optional due date
|
||||||
|
* @param creatorMembershipId immutable creating membership identifier
|
||||||
|
* @param assignerMembershipId membership identifier responsible for the current assignment
|
||||||
|
* @param assignedAt instant the current assignment was established
|
||||||
|
* @param createdAt immutable Task creation instant
|
||||||
|
*/
|
||||||
public record TaskView(
|
public record TaskView(
|
||||||
long id,
|
long id,
|
||||||
long projectId,
|
long projectId,
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ import jakarta.persistence.Version;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted Task aggregate row with one current same-Project assignee.
|
||||||
|
*
|
||||||
|
* <p>Creator attribution never changes. Assignment actor/time describe the current assignment,
|
||||||
|
* deletion is soft and historical, and the JPA version detects conflicting updates. Newly created
|
||||||
|
* Tasks always begin in TODO; status changes must follow the fixed {@link TaskStatus} graph.
|
||||||
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "tasks")
|
@Table(name = "tasks")
|
||||||
public class Task {
|
public class Task {
|
||||||
@@ -64,8 +71,20 @@ public class Task {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
|
/** Constructor reserved for JPA materialization. */
|
||||||
protected Task() {}
|
protected Task() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a TODO Task and records the creating membership as both creator and assigner.
|
||||||
|
*
|
||||||
|
* @param projectId owning Project identifier
|
||||||
|
* @param assigneeMembershipId active membership identifier in the same Project
|
||||||
|
* @param title normalized required title
|
||||||
|
* @param description optional normalized description
|
||||||
|
* @param dueDate optional validated business due date
|
||||||
|
* @param actorMembershipId authenticated creating membership identifier
|
||||||
|
* @param now server-controlled creation and assignment instant
|
||||||
|
*/
|
||||||
public Task(
|
public Task(
|
||||||
long projectId,
|
long projectId,
|
||||||
long assigneeMembershipId,
|
long assigneeMembershipId,
|
||||||
@@ -87,6 +106,13 @@ public class Task {
|
|||||||
this.updatedAt = now;
|
this.updatedAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies one permitted fixed-graph status transition and advances the update timestamp.
|
||||||
|
*
|
||||||
|
* @param target next Task status
|
||||||
|
* @param now server-controlled mutation instant
|
||||||
|
* @throws IllegalArgumentException when the requested direct transition is forbidden
|
||||||
|
*/
|
||||||
public void changeStatus(TaskStatus target, Instant now) {
|
public void changeStatus(TaskStatus target, Instant now) {
|
||||||
if (!status.canTransitionTo(target)) {
|
if (!status.canTransitionTo(target)) {
|
||||||
throw new IllegalArgumentException("Task status transition is not allowed");
|
throw new IllegalArgumentException("Task status transition is not allowed");
|
||||||
@@ -95,50 +121,110 @@ public class Task {
|
|||||||
updatedAt = now;
|
updatedAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the persistence identity.
|
||||||
|
*
|
||||||
|
* @return Task identifier, or {@code null} before insertion
|
||||||
|
*/
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the aggregate identity.
|
||||||
|
*
|
||||||
|
* @return owning Project identifier
|
||||||
|
*/
|
||||||
public long getProjectId() {
|
public long getProjectId() {
|
||||||
return projectId;
|
return projectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current assignment identity.
|
||||||
|
*
|
||||||
|
* @return current same-Project assignee membership identifier
|
||||||
|
*/
|
||||||
public long getAssigneeMembershipId() {
|
public long getAssigneeMembershipId() {
|
||||||
return assigneeMembershipId;
|
return assigneeMembershipId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the display title.
|
||||||
|
*
|
||||||
|
* @return normalized Task title
|
||||||
|
*/
|
||||||
public String getTitle() {
|
public String getTitle() {
|
||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the descriptive text.
|
||||||
|
*
|
||||||
|
* @return optional normalized description
|
||||||
|
*/
|
||||||
public String getDescription() {
|
public String getDescription() {
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the workflow state.
|
||||||
|
*
|
||||||
|
* @return current fixed workflow status
|
||||||
|
*/
|
||||||
public TaskStatus getStatus() {
|
public TaskStatus getStatus() {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the business deadline.
|
||||||
|
*
|
||||||
|
* @return optional validated due date
|
||||||
|
*/
|
||||||
public LocalDate getDueDate() {
|
public LocalDate getDueDate() {
|
||||||
return dueDate;
|
return dueDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns current assignment timing.
|
||||||
|
*
|
||||||
|
* @return instant when the current assignment was established
|
||||||
|
*/
|
||||||
public Instant getAssignedAt() {
|
public Instant getAssignedAt() {
|
||||||
return assignedAt;
|
return assignedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns original creator attribution.
|
||||||
|
*
|
||||||
|
* @return immutable creating membership identifier
|
||||||
|
*/
|
||||||
public long getCreatorMembershipId() {
|
public long getCreatorMembershipId() {
|
||||||
return creatorMembershipId;
|
return creatorMembershipId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns current assignment attribution.
|
||||||
|
*
|
||||||
|
* @return membership identifier responsible for the current assignment
|
||||||
|
*/
|
||||||
public long getAssignerMembershipId() {
|
public long getAssignerMembershipId() {
|
||||||
return assignerMembershipId;
|
return assignerMembershipId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns lifecycle visibility state.
|
||||||
|
*
|
||||||
|
* @return soft-deletion instant, or {@code null} while current
|
||||||
|
*/
|
||||||
public Instant getDeletedAt() {
|
public Instant getDeletedAt() {
|
||||||
return deletedAt;
|
return deletedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns creation timing.
|
||||||
|
*
|
||||||
|
* @return immutable creation instant
|
||||||
|
*/
|
||||||
public Instant getCreatedAt() {
|
public Instant getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import jakarta.persistence.Id;
|
|||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted append-only Task comment.
|
||||||
|
*
|
||||||
|
* <p>The author is retained as a user identifier so membership or leadership changes do not move
|
||||||
|
* historical attribution. This entity intentionally exposes no edit or delete operation.
|
||||||
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "task_comments")
|
@Table(name = "task_comments")
|
||||||
public class TaskComment {
|
public class TaskComment {
|
||||||
@@ -28,8 +34,17 @@ public class TaskComment {
|
|||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
|
/** Constructor reserved for JPA materialization. */
|
||||||
protected TaskComment() {}
|
protected TaskComment() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an immutable comment from server-authorized values.
|
||||||
|
*
|
||||||
|
* @param taskId owning Task identifier
|
||||||
|
* @param authorUserId authenticated historical author user identifier
|
||||||
|
* @param body normalized non-blank comment text
|
||||||
|
* @param createdAt server-controlled creation instant
|
||||||
|
*/
|
||||||
public TaskComment(long taskId, long authorUserId, String body, Instant createdAt) {
|
public TaskComment(long taskId, long authorUserId, String body, Instant createdAt) {
|
||||||
this.taskId = taskId;
|
this.taskId = taskId;
|
||||||
this.authorUserId = authorUserId;
|
this.authorUserId = authorUserId;
|
||||||
@@ -37,22 +52,47 @@ public class TaskComment {
|
|||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the persistence identity.
|
||||||
|
*
|
||||||
|
* @return comment identifier, or {@code null} before insertion
|
||||||
|
*/
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the owning record identity.
|
||||||
|
*
|
||||||
|
* @return owning Task identifier
|
||||||
|
*/
|
||||||
public long getTaskId() {
|
public long getTaskId() {
|
||||||
return taskId;
|
return taskId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns historical authorship.
|
||||||
|
*
|
||||||
|
* @return immutable historical author user identifier
|
||||||
|
*/
|
||||||
public long getAuthorUserId() {
|
public long getAuthorUserId() {
|
||||||
return authorUserId;
|
return authorUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns comment content.
|
||||||
|
*
|
||||||
|
* @return normalized comment text
|
||||||
|
*/
|
||||||
public String getBody() {
|
public String getBody() {
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns creation timing.
|
||||||
|
*
|
||||||
|
* @return immutable creation instant
|
||||||
|
*/
|
||||||
public Instant getCreatedAt() {
|
public Instant getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import com.lab.labtimesheet.feature.task.model.entity.TaskComment;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
/** JPA persistence boundary for append-only Task comments. */
|
||||||
public interface TaskCommentRepository extends JpaRepository<TaskComment, Long> {
|
public interface TaskCommentRepository extends JpaRepository<TaskComment, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a Task's complete comment history deterministically.
|
||||||
|
*
|
||||||
|
* @param taskId owning Task identifier
|
||||||
|
* @return comments ordered by creation instant and then identifier
|
||||||
|
*/
|
||||||
List<TaskComment> findAllByTaskIdOrderByCreatedAtAscIdAsc(long taskId);
|
List<TaskComment> findAllByTaskIdOrderByCreatedAtAscIdAsc(long taskId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,22 +12,79 @@ import org.springframework.data.jpa.repository.Lock;
|
|||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA persistence and current-read queries for Task rows.
|
||||||
|
*
|
||||||
|
* <p>Normal reads consistently exclude soft-deleted rows. Mutation callers acquire the owning
|
||||||
|
* Project lock before requesting the Task row lock so aggregate and Task lock order remains stable.
|
||||||
|
*/
|
||||||
public interface TaskRepository extends JpaRepository<Task, Long> {
|
public interface TaskRepository extends JpaRepository<Task, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a current Task only when its identifier belongs to the supplied Project.
|
||||||
|
*
|
||||||
|
* @param id Task identifier
|
||||||
|
* @param projectId owning Project identifier
|
||||||
|
* @return matching non-deleted Task, if visible in that aggregate
|
||||||
|
*/
|
||||||
Optional<Task> findByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId);
|
Optional<Task> findByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks one current Task for a mutation after the caller has locked its Project.
|
||||||
|
*
|
||||||
|
* @param id Task identifier
|
||||||
|
* @param projectId owning Project identifier
|
||||||
|
* @return matching non-deleted Task under a pessimistic write lock
|
||||||
|
*/
|
||||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
Optional<Task> findLockedByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId);
|
Optional<Task> findLockedByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists current Tasks for one Project in deterministic identifier order.
|
||||||
|
*
|
||||||
|
* @param projectId owning Project identifier
|
||||||
|
* @return non-deleted Tasks
|
||||||
|
*/
|
||||||
List<Task> findAllByProjectIdAndDeletedAtIsNullOrderById(long projectId);
|
List<Task> findAllByProjectIdAndDeletedAtIsNullOrderById(long projectId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts all current Tasks in one Project.
|
||||||
|
*
|
||||||
|
* @param projectId owning Project identifier
|
||||||
|
* @return non-deleted Task count
|
||||||
|
*/
|
||||||
long countByProjectIdAndDeletedAtIsNull(long projectId);
|
long countByProjectIdAndDeletedAtIsNull(long projectId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts current Tasks in a status across the supplied Projects.
|
||||||
|
*
|
||||||
|
* @param projectIds authorized Project identifiers
|
||||||
|
* @param status status to count
|
||||||
|
* @return matching non-deleted Task count
|
||||||
|
*/
|
||||||
long countByProjectIdInAndStatusAndDeletedAtIsNull(List<Long> projectIds, TaskStatus status);
|
long countByProjectIdInAndStatusAndDeletedAtIsNull(List<Long> projectIds, TaskStatus status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts current Tasks assigned through the supplied Project memberships.
|
||||||
|
*
|
||||||
|
* @param projectIds authorized Project identifiers
|
||||||
|
* @param assigneeMembershipIds actor memberships scoped to those Projects
|
||||||
|
* @return matching non-deleted Task count
|
||||||
|
*/
|
||||||
long countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull(
|
long countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull(
|
||||||
List<Long> projectIds, List<Long> assigneeMembershipIds);
|
List<Long> projectIds, List<Long> assigneeMembershipIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the highest-priority current assignments within authorized Project/membership pairs.
|
||||||
|
*
|
||||||
|
* <p>Ordering is due date ascending, null due dates last, then Task identifier ascending. The
|
||||||
|
* supplied page bounds how many rows are returned.
|
||||||
|
*
|
||||||
|
* @param projectIds authorized active Project identifiers
|
||||||
|
* @param assigneeMembershipIds actor's current memberships in those Projects
|
||||||
|
* @param pageable result limit
|
||||||
|
* @return ordered non-deleted Tasks
|
||||||
|
*/
|
||||||
@Query("""
|
@Query("""
|
||||||
select task
|
select task
|
||||||
from Task task
|
from Task task
|
||||||
@@ -43,6 +100,17 @@ public interface TaskRepository extends JpaRepository<Task, Long> {
|
|||||||
@Param("assigneeMembershipIds") List<Long> assigneeMembershipIds,
|
@Param("assigneeMembershipIds") List<Long> assigneeMembershipIds,
|
||||||
Pageable pageable);
|
Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts current Tasks whose assignee is outside a non-empty active-membership set.
|
||||||
|
*
|
||||||
|
* <p>The Project activation caller is responsible for holding the Project write lock through
|
||||||
|
* its decision. Empty membership sets are handled by {@code TaskQueryService} rather than this
|
||||||
|
* {@code NOT IN} query.
|
||||||
|
*
|
||||||
|
* @param projectId locked Project identifier
|
||||||
|
* @param activeMembershipIds non-empty active same-Project membership identifiers
|
||||||
|
* @return non-deleted Tasks assigned outside the supplied set
|
||||||
|
*/
|
||||||
@Query("""
|
@Query("""
|
||||||
select count(task)
|
select count(task)
|
||||||
from Task task
|
from Task task
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ import org.springframework.data.domain.PageRequest;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplies the Task-owned portion of the shared role dashboard.
|
||||||
|
*
|
||||||
|
* <p>Project visibility, role, and active membership facts come only from the Project service DTO
|
||||||
|
* boundary. Mentor counts cover blocked current Tasks in visible active Projects. Intern counts and
|
||||||
|
* priorities cover current assignments only where the actor still has an active membership.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class TaskDashboardService {
|
public class TaskDashboardService {
|
||||||
|
|
||||||
@@ -23,11 +30,27 @@ public class TaskDashboardService {
|
|||||||
private final TaskRepository tasks;
|
private final TaskRepository tasks;
|
||||||
private final ProjectQueryService projects;
|
private final ProjectQueryService projects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the dashboard query service.
|
||||||
|
*
|
||||||
|
* @param tasks Task persistence boundary
|
||||||
|
* @param projects authorized Project query boundary
|
||||||
|
*/
|
||||||
public TaskDashboardService(TaskRepository tasks, ProjectQueryService projects) {
|
public TaskDashboardService(TaskRepository tasks, ProjectQueryService projects) {
|
||||||
this.tasks = tasks;
|
this.tasks = tasks;
|
||||||
this.projects = projects;
|
this.projects = projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the role-scoped Task dashboard for one authenticated account.
|
||||||
|
*
|
||||||
|
* <p>Mentors receive the blocked count for active Projects they can see. Interns receive their
|
||||||
|
* non-deleted assignment count and at most five priority Tasks, ordered by due date ascending,
|
||||||
|
* null due dates last, and Task identifier ascending. Other roles receive zero/empty values.
|
||||||
|
*
|
||||||
|
* @param actorEmail authenticated account email
|
||||||
|
* @return immutable role-appropriate Task dashboard data
|
||||||
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public TaskDashboardView dashboard(String actorEmail) {
|
public TaskDashboardView dashboard(String actorEmail) {
|
||||||
var actor = projects.authenticatedActor(actorEmail);
|
var actor = projects.authenticatedActor(actorEmail);
|
||||||
|
|||||||
@@ -5,15 +5,35 @@ import java.util.Set;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Task query boundary used by other features without exposing Task entities or repositories.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class TaskQueryService {
|
public class TaskQueryService {
|
||||||
|
|
||||||
private final TaskRepository tasks;
|
private final TaskRepository tasks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the cross-feature Task query service.
|
||||||
|
*
|
||||||
|
* @param tasks Task persistence boundary
|
||||||
|
*/
|
||||||
public TaskQueryService(TaskRepository tasks) {
|
public TaskQueryService(TaskRepository tasks) {
|
||||||
this.tasks = tasks;
|
this.tasks = tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts current Tasks assigned outside the supplied active membership set.
|
||||||
|
*
|
||||||
|
* <p>Only non-deleted Tasks are considered. An empty set means every current Task is invalid
|
||||||
|
* and avoids an empty {@code NOT IN} predicate. This method joins an existing transaction; a
|
||||||
|
* Project lifecycle caller must acquire and retain the Project write lock before calling it so
|
||||||
|
* the assignee guard remains stable through the Project decision and commit.
|
||||||
|
*
|
||||||
|
* @param projectId Project whose current Task assignments are being validated
|
||||||
|
* @param activeMembershipIds current eligible same-Project membership identifiers
|
||||||
|
* @return number of current Tasks whose assignee is not in the supplied set
|
||||||
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public long countCurrentTasksAssignedOutside(long projectId, Set<Long> activeMembershipIds) {
|
public long countCurrentTasksAssignedOutside(long projectId, Set<Long> activeMembershipIds) {
|
||||||
if (activeMembershipIds.isEmpty()) {
|
if (activeMembershipIds.isEmpty()) {
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ import java.util.stream.Collectors;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes authorized Task creation, status, comment, and current/historical read use cases.
|
||||||
|
*
|
||||||
|
* <p>Project identity, lifecycle, ownership, leadership, and membership are obtained through
|
||||||
|
* Project service DTOs. Mutations lock and re-evaluate the Project first; status/comment mutations
|
||||||
|
* then lock the Task row, preserving the Project-to-Task lock order. Access failures are translated
|
||||||
|
* to a non-disclosing Task 404, while authenticated business-rule failures use Task validation.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class TaskService {
|
public class TaskService {
|
||||||
|
|
||||||
@@ -43,6 +51,16 @@ public class TaskService {
|
|||||||
private final CalendarApplicationService calendar;
|
private final CalendarApplicationService calendar;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the Task application service and its feature boundaries.
|
||||||
|
*
|
||||||
|
* @param tasks Task persistence boundary
|
||||||
|
* @param comments append-only comment persistence boundary
|
||||||
|
* @param projects authorized Project read boundary
|
||||||
|
* @param projectMutations Project-first locking mutation boundary
|
||||||
|
* @param calendar authoritative global day-off query boundary
|
||||||
|
* @param clock server time source for persisted instants
|
||||||
|
*/
|
||||||
public TaskService(
|
public TaskService(
|
||||||
TaskRepository tasks,
|
TaskRepository tasks,
|
||||||
TaskCommentRepository comments,
|
TaskCommentRepository comments,
|
||||||
@@ -58,6 +76,20 @@ public class TaskService {
|
|||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a TODO Task in a PLANNED or ACTIVE Project.
|
||||||
|
*
|
||||||
|
* <p>The Project is write-locked before membership and lifecycle checks. A current Leader may
|
||||||
|
* choose any active same-Project member; another active member may choose only themselves.
|
||||||
|
* Creator, assigner, and assignment time are stored from authenticated current context. An
|
||||||
|
* optional due date must be within Project dates and not a current global day off.
|
||||||
|
*
|
||||||
|
* @param actorEmail authenticated account email
|
||||||
|
* @param command requested Project, membership, and Task fields
|
||||||
|
* @return created Task projection
|
||||||
|
* @throws TaskNotFoundException when current authorization/context is absent
|
||||||
|
* @throws TaskValidationException when title or due date violates a business rule
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public TaskView create(String actorEmail, CreateTaskCommand command) {
|
public TaskView create(String actorEmail, CreateTaskCommand command) {
|
||||||
String title = requireTitle(command.title());
|
String title = requireTitle(command.title());
|
||||||
@@ -84,6 +116,21 @@ public class TaskService {
|
|||||||
return view(tasks.saveAndFlush(task), assignee.displayName());
|
return view(tasks.saveAndFlush(task), assignee.displayName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes an ACTIVE Project Task through one fixed workflow edge.
|
||||||
|
*
|
||||||
|
* <p>The transaction locks the Project before the Task row and permits only the current
|
||||||
|
* assignee membership to mutate status. Neither leadership nor a global role substitutes for
|
||||||
|
* assignment authority.
|
||||||
|
*
|
||||||
|
* @param actorEmail authenticated account email
|
||||||
|
* @param projectId owning Project identifier
|
||||||
|
* @param taskId Task identifier within that Project
|
||||||
|
* @param target requested next status
|
||||||
|
* @return updated Task projection
|
||||||
|
* @throws TaskNotFoundException when scope, lifecycle, assignment, or identifiers are invalid
|
||||||
|
* @throws TaskValidationException when the requested status edge is forbidden
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) {
|
public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) {
|
||||||
TaskAccess access = requireMutationAccess(actorEmail, projectId);
|
TaskAccess access = requireMutationAccess(actorEmail, projectId);
|
||||||
@@ -103,6 +150,21 @@ public class TaskService {
|
|||||||
return view(tasks.saveAndFlush(task), actorMembership.displayName());
|
return view(tasks.saveAndFlush(task), actorMembership.displayName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a comment to a current Task before Project completion.
|
||||||
|
*
|
||||||
|
* <p>The transaction locks the Project before the Task row. The owning Mentor or any active
|
||||||
|
* member may comment; the persisted author is the authenticated user so later membership or
|
||||||
|
* leadership changes do not alter history.
|
||||||
|
*
|
||||||
|
* @param actorEmail authenticated account email
|
||||||
|
* @param projectId owning Project identifier
|
||||||
|
* @param taskId Task identifier within that Project
|
||||||
|
* @param body required comment text
|
||||||
|
* @return created append-only comment projection
|
||||||
|
* @throws TaskNotFoundException when current authorization, lifecycle, or identifiers are invalid
|
||||||
|
* @throws TaskValidationException when the normalized body is empty
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) {
|
public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) {
|
||||||
String normalizedBody = requireCommentBody(body);
|
String normalizedBody = requireCommentBody(body);
|
||||||
@@ -123,6 +185,18 @@ public class TaskService {
|
|||||||
return view(comments.saveAndFlush(comment));
|
return view(comments.saveAndFlush(comment));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists current Tasks and progress for an authorized Project reader.
|
||||||
|
*
|
||||||
|
* <p>Soft-deleted Tasks are excluded. Active members may read open Projects; former members may
|
||||||
|
* read only a completed Project in which they historically participated. Empty current Task
|
||||||
|
* sets produce empty completion percentage semantics. The create capability is server-derived.
|
||||||
|
*
|
||||||
|
* @param actorEmail authenticated account email
|
||||||
|
* @param projectId Project identifier
|
||||||
|
* @return visible Tasks, progress counts, and create capability
|
||||||
|
* @throws TaskNotFoundException when the Project is outside the actor's authorized scope
|
||||||
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public TaskListView list(String actorEmail, long projectId) {
|
public TaskListView list(String actorEmail, long projectId) {
|
||||||
TaskAccess access = requireReadableProject(actorEmail, projectId);
|
TaskAccess access = requireReadableProject(actorEmail, projectId);
|
||||||
@@ -137,6 +211,19 @@ public class TaskService {
|
|||||||
isOpen(access.project()) && activeMembership(access) != null);
|
isOpen(access.project()) && activeMembership(access) != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads one current Task, append-only comments, and server-derived action capabilities.
|
||||||
|
*
|
||||||
|
* <p>Status capability requires the ACTIVE Project's current assignee. Comment capability
|
||||||
|
* requires an active member or owning Mentor before completion. Historical completed-Project
|
||||||
|
* readers receive details with no mutation capability.
|
||||||
|
*
|
||||||
|
* @param actorEmail authenticated account email
|
||||||
|
* @param projectId owning Project identifier
|
||||||
|
* @param taskId Task identifier within that Project
|
||||||
|
* @return authorized detail projection
|
||||||
|
* @throws TaskNotFoundException when scope or identifiers are invalid
|
||||||
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public TaskDetails details(String actorEmail, long projectId, long taskId) {
|
public TaskDetails details(String actorEmail, long projectId, long taskId) {
|
||||||
TaskAccess access = requireReadableProject(actorEmail, projectId);
|
TaskAccess access = requireReadableProject(actorEmail, projectId);
|
||||||
@@ -157,6 +244,18 @@ public class TaskService {
|
|||||||
return new TaskDetails(task, taskComments, canChangeStatus, canComment);
|
return new TaskDetails(task, taskComments, canChangeStatus, canComment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns assignee options for an authorized Task create form.
|
||||||
|
*
|
||||||
|
* <p>A current Leader receives every active same-Project membership; another active member
|
||||||
|
* receives only their own membership. Completed Projects and non-members are denied without
|
||||||
|
* disclosing Project membership data.
|
||||||
|
*
|
||||||
|
* @param actorEmail authenticated account email
|
||||||
|
* @param projectId Project identifier
|
||||||
|
* @return authorized membership choices
|
||||||
|
* @throws TaskNotFoundException when current authorization or lifecycle is invalid
|
||||||
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<TaskAssigneeChoice> assignmentChoices(String actorEmail, long projectId) {
|
public List<TaskAssigneeChoice> assignmentChoices(String actorEmail, long projectId) {
|
||||||
TaskAccess access = requireProjectAccess(actorEmail, projectId);
|
TaskAccess access = requireProjectAccess(actorEmail, projectId);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label for="dueDate">Due date</label>
|
<label for="dueDate">Due date</label>
|
||||||
<input id="dueDate" type="date" th:field="*{dueDate}">
|
<input id="dueDate" type="date" th:field="*{dueDate}">
|
||||||
|
<p role="alert" th:if="${#fields.hasErrors('dueDate')}" th:errors="*{dueDate}">Due date error</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Create Task</button>
|
<button type="submit">Create Task</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
|
||||||
|
|
||||||
import com.lab.labtimesheet.feature.task.exception.TaskNotFoundException;
|
import com.lab.labtimesheet.feature.task.exception.TaskNotFoundException;
|
||||||
|
import com.lab.labtimesheet.feature.task.exception.TaskValidationException;
|
||||||
import com.lab.labtimesheet.feature.task.model.TaskProgress;
|
import com.lab.labtimesheet.feature.task.model.TaskProgress;
|
||||||
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
||||||
import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
|
import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
|
||||||
@@ -28,7 +29,11 @@ import com.lab.labtimesheet.feature.task.service.TaskService;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
@@ -125,6 +130,46 @@ class TaskControllerTest {
|
|||||||
.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class));
|
.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidDueDateRendersFieldErrorAndRetainsSafeInput() throws Exception {
|
||||||
|
TaskAssigneeChoice assignee = new TaskAssigneeChoice(7L, "Member Name");
|
||||||
|
given(taskService.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class)))
|
||||||
|
.willThrow(new TaskValidationException("Due date must fall within the Project dates"));
|
||||||
|
given(taskService.assignmentChoices(ACTOR_EMAIL, 10L)).willReturn(List.of(assignee));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/projects/10/tasks")
|
||||||
|
.with(user(ACTOR_EMAIL))
|
||||||
|
.with(csrf())
|
||||||
|
.param("title", "Draft")
|
||||||
|
.param("description", "Safe notes")
|
||||||
|
.param("assigneeMembershipId", "7")
|
||||||
|
.param("dueDate", "2026-09-01"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(view().name("tasks/form"))
|
||||||
|
.andExpect(model().attributeHasFieldErrors("taskForm", "dueDate"))
|
||||||
|
.andExpect(model().attribute("assignees", List.of(assignee)))
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.containsString("Draft")))
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.containsString("Safe notes")))
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.containsString("2026-09-01")))
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.containsString(
|
||||||
|
"Due date must fall within the Project dates")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void guessedProjectDuringCreateRemainsNotFound() throws Exception {
|
||||||
|
given(taskService.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class)))
|
||||||
|
.willThrow(new TaskNotFoundException());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/projects/999/tasks")
|
||||||
|
.with(user(ACTOR_EMAIL))
|
||||||
|
.with(csrf())
|
||||||
|
.param("title", "Draft")
|
||||||
|
.param("assigneeMembershipId", "7"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
|
||||||
|
verify(taskService, org.mockito.Mockito.never()).assignmentChoices(ACTOR_EMAIL, 999L);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void statusAndCommentPostsUseAuthenticatedIdentityAndCsrf() throws Exception {
|
void statusAndCommentPostsUseAuthenticatedIdentityAndCsrf() throws Exception {
|
||||||
given(taskService.changeStatus(ACTOR_EMAIL, 10L, 25L, TaskStatus.IN_PROGRESS))
|
given(taskService.changeStatus(ACTOR_EMAIL, 10L, 25L, TaskStatus.IN_PROGRESS))
|
||||||
@@ -171,10 +216,33 @@ class TaskControllerTest {
|
|||||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("Add comment")));
|
.andExpect(content().string(org.hamcrest.Matchers.containsString("Add comment")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest(name = "{0} exposes only {1}")
|
||||||
|
@MethodSource("allowedStatusChoices")
|
||||||
|
void taskDetailsExposeOnlyAllowedStatusTransitions(TaskStatus current, List<TaskStatus> expected) throws Exception {
|
||||||
|
given(taskService.details(ACTOR_EMAIL, 10L, 25L))
|
||||||
|
.willReturn(new TaskDetails(task(25L, current), List.of(), true, true));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/projects/10/tasks/25").with(user(ACTOR_EMAIL)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(model().attribute("statuses", expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Stream<Arguments> allowedStatusChoices() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of(TaskStatus.TODO, List.of(TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED)),
|
||||||
|
Arguments.of(TaskStatus.IN_PROGRESS, List.of(TaskStatus.BLOCKED, TaskStatus.DONE)),
|
||||||
|
Arguments.of(TaskStatus.BLOCKED, List.of(TaskStatus.TODO, TaskStatus.IN_PROGRESS)),
|
||||||
|
Arguments.of(TaskStatus.DONE, List.of(TaskStatus.IN_PROGRESS)));
|
||||||
|
}
|
||||||
|
|
||||||
private static TaskView task(long id) {
|
private static TaskView task(long id) {
|
||||||
|
return task(id, TaskStatus.TODO);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskView task(long id, TaskStatus status) {
|
||||||
Instant instant = Instant.parse("2026-08-14T10:00:00Z");
|
Instant instant = Instant.parse("2026-08-14T10:00:00Z");
|
||||||
return new TaskView(
|
return new TaskView(
|
||||||
id, 10L, 7L, "Member Name", "Draft", "Notes", TaskStatus.TODO,
|
id, 10L, 7L, "Member Name", "Draft", "Notes", status,
|
||||||
LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant);
|
LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user