Merge commit 'e38e2cdea912160b183c65398c4e8d5682c1b00e' into work/reports-ui

# Conflicts:
#	src/main/resources/templates/tasks/form.html
This commit is contained in:
sechmachine
2026-08-15 03:35:52 +07:00
24 changed files with 704 additions and 22 deletions
@@ -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,14 +71,21 @@ public class TaskController {
populateForm(authentication.getName(), projectId, model);
return "tasks/form";
}
TaskView task = taskService.create(
authentication.getName(),
new CreateTaskCommand(
projectId,
form.assigneeMembershipId(),
form.title(),
form.description(),
form.dueDate()));
TaskView task;
try {
task = taskService.create(
authentication.getName(),
new CreateTaskCommand(
projectId,
form.assigneeMembershipId(),
form.title(),
form.description(),
form.dueDate()));
} catch (TaskValidationException exception) {
bindingResult.rejectValue("dueDate", "task.dueDate", exception.getMessage());
populateForm(authentication.getName(), projectId, model);
return "tasks/form";
}
return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id());
}
@@ -72,9 +95,12 @@ public class TaskController {
@PathVariable long projectId,
@PathVariable long taskId,
Model model) {
TaskDetails details = taskService.details(authentication.getName(), projectId, taskId);
model.addAttribute("projectId", projectId);
model.addAttribute("details", taskService.details(authentication.getName(), projectId, taskId));
model.addAttribute("statuses", TaskStatus.values());
model.addAttribute("details", details);
model.addAttribute("statuses", Arrays.stream(TaskStatus.values())
.filter(details.task().status()::canTransitionTo)
.toList());
return "tasks/detail";
}
@@ -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");
}
@@ -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);