fix(projects): close review authorization and error gaps
This commit is contained in:
+143
-11
@@ -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) {
|
||||
|
||||
+4
@@ -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");
|
||||
}
|
||||
|
||||
+46
-8
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
|
||||
+18
@@ -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);
|
||||
}
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+8
@@ -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);
|
||||
}
|
||||
|
||||
+7
@@ -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());
|
||||
}
|
||||
|
||||
+42
@@ -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;
|
||||
}
|
||||
|
||||
+37
@@ -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);
|
||||
|
||||
+81
-3
@@ -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>
|
||||
|
||||
+165
-2
@@ -1,11 +1,13 @@
|
||||
package com.lab.labtimesheet.feature.project.controller;
|
||||
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
|
||||
@@ -14,15 +16,21 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectLeadershipTermView;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberView;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectService;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
@@ -83,7 +91,11 @@ class ProjectControllerTest {
|
||||
when(pages.detail(20L, 999L)).thenThrow(new ProjectAccessDeniedException());
|
||||
|
||||
mvc.perform(get("/projects/999"))
|
||||
.andExpect(status().isNotFound());
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(view().name("error/generic"))
|
||||
.andExpect(model().attribute("errorStatus", 404))
|
||||
.andExpect(model().attribute("errorTitle", "Project unavailable"))
|
||||
.andExpect(model().attributeExists("errorMessage"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -210,15 +222,166 @@ class ProjectControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/form"))
|
||||
.andExpect(model().attributeHasFieldErrors(
|
||||
"projectForm", "name", "initialLeaderUserId"));
|
||||
"projectForm", "name", "initialLeaderUserId"))
|
||||
.andExpect(model().attributeHasErrors("projectForm"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("2026-09-30")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("2026-08-15")));
|
||||
|
||||
verify(projects, never()).create(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void domainValidationErrorsStayOnTheirSafeFormsWithRetainedInput() throws Exception {
|
||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
|
||||
when(pages.members(10L, 30L)).thenReturn(List.of(new ProjectMemberView(
|
||||
40L, 20L, "Current Leader", Instant.parse("2026-08-15T00:00:00Z"), null, true)));
|
||||
when(pages.leadership(10L, 30L)).thenReturn(List.of(new ProjectLeadershipTermView(
|
||||
50L, "Current Leader", Instant.parse("2026-08-15T00:00:00Z"), null)));
|
||||
when(projects.create(
|
||||
10L,
|
||||
new ProjectCreateCommand(
|
||||
"Retained name",
|
||||
"Retained description",
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
99L)))
|
||||
.thenThrow(new ProjectRuleViolationException("Intern must have an active account and internship"));
|
||||
doThrow(new ProjectRuleViolationException("Intern is already a current Project member"))
|
||||
.when(projects).addMember(10L, 30L, 20L);
|
||||
doThrow(new ProjectRuleViolationException("Selected Intern is already the current Leader"))
|
||||
.when(projects).changeLeader(10L, 30L, 20L);
|
||||
|
||||
mvc.perform(post("/projects")
|
||||
.with(csrf())
|
||||
.param("name", "Retained name")
|
||||
.param("description", "Retained description")
|
||||
.param("startDate", "2026-08-15")
|
||||
.param("endDate", "2026-09-30")
|
||||
.param("initialLeaderUserId", "99"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/form"))
|
||||
.andExpect(model().attributeHasFieldErrors("projectForm", "initialLeaderUserId"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("Retained name")));
|
||||
|
||||
mvc.perform(post("/projects/30/members")
|
||||
.with(csrf())
|
||||
.param("internUserId", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/members"))
|
||||
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("value=\"20\"")));
|
||||
|
||||
mvc.perform(post("/projects/30/leadership")
|
||||
.with(csrf())
|
||||
.param("internUserId", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/leadership"))
|
||||
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("value=\"20\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void activationRuleErrorReturnsToDetailWithoutLosingSafeContext() throws Exception {
|
||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
|
||||
doThrow(new ProjectRuleViolationException("Every current Task assignee must be an active Project member"))
|
||||
.when(projects).activate(10L, 30L);
|
||||
|
||||
mvc.perform(post("/projects/30/activate").with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/detail"))
|
||||
.andExpect(model().attribute("projectError",
|
||||
"Every current Task assignee must be an active Project member"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("Every current Task assignee must be an active Project member")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "member@example.test")
|
||||
void uncaughtRuleConflictUsesGenericNonDisclosingErrorContract() throws Exception {
|
||||
when(pages.authenticatedUserId("member@example.test")).thenReturn(20L);
|
||||
when(pages.detail(20L, 30L))
|
||||
.thenThrow(new ProjectRuleViolationException("sensitive aggregate detail"));
|
||||
|
||||
mvc.perform(get("/projects/30"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(view().name("error/generic"))
|
||||
.andExpect(model().attribute("errorStatus", 409))
|
||||
.andExpect(model().attribute("errorTitle", "Project request could not be completed"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(not(containsString("sensitive aggregate detail"))));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"owner@example.test", "admin@example.test", "former@example.test"})
|
||||
void completedProjectPagesRenderForAuthorizedRolesWithoutCurrentLeaderOrMutationForms(String email)
|
||||
throws Exception {
|
||||
long actorId = switch (email) {
|
||||
case "owner@example.test" -> 10L;
|
||||
case "admin@example.test" -> 11L;
|
||||
default -> 20L;
|
||||
};
|
||||
when(pages.authenticatedUserId(email)).thenReturn(actorId);
|
||||
when(pages.detail(actorId, 30L)).thenReturn(new ProjectDetail(
|
||||
30L,
|
||||
"Completed Project",
|
||||
null,
|
||||
"COMPLETED",
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
"Mentor",
|
||||
null,
|
||||
false));
|
||||
when(pages.members(actorId, 30L)).thenReturn(List.of());
|
||||
when(pages.leadership(actorId, 30L)).thenReturn(List.of(new ProjectLeadershipTermView(
|
||||
50L,
|
||||
"Former Leader",
|
||||
Instant.parse("2026-08-15T00:00:00Z"),
|
||||
Instant.parse("2026-09-30T00:00:00Z"))));
|
||||
|
||||
mvc.perform(get("/projects/30").with(user(email)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("No current Leader")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(not(containsString(">Activate<"))));
|
||||
mvc.perform(get("/projects/30/members").with(user(email)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(not(containsString("Add member"))));
|
||||
mvc.perform(get("/projects/30/leadership").with(user(email)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("Former Leader")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(not(containsString("Change Leader"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void stateChangingRoutesRequireCsrf() throws Exception {
|
||||
mvc.perform(post("/projects"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
private static ProjectDetail plannedOwnerDetail() {
|
||||
return new ProjectDetail(
|
||||
30L,
|
||||
"Intern Portal Refresh",
|
||||
null,
|
||||
"PLANNED",
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
"Mentor",
|
||||
"Current Leader",
|
||||
true);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -1,6 +1,7 @@
|
||||
package com.lab.labtimesheet.feature.project.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -172,15 +173,17 @@ class ProjectServiceIntegrationTest {
|
||||
""", dbTime(NOW.plusSeconds(60)), mentorId, projectId, memberId);
|
||||
entityManager.clear();
|
||||
|
||||
assertEquals(projectId, projectPages.detail(memberId, projectId).id());
|
||||
assertTrue(projectService.taskMutationContext(memberId, projectId).activeMembers().stream()
|
||||
.noneMatch(member -> member.userId() == memberId));
|
||||
assertEquals(List.of(), projectPages.listVisible(memberId));
|
||||
assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(memberId, projectId));
|
||||
assertThrows(ProjectAccessDeniedException.class,
|
||||
() -> projectService.taskMutationContext(memberId, projectId));
|
||||
assertEquals(0, projectPages.dashboardSummary(memberId).activeProjectCount());
|
||||
assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader() {
|
||||
long adminId = user("admin-history@example.test", "ADMIN");
|
||||
long mentorId = user("mentor-history@example.test", "MENTOR");
|
||||
long leaderId = intern("leader-history@example.test", "I012");
|
||||
long memberId = intern("member-history@example.test", "I013");
|
||||
@@ -205,6 +208,18 @@ class ProjectServiceIntegrationTest {
|
||||
""", activatedAt, completedAt, completedAt, projectId);
|
||||
entityManager.clear();
|
||||
|
||||
assertEquals(List.of(projectId), projectPages.listVisible(memberId).stream()
|
||||
.map(summary -> summary.id())
|
||||
.toList());
|
||||
var ownerDetail = projectPages.detail(mentorId, projectId);
|
||||
var adminDetail = projectPages.detail(adminId, projectId);
|
||||
var formerMemberDetail = projectPages.detail(memberId, projectId);
|
||||
assertNull(ownerDetail.leaderName());
|
||||
assertNull(adminDetail.leaderName());
|
||||
assertNull(formerMemberDetail.leaderName());
|
||||
assertFalse(ownerDetail.canManage());
|
||||
assertFalse(adminDetail.canManage());
|
||||
assertFalse(formerMemberDetail.canManage());
|
||||
var taskContext = projectPages.taskContext(memberId, projectId);
|
||||
assertEquals("COMPLETED", taskContext.status());
|
||||
assertNull(taskContext.currentLeaderMembershipId());
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title th:text="${errorTitle}">Request unavailable</title></head>
|
||||
<body>
|
||||
<main>
|
||||
<h1 th:text="${errorTitle}">Request unavailable</h1>
|
||||
<p th:text="${errorMessage}">The request could not be completed.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user