fix(projects): close review authorization and error gaps

This commit is contained in:
sechmachine
2026-08-15 02:21:41 +07:00
parent 2a9a149520
commit af0eb3cabb
34 changed files with 1001 additions and 45 deletions
@@ -1,6 +1,7 @@
package com.lab.labtimesheet.feature.project.controller;
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm;
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
@@ -16,6 +17,14 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* Serves authenticated, server-rendered Project pages and binds Project mutation forms.
*
* <p>Project services remain the authority for ownership, membership, lifecycle, and
* transactional validation. Known rule failures are returned to the originating safe view,
* while authorization failures are left to {@code ProjectControllerAdvice} so identifiers are
* not disclosed.
*/
@Controller
@RequestMapping("/projects")
public class ProjectController {
@@ -23,11 +32,25 @@ public class ProjectController {
private final ProjectQueryService pages;
private final ProjectService projects;
/**
* Creates the MVC adapter for Project queries and mutations.
*
* @param pages authorized Project read operations
* @param projects transactional Project mutation operations
*/
public ProjectController(ProjectQueryService pages, ProjectService projects) {
this.pages = pages;
this.projects = projects;
}
/**
* Lists only Projects visible to the authenticated actor and exposes Project creation only
* to Mentors.
*
* @param principal authenticated user
* @param model response model
* @return the Project list view
*/
@GetMapping
public String list(Principal principal, Model model) {
var actor = pages.authenticatedActor(principal.getName());
@@ -36,6 +59,14 @@ public class ProjectController {
return "projects/list";
}
/**
* Opens the creation form for an authenticated Mentor.
*
* @param principal authenticated user
* @param model response model
* @return the Project creation view
* @throws ProjectAccessDeniedException when the actor is not an active Mentor
*/
@GetMapping("/new")
public String createForm(Principal principal, Model model) {
if (!"MENTOR".equals(pages.authenticatedActor(principal.getName()).role())) {
@@ -45,6 +76,14 @@ public class ProjectController {
return "projects/form";
}
/**
* Creates a Project or re-renders the form with retained safe input when validation fails.
*
* @param principal authenticated user
* @param projectForm validated browser input
* @param bindingResult binding and domain validation results
* @return a redirect to the created Project, or the creation form on validation failure
*/
@PostMapping
public String create(
Principal principal,
@@ -53,54 +92,147 @@ public class ProjectController {
if (bindingResult.hasErrors()) {
return "projects/form";
}
long projectId = projects.create(actorId(principal), projectForm.toCommand());
return "redirect:/projects/" + projectId;
try {
long projectId = projects.create(actorId(principal), projectForm.toCommand());
return "redirect:/projects/" + projectId;
} catch (ProjectRuleViolationException exception) {
bindingResult.rejectValue(
"initialLeaderUserId", "project.initialLeader.ineligible", exception.getMessage());
return "projects/form";
}
}
/**
* Renders an authorized Project detail without disclosing guessed identifiers.
*
* @param principal authenticated user
* @param projectId requested Project identifier
* @param model response model
* @return the Project detail view
*/
@GetMapping("/{projectId}")
public String detail(Principal principal, @PathVariable long projectId, Model model) {
model.addAttribute("project", pages.detail(actorId(principal), projectId));
return "projects/detail";
}
/**
* Activates a planned Project or re-renders its detail with a safe lifecycle error.
*
* @param principal authenticated user
* @param projectId Project to activate
* @param model response model used when activation is rejected
* @return a detail redirect after success, or the detail view after a rule failure
*/
@PostMapping("/{projectId}/activate")
public String activate(Principal principal, @PathVariable long projectId) {
projects.activate(actorId(principal), projectId);
return "redirect:/projects/" + projectId;
public String activate(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal);
try {
projects.activate(actorId, projectId);
return "redirect:/projects/" + projectId;
} catch (ProjectRuleViolationException exception) {
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("projectError", exception.getMessage());
return "projects/detail";
}
}
/**
* Renders authorized current and historical membership intervals.
*
* @param principal authenticated user
* @param projectId requested Project identifier
* @param model response model
* @return the membership history view
*/
@GetMapping("/{projectId}/members")
public String members(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal);
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("members", pages.members(actorId, projectId));
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
return "projects/members";
}
/**
* Adds an eligible Intern or re-renders membership history with the submitted identifier
* and a safe validation message.
*
* @param principal authenticated user
* @param projectId owning Project identifier
* @param memberForm validated Intern selection
* @param bindingResult binding and domain validation results
* @param model response model used on failure
* @return a membership redirect after success, or the membership view on validation failure
*/
@PostMapping("/{projectId}/members")
public String addMember(
Principal principal,
@PathVariable long projectId,
@Valid @ModelAttribute ProjectMemberForm memberForm) {
projects.addMember(actorId(principal), projectId, memberForm.internUserId());
return "redirect:/projects/" + projectId + "/members";
@Valid @ModelAttribute("projectMemberForm") ProjectMemberForm memberForm,
BindingResult bindingResult,
Model model) {
long actorId = actorId(principal);
if (!bindingResult.hasErrors()) {
try {
projects.addMember(actorId, projectId, memberForm.internUserId());
return "redirect:/projects/" + projectId + "/members";
} catch (ProjectRuleViolationException exception) {
bindingResult.rejectValue("internUserId", "project.member.ineligible", exception.getMessage());
}
}
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("members", pages.members(actorId, projectId));
return "projects/members";
}
/**
* Renders the authorized leadership-term history and an owner-only mutation form while the
* Project is mutable.
*
* @param principal authenticated user
* @param projectId requested Project identifier
* @param model response model
* @return the leadership history view
*/
@GetMapping("/{projectId}/leadership")
public String leadership(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal);
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("leadership", pages.leadership(actorId, projectId));
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
return "projects/leadership";
}
/**
* Appoints an eligible current member or re-renders leadership history with retained input.
*
* @param principal authenticated user
* @param projectId owning Project identifier
* @param memberForm validated replacement Leader selection
* @param bindingResult binding and domain validation results
* @param model response model used on failure
* @return a leadership redirect after success, or the leadership view on validation failure
*/
@PostMapping("/{projectId}/leadership")
public String changeLeader(
Principal principal,
@PathVariable long projectId,
@Valid @ModelAttribute ProjectMemberForm memberForm) {
projects.changeLeader(actorId(principal), projectId, memberForm.internUserId());
return "redirect:/projects/" + projectId + "/leadership";
@Valid @ModelAttribute("projectMemberForm") ProjectMemberForm memberForm,
BindingResult bindingResult,
Model model) {
long actorId = actorId(principal);
if (!bindingResult.hasErrors()) {
try {
projects.changeLeader(actorId, projectId, memberForm.internUserId());
return "redirect:/projects/" + projectId + "/leadership";
} catch (ProjectRuleViolationException exception) {
bindingResult.rejectValue("internUserId", "project.leader.ineligible", exception.getMessage());
}
}
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("leadership", pages.leadership(actorId, projectId));
return "projects/leadership";
}
private long actorId(Principal principal) {
@@ -1,7 +1,11 @@
package com.lab.labtimesheet.feature.project.exception;
/**
* Signals a Project lookup or operation that must fail without revealing resource existence.
*/
public final class ProjectAccessDeniedException extends RuntimeException {
/** Creates the internal denial signal; controllers replace its message with generic copy. */
public ProjectAccessDeniedException() {
super("Project access denied");
}
@@ -3,19 +3,57 @@ package com.lab.labtimesheet.feature.project.exception;
import com.lab.labtimesheet.feature.project.controller.ProjectController;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.ModelAndView;
@RestControllerAdvice(assignableTypes = ProjectController.class)
/**
* Maps uncaught Project authorization and lifecycle failures to the shared, non-disclosing
* server-rendered error contract.
*
* <p>The Reporting/UI feature supplies {@code error/generic}. Its stable model contains
* {@code errorStatus}, {@code errorTitle}, and {@code errorMessage}; none is populated from the
* exception message.
*/
@ControllerAdvice(assignableTypes = ProjectController.class)
public class ProjectControllerAdvice {
@ExceptionHandler(ProjectAccessDeniedException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public void accessDenied() {
/** Creates the stateless Project exception-to-view adapter. */
public ProjectControllerAdvice() {
}
/**
* Hides whether a requested Project or nested resource exists.
*
* @return the shared generic error view with HTTP 404 and safe copy
*/
@ExceptionHandler(ProjectAccessDeniedException.class)
public ModelAndView accessDenied() {
return genericError(
HttpStatus.NOT_FOUND,
"Project unavailable",
"The requested Project could not be found or is not available to you.");
}
/**
* Reports an uncaught stale or invalid Project request without exposing aggregate details.
* Known form validation failures are handled by the controller before reaching this fallback.
*
* @return the shared generic error view with HTTP 409 and safe copy
*/
@ExceptionHandler(ProjectRuleViolationException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public void conflict() {
public ModelAndView conflict() {
return genericError(
HttpStatus.CONFLICT,
"Project request could not be completed",
"Review the Project and try again.");
}
private static ModelAndView genericError(HttpStatus status, String title, String message) {
var error = new ModelAndView("error/generic");
error.setStatus(status);
error.addObject("errorStatus", status.value());
error.addObject("errorTitle", title);
error.addObject("errorMessage", message);
return error;
}
}
@@ -1,7 +1,16 @@
package com.lab.labtimesheet.feature.project.exception;
/**
* Signals that a Project lifecycle, eligibility, membership, or leadership rule rejected a
* mutation without committing a partial aggregate change.
*/
public final class ProjectRuleViolationException extends RuntimeException {
/**
* Creates a domain-rule failure whose message may be shown only by a known safe form flow.
*
* @param message actionable domain validation message without protected identifiers
*/
public ProjectRuleViolationException(String message) {
super(message);
}
@@ -1,13 +1,31 @@
package com.lab.labtimesheet.feature.project.model;
/**
* Account-owned eligibility fact used by the Project aggregate without importing Account
* persistence types.
*
* @param userId Intern account identifier
* @param eligible true only when both account and internship are active for the relevant check
*/
public record ProjectInternEligibility(long userId, boolean eligible) {
/**
* Rejects invalid identifiers before they enter Project membership history.
*
* @param userId Intern account identifier
* @param eligible Account-service eligibility decision
*/
public ProjectInternEligibility {
if (userId <= 0) {
throw new IllegalArgumentException("Intern user ID must be positive");
}
}
/**
* Returns the Account-service eligibility decision.
*
* @return true when the Intern may participate in the requested Project operation
*/
public boolean isEligible() {
return eligible;
}
@@ -3,5 +3,12 @@ package com.lab.labtimesheet.feature.project.model;
import com.lab.labtimesheet.feature.project.model.entity.ProjectMembershipEntity;
import java.time.Instant;
/**
* In-transaction handoff between closing the current leadership term and opening its replacement.
* It exists so the old interval can be flushed before PostgreSQL validates the new current term.
*
* @param replacement active same-Project membership appointed as Leader
* @param effectiveAt end/start instant shared by the adjacent leadership terms
*/
public record ProjectLeaderChange(ProjectMembershipEntity replacement, Instant effectiveAt) {
}
@@ -1,7 +1,11 @@
package com.lab.labtimesheet.feature.project.model;
/** Project aggregate lifecycle; completion is terminal and read-only. */
public enum ProjectStatus {
/** Preparation state in which membership, leadership, and Task definitions may change. */
PLANNED,
/** Execution state in which Project work may proceed. */
ACTIVE,
/** Terminal read-only state retaining historical membership and leadership visibility. */
COMPLETED
}
@@ -1,4 +1,10 @@
package com.lab.labtimesheet.feature.project.model.dto;
/**
* Active authenticated actor information exposed to Project web consumers.
*
* @param userId stable account identifier
* @param role immutable global role name
*/
public record ProjectActorView(long userId, String role) {
}
@@ -2,6 +2,15 @@ package com.lab.labtimesheet.feature.project.model.dto;
import java.time.LocalDate;
/**
* Service command for atomically planning a Project with its initial Leader.
*
* @param name required Project name
* @param description optional Project description
* @param startDate inclusive Project start date
* @param endDate inclusive Project end date, not before {@code startDate}
* @param initialLeaderUserId eligible Intern appointed as the first Leader
*/
public record ProjectCreateCommand(
String name,
String description,
@@ -8,6 +8,15 @@ import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
/**
* Validated browser input for planning a Project and appointing its initial Leader.
*
* @param name required Project name, limited to the persisted column length
* @param description optional description
* @param startDate inclusive Project start date
* @param endDate inclusive Project end date
* @param initialLeaderUserId positive eligible Intern user identifier
*/
public record ProjectCreateForm(
@NotBlank @Size(max = 160) String name,
String description,
@@ -15,15 +24,26 @@ public record ProjectCreateForm(
@NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@NotNull @Positive Long initialLeaderUserId) {
/** Creates an empty form for the initial GET request and Thymeleaf binding. */
public ProjectCreateForm() {
this(null, null, null, null, null);
}
/**
* Validates the date interval only after both required dates have bound successfully.
*
* @return true when either date awaits required-field validation or end is not before start
*/
@AssertTrue(message = "End date must not precede start date")
public boolean isDateRangeValid() {
return startDate == null || endDate == null || !endDate.isBefore(startDate);
}
/**
* Converts validated browser input to the immutable service command.
*
* @return creation command preserving the submitted values
*/
public ProjectCreateCommand toCommand() {
return new ProjectCreateCommand(name, description, startDate, endDate, initialLeaderUserId);
}
@@ -1,4 +1,10 @@
package com.lab.labtimesheet.feature.project.model.dto;
/**
* Role-scoped current Project metrics for the dashboard.
*
* @param activeProjectCount number of active Projects visible in the actor's current scope
* @param distinctActiveMemberCount distinct eligible active members for a Mentor; zero for other roles
*/
public record ProjectDashboardSummary(long activeProjectCount, long distinctActiveMemberCount) {
}
@@ -2,6 +2,19 @@ package com.lab.labtimesheet.feature.project.model.dto;
import java.time.LocalDate;
/**
* Authorized Project detail for server-rendered pages.
*
* @param id Project identifier
* @param name display name
* @param description optional description
* @param status lifecycle status
* @param startDate inclusive Project start date
* @param endDate inclusive Project end date
* @param mentorName owning Mentor display name
* @param leaderName current Leader display name, or null after completion closes leadership
* @param canManage whether the viewer is the owner and the Project remains mutable
*/
public record ProjectDetail(
long id,
String name,
@@ -2,6 +2,14 @@ package com.lab.labtimesheet.feature.project.model.dto;
import java.time.Instant;
/**
* Historical leadership interval for an authorized Project page.
*
* @param id leadership-term identifier
* @param leaderName retained Leader display name
* @param startedAt inclusive term start instant
* @param endedAt term end instant, or null while the term is current
*/
public record ProjectLeadershipTermView(
long id,
String leaderName,
@@ -1,6 +1,12 @@
package com.lab.labtimesheet.feature.project.model.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public record ProjectMemberForm(@Positive long internUserId) {
/**
* Browser form selecting an Intern for direct membership or leadership appointment.
*
* @param internUserId positive Intern user identifier; null binding is rejected before mutation
*/
public record ProjectMemberForm(@NotNull @Positive Long internUserId) {
}
@@ -2,6 +2,16 @@ package com.lab.labtimesheet.feature.project.model.dto;
import java.time.Instant;
/**
* Current or historical Project membership for authorized server-rendered pages.
*
* @param membershipId stable membership-interval identifier
* @param internUserId participating Intern user identifier
* @param displayName current Account display name
* @param joinedAt inclusive membership start instant
* @param leftAt membership end instant, or null while current
* @param currentLeader true only for the current open-Project Leader membership
*/
public record ProjectMemberView(
long membershipId,
long internUserId,
@@ -2,5 +2,14 @@ package com.lab.labtimesheet.feature.project.model.dto;
import java.time.LocalDate;
/**
* Compact authorized Project row for lists.
*
* @param id Project identifier
* @param name display name
* @param status lifecycle status
* @param startDate inclusive start date
* @param endDate inclusive end date
*/
public record ProjectSummary(long id, String name, String status, LocalDate startDate, LocalDate endDate) {
}
@@ -3,6 +3,17 @@ package com.lab.labtimesheet.feature.project.model.dto;
import java.time.LocalDate;
import java.util.List;
/**
* DTO-only Project authorization and lifecycle context consumed by the Task feature.
*
* @param projectId Project identifier
* @param mentorUserId owning Mentor user identifier
* @param status lifecycle status
* @param startDate inclusive Project start date
* @param endDate inclusive Project end date
* @param currentLeaderMembershipId current Leader membership, or null after completion
* @param activeMembers eligible current memberships, empty after completion
*/
public record ProjectTaskContext(
long projectId,
long mentorUserId,
@@ -12,6 +23,18 @@ public record ProjectTaskContext(
Long currentLeaderMembershipId,
List<ProjectTaskMemberView> activeMembers) {
/**
* Defensively snapshots member context so consumers cannot change authorization facts after
* they were read.
*
* @param projectId Project identifier
* @param mentorUserId owning Mentor user identifier
* @param status lifecycle status
* @param startDate inclusive Project start date
* @param endDate inclusive Project end date
* @param currentLeaderMembershipId current Leader membership, or null after completion
* @param activeMembers eligible current memberships, copied and never null
*/
public ProjectTaskContext {
activeMembers = List.copyOf(activeMembers);
}
@@ -1,4 +1,11 @@
package com.lab.labtimesheet.feature.project.model.dto;
/**
* Current eligible Project member exposed to Task services without sharing Project entities.
*
* @param membershipId active membership-interval identifier used by Task foreign keys
* @param userId Intern account identifier used for actor authorization
* @param displayName current Account display name
*/
public record ProjectTaskMemberView(long membershipId, long userId, String displayName) {
}
@@ -23,6 +23,13 @@ import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* JPA aggregate root for Project lifecycle, membership intervals, and leadership intervals.
*
* <p>A planned or active Project owns exactly one current Leader membership. Completion is
* terminal and closes current intervals; history is retained rather than reassigned or deleted.
* Mutation methods enforce aggregate rules independently of browser control visibility.
*/
@Entity
@Table(name = "projects")
public class ProjectEntity {
@@ -68,6 +75,7 @@ public class ProjectEntity {
@Version
private long version;
/** Constructor reserved for JPA materialization. */
protected ProjectEntity() {
}
@@ -87,6 +95,19 @@ public class ProjectEntity {
this.updatedAt = createdAt;
}
/**
* Plans a Project with one eligible initial Leader membership and its first leadership term.
* The returned aggregate is never empty or leaderless.
*
* @param mentorUserId active Mentor who owns the Project
* @param name required Project name
* @param description optional description
* @param startDate inclusive start date
* @param endDate inclusive end date, not before {@code startDate}
* @param initialLeader eligible Intern appointed as first Leader
* @param at server mutation instant used for all initial records
* @return new unsaved planned aggregate
*/
public static ProjectEntity plan(
long mentorUserId,
String name,
@@ -119,6 +140,14 @@ public class ProjectEntity {
return project;
}
/**
* Adds a distinct eligible current member to a mutable Project after owner authorization.
*
* @param actorMentorUserId authenticated owning Mentor
* @param intern current Account/internship eligibility fact
* @param at server join instant
* @return newly created membership interval
*/
public ProjectMembershipEntity addMember(
long actorMentorUserId, ProjectInternEligibility intern, Instant at) {
requireOwner(actorMentorUserId);
@@ -131,6 +160,15 @@ public class ProjectEntity {
return addEligibleMember(intern, actorMentorUserId, at);
}
/**
* Closes the current leadership term and prepares an eligible active-member replacement.
* Callers must flush the closed interval before opening the replacement term.
*
* @param actorMentorUserId authenticated owning Mentor
* @param intern eligible replacement Intern
* @param at server effective instant
* @return replacement membership and adjacent-term effective instant
*/
public ProjectLeaderChange prepareLeaderChange(
long actorMentorUserId, ProjectInternEligibility intern, Instant at) {
requireOwner(actorMentorUserId);
@@ -148,6 +186,12 @@ public class ProjectEntity {
return new ProjectLeaderChange(replacement, effectiveAt);
}
/**
* Opens the replacement term after the former current term has been closed and flushed.
*
* @param actorMentorUserId authenticated owning Mentor
* @param change prepared replacement from this transaction
*/
public void completeLeaderChange(long actorMentorUserId, ProjectLeaderChange change) {
requireOwner(actorMentorUserId);
Objects.requireNonNull(change, "change");
@@ -158,6 +202,15 @@ public class ProjectEntity {
this, change.replacement(), change.effectiveAt(), actorMentorUserId));
}
/**
* Moves a planned Project to active after current member, Leader, and Task-assignee guards
* pass. The transition is one-way and records the server activation instant.
*
* @param actorMentorUserId authenticated owning Mentor
* @param activeInternUserIds currently eligible member user identifiers
* @param allTaskAssigneesAreCurrent true when every non-deleted Task points to a current membership
* @param at server activation instant
*/
public void activate(
long actorMentorUserId,
Set<Long> activeInternUserIds,
@@ -184,59 +237,135 @@ public class ProjectEntity {
updatedAt = at;
}
/**
* Returns the persistence identity.
*
* @return persisted identifier, or null before insertion
*/
public Long id() {
return id;
}
/**
* Returns immutable Project ownership.
*
* @return owning Mentor user identifier
*/
public long mentorUserId() {
return mentorUserId;
}
/**
* Returns the display name.
*
* @return normalized Project name
*/
public String name() {
return name;
}
/**
* Returns optional descriptive copy.
*
* @return normalized optional description, or null when absent
*/
public String description() {
return description;
}
/**
* Returns the lower business-date boundary.
*
* @return inclusive Project start date
*/
public LocalDate startDate() {
return startDate;
}
/**
* Returns the upper business-date boundary.
*
* @return inclusive Project end date
*/
public LocalDate endDate() {
return endDate;
}
/**
* Returns the current lifecycle state.
*
* @return current aggregate lifecycle status
*/
public ProjectStatus status() {
return status;
}
/**
* Returns when execution began.
*
* @return server activation instant, or null while planned
*/
public Instant activatedAt() {
return activatedAt;
}
/**
* Returns a defensive snapshot of current and historical membership intervals.
*
* @return unmodifiable membership snapshot
*/
public List<ProjectMembershipEntity> memberships() {
return List.copyOf(memberships);
}
/**
* Returns a defensive snapshot of current and historical leadership intervals.
*
* @return unmodifiable leadership-term snapshot
*/
public List<ProjectLeadershipTermEntity> leadershipTerms() {
return List.copyOf(leadershipTerms);
}
/**
* Enforces owning-Mentor authority without revealing details to non-owners.
*
* @param actorMentorUserId authenticated Mentor identifier
* @throws ProjectAccessDeniedException when the actor does not own this Project
*/
public void authorizeOwner(long actorMentorUserId) {
requireOwner(actorMentorUserId);
}
/**
* Checks only open membership intervals.
*
* @param internUserId Intern account identifier
* @return true when the Intern currently belongs to this Project
*/
public boolean hasCurrentMember(long internUserId) {
return memberships.stream()
.anyMatch(membership -> membership.internUserId() == internUserId && membership.isCurrent());
}
/**
* Checks current and closed membership intervals for completed-history authorization.
*
* @param internUserId Intern account identifier
* @return true when the Intern has ever belonged to this Project
*/
public boolean hasEverHadMember(long internUserId) {
return memberships.stream().anyMatch(membership -> membership.internUserId() == internUserId);
}
/**
* Resolves the active membership referenced by the one current leadership term.
* Completed Projects deliberately have no current Leader and callers must not use this method
* for completed-history rendering.
*
* @return current Leader membership
* @throws ProjectRuleViolationException when the open-Project Leader invariant is absent
*/
public ProjectMembershipEntity currentLeader() {
return currentMembership(currentLeadershipTerm().internUserId());
}
@@ -12,6 +12,12 @@ import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* JPA leadership interval attached to an active same-Project membership.
*
* <p>Changes close the current term and create a new term; completion closes the final term.
* Historical terms retain the appointing and ending Mentor attribution.
*/
@Entity
@Table(name = "project_leadership_terms")
public class ProjectLeadershipTermEntity {
@@ -40,6 +46,7 @@ public class ProjectLeadershipTermEntity {
@Column(name = "ended_by_mentor_user_id")
private Long endedByMentorUserId;
/** Constructor reserved for JPA materialization. */
protected ProjectLeadershipTermEntity() {
}
@@ -54,30 +61,65 @@ public class ProjectLeadershipTermEntity {
this.appointedByMentorUserId = appointedByMentorUserId;
}
/**
* Returns the interval identity.
*
* @return persisted leadership-term identifier, or null before insertion
*/
public Long id() {
return id;
}
/**
* Returns the Intern who led during this interval.
*
* @return Intern account identifier obtained from the retained membership interval
*/
public long internUserId() {
return membership.internUserId();
}
/**
* Returns when leadership authority began.
*
* @return inclusive leadership start instant
*/
public Instant startedAt() {
return startedAt;
}
/**
* Returns appointment provenance.
*
* @return owning Mentor account that appointed this Leader
*/
public long appointedByMentorUserId() {
return appointedByMentorUserId;
}
/**
* Returns when leadership authority ended.
*
* @return term end instant, or null while current
*/
public Instant endedAt() {
return endedAt;
}
/**
* Returns closure provenance.
*
* @return Mentor that closed the term, or null while current
*/
public Long endedByMentorUserId() {
return endedByMentorUserId;
}
/**
* Indicates whether this term currently grants Leader authority.
*
* @return true while the term has no end instant
*/
public boolean isCurrent() {
return endedAt == null;
}
@@ -12,6 +12,12 @@ import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
/**
* JPA membership interval linking one Intern to one Project.
*
* <p>Leaving closes the interval; the row and its provenance remain for completed Project and
* Task history. Current membership is represented by a null {@code leftAt}.
*/
@Entity
@Table(name = "project_memberships")
public class ProjectMembershipEntity {
@@ -42,6 +48,7 @@ public class ProjectMembershipEntity {
@Version
private long version;
/** Constructor reserved for JPA materialization. */
protected ProjectMembershipEntity() {
}
@@ -53,26 +60,56 @@ public class ProjectMembershipEntity {
this.updatedAt = joinedAt;
}
/**
* Returns the interval identity used by Project and Task relationships.
*
* @return persisted membership identifier, or null before insertion
*/
public Long id() {
return id;
}
/**
* Returns the participating Intern.
*
* @return participating Intern account identifier
*/
public long internUserId() {
return internUserId;
}
/**
* Returns when membership authority began.
*
* @return inclusive membership start instant
*/
public Instant joinedAt() {
return joinedAt;
}
/**
* Returns membership provenance.
*
* @return account identifier that directly created this interval
*/
public long addedByUserId() {
return addedByUserId;
}
/**
* Returns when membership authority ended.
*
* @return interval end instant, or null while membership is current
*/
public Instant leftAt() {
return leftAt;
}
/**
* Indicates whether the Intern currently belongs to the Project.
*
* @return true while the membership interval has no end instant
*/
public boolean isCurrent() {
return leftAt == null;
}
@@ -9,20 +9,52 @@ import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* Persists the Project aggregate, including its membership and leadership intervals.
*
* <p>Consumers outside the Project feature use Project services and DTOs rather than this
* repository or its JPA entities.
*/
public interface ProjectRepository extends JpaRepository<ProjectEntity, Long> {
/**
* Loads one Project under a pessimistic write lock for mutation-time authorization and
* invariant checks. The caller's transaction retains the lock through commit or rollback.
*
* @param id Project identifier
* @return the locked aggregate, or empty when the identifier does not exist
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select project from ProjectEntity project where project.id = :id")
Optional<ProjectEntity> findLockedById(@Param("id") long id);
/**
* Lists all Projects for Admin read-only inspection, most recently updated first.
*
* @return ordered Projects
*/
List<ProjectEntity> findAllByOrderByUpdatedAtDescIdDesc();
/**
* Lists Projects owned by one Mentor, most recently updated first.
*
* @param mentorUserId owning Mentor user identifier
* @return ordered owned Projects
*/
List<ProjectEntity> findByMentorUserIdOrderByUpdatedAtDescIdDesc(long mentorUserId);
/**
* Lists Projects visible to an Intern: current memberships in open Projects and historical
* memberships only after completion.
*
* @param internUserId Intern user identifier
* @return ordered visible Projects without duplicate rows
*/
@Query("""
select distinct project from ProjectEntity project
join project.memberships membership
where membership.internUserId = :internUserId
and (membership.leftAt is null or project.status = com.lab.labtimesheet.feature.project.model.ProjectStatus.COMPLETED)
order by project.updatedAt desc, project.id desc
""")
List<ProjectEntity> findVisibleToIntern(@Param("internUserId") long internUserId);
@@ -18,22 +18,49 @@ import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Provides authorization-aware, DTO-only Project reads to MVC and other features.
*
* <p>Admins inspect all Projects, Mentors inspect only owned Projects, and Interns inspect open
* Projects only while currently enrolled. Completed Projects remain visible to historical
* members but expose no current Leader or active-member context.
*/
@Service
public class ProjectQueryService {
private final ProjectRepository projects;
private final AccountService accounts;
/**
* Creates the Project read service.
*
* @param projects Project aggregate repository
* @param accounts public Account identity and internship-eligibility boundary
*/
public ProjectQueryService(ProjectRepository projects, AccountService accounts) {
this.projects = projects;
this.accounts = accounts;
}
/**
* Resolves an active authenticated account to its stable user identifier.
*
* @param email authenticated email address
* @return active user identifier
* @throws ProjectAccessDeniedException when no active identity is available
*/
@Transactional(readOnly = true)
public long authenticatedUserId(String email) {
return authenticatedActor(email).userId();
}
/**
* Resolves the active authenticated actor needed for role-aware Project navigation.
*
* @param email authenticated email address
* @return user identifier and immutable global role
* @throws ProjectAccessDeniedException when no active identity is available
*/
@Transactional(readOnly = true)
public ProjectActorView authenticatedActor(String email) {
try {
@@ -47,15 +74,32 @@ public class ProjectQueryService {
}
}
/**
* Lists Projects visible under the actor's current role and Project relationship.
* Historical Intern membership grants visibility only to completed Projects.
*
* @param actorUserId active actor user identifier
* @return ordered authorized summaries
*/
@Transactional(readOnly = true)
public List<ProjectSummary> listVisible(long actorUserId) {
var actor = activeActor(actorUserId);
return visibleProjects(actor, actorUserId).stream().map(ProjectQueryService::summary).toList();
}
/**
* Returns one authorized Project detail. Completed Projects have no current Leader and never
* grant mutation capability, including to their owning Mentor.
*
* @param actorUserId active actor user identifier
* @param projectId requested Project identifier
* @return authorized detail
* @throws ProjectAccessDeniedException for missing and unauthorized identifiers alike
*/
@Transactional(readOnly = true)
public ProjectDetail detail(long actorUserId, long projectId) {
var project = visibleProject(actorUserId, projectId);
var completed = project.status() == ProjectStatus.COMPLETED;
return new ProjectDetail(
project.id(),
project.name(),
@@ -64,10 +108,18 @@ public class ProjectQueryService {
project.startDate(),
project.endDate(),
displayName(project.mentorUserId()),
displayName(project.currentLeader().internUserId()),
project.mentorUserId() == actorUserId);
completed ? null : displayName(project.currentLeader().internUserId()),
!completed && project.mentorUserId() == actorUserId);
}
/**
* Returns membership interval history for an authorized Project. Completed history marks no
* membership as current Leader because completion closes the final leadership term.
*
* @param actorUserId active actor user identifier
* @param projectId requested Project identifier
* @return membership history in aggregate order
*/
@Transactional(readOnly = true)
public List<ProjectMemberView> members(long actorUserId, long projectId) {
var project = visibleProject(actorUserId, projectId);
@@ -87,6 +139,13 @@ public class ProjectQueryService {
.toList();
}
/**
* Returns retained leadership terms for an authorized Project, newest first.
*
* @param actorUserId active actor user identifier
* @param projectId requested Project identifier
* @return leadership history, including closed terms
*/
@Transactional(readOnly = true)
public List<ProjectLeadershipTermView> leadership(long actorUserId, long projectId) {
return visibleProject(actorUserId, projectId).leadershipTerms().stream()
@@ -99,6 +158,15 @@ public class ProjectQueryService {
.toList();
}
/**
* Returns the DTO-only Project facts needed for Task reads. Open Projects include the current
* Leader membership and active eligible members; completed Projects return a null Leader and
* an empty active-member list while remaining visible to former members.
*
* @param actorUserId active actor user identifier
* @param projectId requested Project identifier
* @return authorized Task context
*/
@Transactional(readOnly = true)
public ProjectTaskContext taskContext(long actorUserId, long projectId) {
var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new);
@@ -140,6 +208,13 @@ public class ProjectQueryService {
activeMembers);
}
/**
* Computes role-scoped dashboard counts from current active Project relationships.
* Historical memberships never contribute to current Intern or Mentor metrics.
*
* @param actorUserId active actor user identifier
* @return active Project count and, for Mentors, distinct eligible active-member count
*/
@Transactional(readOnly = true)
public ProjectDashboardSummary dashboardSummary(long actorUserId) {
var actor = activeActor(actorUserId);
@@ -170,7 +245,10 @@ public class ProjectQueryService {
var actor = activeActor(actorUserId);
var visible = "ADMIN".equals(actor.role().name())
|| ("MENTOR".equals(actor.role().name()) && project.mentorUserId() == actorUserId)
|| ("INTERN".equals(actor.role().name()) && project.hasEverHadMember(actorUserId));
|| ("INTERN".equals(actor.role().name())
&& (project.hasCurrentMember(actorUserId)
|| (project.status() == ProjectStatus.COMPLETED
&& project.hasEverHadMember(actorUserId))));
if (!visible) {
throw new ProjectAccessDeniedException();
}
@@ -14,6 +14,13 @@ import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Executes Project aggregate mutations under Spring-managed transactions.
*
* <p>Existing aggregates are pessimistically locked before mutation-time authorization and
* lifecycle checks. Account and Task facts arrive through public feature services; Project never
* imports their repositories or entities.
*/
@Service
public class ProjectService {
@@ -23,6 +30,15 @@ public class ProjectService {
private final TaskQueryService taskQueries;
private final Clock clock;
/**
* Creates the Project mutation service.
*
* @param projects Project aggregate repository
* @param accounts public Account identity and eligibility boundary
* @param queries DTO-only Project query boundary reused for locked Task context
* @param taskQueries public Task activation-guard boundary
* @param clock server clock supplying persisted mutation instants
*/
public ProjectService(
ProjectRepository projects,
AccountService accounts,
@@ -36,6 +52,17 @@ public class ProjectService {
this.clock = clock;
}
/**
* Atomically creates a planned Mentor-owned Project, eligible initial membership, and first
* leadership term. {@code saveAndFlush} exposes database invariant violations before commit.
*
* @param actorUserId authenticated active Mentor creating and owning the Project
* @param command validated creation values
* @return generated Project identifier
* @throws ProjectAccessDeniedException when the actor is not an active Mentor
* @throws com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException when
* dates, name, or initial-Leader eligibility violate the aggregate rules
*/
@Transactional
public long create(long actorUserId, ProjectCreateCommand command) {
requireActiveMentor(actorUserId);
@@ -50,6 +77,15 @@ public class ProjectService {
return projects.saveAndFlush(project).id();
}
/**
* Adds one eligible Intern as a current member while holding the Project write lock.
* The same Intern may belong to other Projects, but duplicate current membership in this
* Project is rejected before flush.
*
* @param actorUserId authenticated owning Mentor
* @param projectId Project to update
* @param internUserId Intern selected for direct addition
*/
@Transactional
public void addMember(long actorUserId, long projectId, long internUserId) {
var project = lockedProject(projectId);
@@ -58,6 +94,15 @@ public class ProjectService {
projects.flush();
}
/**
* Replaces the current Leader with an eligible current member in one transaction.
* The closed term is flushed before its replacement so PostgreSQL's immediate exclusion rule
* observes exactly one current term; Task assignments are not changed.
*
* @param actorUserId authenticated owning Mentor
* @param projectId Project whose Leader changes
* @param internUserId active same-Project replacement Intern
*/
@Transactional
public void changeLeader(long actorUserId, long projectId, long internUserId) {
var project = lockedProject(projectId);
@@ -71,11 +116,29 @@ public class ProjectService {
projects.flush();
}
/**
* Locks the Project and re-evaluates visibility, lifecycle, current leadership, and active
* eligible memberships for a Task mutation. When called inside {@code TaskService}'s
* transaction, the pessimistic lock remains held through the outer commit or rollback.
*
* @param actorUserId authenticated Task actor
* @param projectId owning Project identifier
* @return DTO-only locked mutation context
* @throws ProjectAccessDeniedException for missing or unauthorized Projects
*/
@Transactional
public ProjectTaskContext taskMutationContext(long actorUserId, long projectId) {
return queries.taskContext(actorUserId, lockedProject(projectId));
}
/**
* Activates a planned Project while holding its write lock. Current Account eligibility and
* Task-assignee validity are checked inside the same transaction; any failure leaves the
* Project planned and preserves Tasks and interval history.
*
* @param actorUserId authenticated owning Mentor
* @param projectId planned Project to activate
*/
@Transactional
public void activate(long actorUserId, long projectId) {
var project = lockedProject(projectId);
@@ -5,7 +5,8 @@
<main>
<h1 th:text="${project.name}">Project</h1>
<p th:text="${project.description}"></p>
<dl><dt>Status</dt><dd th:text="${project.status}"></dd><dt>Mentor</dt><dd th:text="${project.mentorName}"></dd><dt>Leader</dt><dd th:text="${project.leaderName}"></dd></dl>
<p role="alert" th:if="${projectError}" th:text="${projectError}"></p>
<dl><dt>Status</dt><dd th:text="${project.status}"></dd><dt>Mentor</dt><dd th:text="${project.mentorName}"></dd><dt>Leader</dt><dd th:text="${project.leaderName ?: 'No current Leader'}"></dd></dl>
<form th:if="${project.canManage and project.status == 'PLANNED'}" th:action="@{/projects/{id}/activate(id=${project.id})}" method="post">
<button type="submit">Activate</button>
</form>
@@ -5,11 +5,13 @@
<main>
<h1>Create Project</h1>
<form method="post" th:action="@{/projects}" th:object="${projectForm}">
<p role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the highlighted Project details.</p>
<label for="name">Name</label><input id="name" th:field="*{name}" required maxlength="160">
<p th:errors="*{name}"></p>
<label for="description">Description</label><textarea id="description" th:field="*{description}"></textarea>
<label for="startDate">Start date</label><input id="startDate" type="date" th:field="*{startDate}" required>
<label for="endDate">End date</label><input id="endDate" type="date" th:field="*{endDate}" required>
<p th:errors="*{dateRangeValid}"></p>
<label for="leader">Initial Leader user ID</label><input id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required>
<p th:errors="*{initialLeaderUserId}"></p>
<button type="submit">Create Project</button>
@@ -7,7 +7,12 @@
<table><caption>Leadership history</caption><thead><tr><th scope="col">Leader</th><th scope="col">Started</th><th scope="col">Ended</th></tr></thead>
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}"></td><td th:text="${term.startedAt}"></td><td th:text="${term.endedAt}"></td></tr></tbody>
</table>
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}"><label for="leader">New Leader user ID</label><input id="leader" name="internUserId" type="number" min="1" required><button type="submit">Change Leader</button></form>
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}" th:object="${projectMemberForm}">
<p role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the Leader selection.</p>
<label for="leader">New Leader user ID</label><input id="leader" th:field="*{internUserId}" type="number" min="1" required>
<p th:errors="*{internUserId}"></p>
<button type="submit">Change Leader</button>
</form>
</main>
</body>
</html>
@@ -7,7 +7,12 @@
<table><caption>Membership history</caption><thead><tr><th scope="col">Intern</th><th scope="col">Joined</th><th scope="col">Left</th><th scope="col">Role</th></tr></thead>
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}"></td><td th:text="${member.joinedAt}"></td><td th:text="${member.leftAt}"></td><td th:text="${member.currentLeader} ? 'Leader' : 'Member'"></td></tr></tbody>
</table>
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}"><label for="intern">Intern user ID</label><input id="intern" name="internUserId" type="number" min="1" required><button type="submit">Add member</button></form>
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}" th:object="${projectMemberForm}">
<p role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the member selection.</p>
<label for="intern">Intern user ID</label><input id="intern" th:field="*{internUserId}" type="number" min="1" required>
<p th:errors="*{internUserId}"></p>
<button type="submit">Add member</button>
</form>
</main>
</body>
</html>