Merge commit 'baa0695c60153bc997ebf8b11adcfbdd2cbc1962' into work/reports-ui
# Conflicts: # src/main/resources/templates/projects/detail.html # src/main/resources/templates/projects/form.html # src/main/resources/templates/projects/leadership.html # src/main/resources/templates/projects/members.html
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
|
||||
- **Test type:** Integration
|
||||
- **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004`, `AUTH-011`, `DB-003`, `DB-007`
|
||||
- **Scenario IDs:** `AC-AUTH-010`, `AC-PRJ-001`–`AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009`
|
||||
- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-007`, `AC-AUTH-010`, `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009`
|
||||
- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest`
|
||||
- **Implementation commits:** `25a855e`, `dbf1202`
|
||||
- **Implementation commits:** `25a855e`, `dbf1202`, `af0eb3c`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
@@ -16,7 +16,7 @@ A Spring Boot integration test uses the platform-owned PostgreSQL 18.4 Testconta
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
Creation yields one Project, one active membership, and one current leadership term. Direct addition yields one membership per Project/Intern pair while allowing the same Intern in a second Project. Leader change yields one closed and one current term while the Task assignee ID remains unchanged. Activation persists `ACTIVE` and `activated_at` when every live Task is assigned to a current eligible membership; a live Task assigned to a closed membership leaves the Project `PLANNED` and the Task intact. Admin, owner, and historical member visibility is allowed; unrelated IDs are denied uniformly.
|
||||
Creation yields one Project, one active membership, and one current leadership term. Direct addition yields one membership per Project/Intern pair while allowing the same Intern in a second Project. Leader change yields one closed and one current term while the Task assignee ID remains unchanged. Activation persists `ACTIVE` and `activated_at` when every live Task is assigned to a current eligible membership; a live Task assigned to a closed membership leaves the Project `PLANNED` and the Task intact. Admin and owner visibility is allowed. Intern visibility requires a current membership while the Project is `PLANNED` or `ACTIVE`; a closed membership becomes visible again only after the Project is `COMPLETED`. Unrelated and former-member open-Project IDs are denied uniformly. Completed detail has no current Leader and no mutation capability for Admin, owner, or former members.
|
||||
|
||||
## RED
|
||||
|
||||
@@ -62,6 +62,21 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
|
||||
**GREEN:** after wiring the locked Project aggregate to Account eligibility and `TaskQueryService.countCurrentTasksAssignedOutside`, both focused activation tests passed. The valid Project became `ACTIVE`; the former-member assignee case threw `ProjectRuleViolationException`, retained `PLANNED`, and preserved its live Task.
|
||||
|
||||
## Review round 1 visibility and completed-detail regression
|
||||
|
||||
**RED command:**
|
||||
|
||||
```text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
./mvnw -Dtest=ProjectServiceIntegrationTest#listAndDetailQueriesEnforceRoleOwnershipAndMembershipWithoutIdDisclosure+completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader test
|
||||
```
|
||||
|
||||
**Observed RED:** `Tests run: 2, Failures: 1, Errors: 1`. The former member still received the active Project in `listVisible`, and completed `detail` threw `ProjectRuleViolationException: Project has no current Leader`.
|
||||
|
||||
**Observed GREEN:** the same command completed with `Tests run: 2, Failures: 0, Errors: 0, Skipped: 0` and `BUILD SUCCESS` against PostgreSQL 18.4. The test closes a real membership and, for completed history, closes all membership and leadership intervals before querying Admin, owner, and former-member detail DTOs.
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
@@ -70,12 +85,12 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
./mvnw test
|
||||
./mvnw -Dtest='Project*Test' test
|
||||
|
||||
[INFO] Tests run: 111, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] Tests run: 31, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
This test does not prove MockMvc authorization, Thymeleaf rendering, browser accessibility, a two-transaction lock race, or Iteration 2 invitations/removals/completion. Task query semantics have their own Task-owned unit evidence; this integration proves Project consumes that public service boundary atomically without importing Task persistence.
|
||||
This test does not prove MockMvc authorization, Thymeleaf rendering, browser accessibility, or a two-transaction leadership race. In particular it does not claim `AC-PRJ-002`; that concurrency proof remains Iteration 3 scope. Iteration 2 invitations/removals/completion services are also out of scope; SQL is used only to shape the already specified completed-history fixture. Task query semantics have their own Task-owned unit evidence; this integration proves Project consumes that public service boundary atomically without importing Task persistence.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- **Requirement IDs:** `ARC-002`, `ARC-005`–`ARC-007`, `OPS-018`–`OPS-020`, `TST-001`–`TST-010`
|
||||
- **Scenario IDs:** `I1-PRJ-01`–`I1-PRJ-05`
|
||||
- **Test class/method:** `com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest#projectPersistenceUsesTheRequiredLayerPackagesAndSpringDataJpa`
|
||||
- **Implementation commit:** `25a855e`
|
||||
- **Implementation commits:** `25a855e`, `af0eb3c`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
@@ -70,6 +70,22 @@ export PATH="$JAVA_HOME/bin:$PATH"
|
||||
[INFO] BUILD SUCCESS
|
||||
```
|
||||
|
||||
## Iteration 1 Javadoc retrofit verification
|
||||
|
||||
No behavioral RED was manufactured for documentation. The initial Project-scoped doclint run
|
||||
reported 29 warnings for missing type comments, an implicit public advice constructor, and
|
||||
accessor comments without main descriptions. After documenting every Project-owned production
|
||||
type and declared public/protected API, the same scoped command passed:
|
||||
|
||||
```text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
./mvnw -DskipTests -Dmaven.javadoc.failOnWarnings=true -Ddoclint=all -Dsubpackages=com.lab.labtimesheet.feature.project javadoc:javadoc
|
||||
|
||||
[INFO] BUILD SUCCESS
|
||||
[INFO] Total time: 2.579 s
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
This check does not prove database mappings, transaction behavior, MVC routing, or runtime authorization; those remain covered by PostgreSQL and MockMvc tests.
|
||||
This check does not prove database mappings, transaction behavior, MVC routing, or runtime authorization; those remain covered by PostgreSQL and MockMvc tests. Whole-application fail-on-warning Javadoc remains an integration responsibility after every feature owner completes the approved Iteration 1 retrofit; this evidence deliberately scopes generation to the Project-owned package.
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004`–`PRJ-006`, `PRJ-012`, `SEC-001`, `ERR-001`
|
||||
- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `AC-PRJ-006`, `I1-PRJ-04`, `I1-PRJ-05`
|
||||
- **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest`
|
||||
- **Implementation commits:** `25a855e`, `a9ee99a`, `2f25731`, `dbf1202`
|
||||
- **Implementation commits:** `25a855e`, `a9ee99a`, `2f25731`, `dbf1202`, `af0eb3c`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
Authenticated users receive only authorized Project routes; guessed IDs return a non-disclosing not-found response; valid Mentor create requests use the authenticated identity; invalid forms do not mutate; the planned-Project activation action is shown only to the owning Mentor; state changes require CSRF.
|
||||
Authenticated users receive only authorized Project routes; guessed IDs return the shared non-disclosing error contract; valid Mentor create requests use the authenticated identity; binding and domain validation re-render safe forms with retained input and no mutation. Completed owner/Admin/former-member views render without a current Leader or mutation forms. The planned-Project activation action is shown only to the owning Mentor; state changes require CSRF.
|
||||
|
||||
## Test method
|
||||
|
||||
@@ -16,7 +16,7 @@ MockMvc exercises the real controller, binding, Bean Validation, exception mappi
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
An authorized list request renders `projects/list`. An unauthorized direct ID returns 404. Member and leadership routes authorize through actor plus Project ID. A valid create redirects to the created detail ID; a blank name and zero Leader ID render field errors and make no service call. An owning Mentor can submit activation and is redirected to detail; non-owners do not receive that control. POST without CSRF returns 403.
|
||||
An authorized list request renders `projects/list`. Unauthorized and missing direct IDs produce the same `error/generic` view with `errorStatus`, `errorTitle`, and `errorMessage`; no exception detail is rendered. Member and leadership routes authorize through actor plus Project ID. A valid create redirects to the created detail ID; blank/date-invalid input and ineligible Leader/member selections retain safe input and render field errors without a successful mutation. An activation guard failure returns to detail with its safe rule message. Completed Project pages show no current Leader and no forms for owner, Admin, or former member. POST without CSRF returns 403.
|
||||
|
||||
## RED
|
||||
|
||||
@@ -62,9 +62,9 @@ export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
./mvnw test
|
||||
./mvnw -Dtest='Project*Test' test
|
||||
|
||||
[INFO] Tests run: 111, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] Tests run: 31, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
```
|
||||
|
||||
@@ -86,6 +86,20 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
|
||||
**GREEN:** after adding the CSRF-protected POST route and owner/status-conditional Thymeleaf form, the two focused tests passed; the full `ProjectControllerTest` class passed 10 tests with zero failures, errors, or skips.
|
||||
|
||||
## Review round 1 safe-validation, completed-page, and error-contract regression
|
||||
|
||||
**RED command:**
|
||||
|
||||
```text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
./mvnw -Dtest=ProjectControllerTest test
|
||||
```
|
||||
|
||||
**Observed RED:** `Tests run: 14, Failures: 5, Errors: 0`. Domain validation and activation returned blank 409 responses instead of their safe originating views; authorization/conflict responses had no `ModelAndView`; and completed detail rendered an empty Leader value instead of an explicit no-current-Leader state.
|
||||
|
||||
**Observed GREEN:** the same command passed the expanded owner/Admin/former-member matrix with `Tests run: 16, Failures: 0, Errors: 0, Skipped: 0` and `BUILD SUCCESS`. The Project test resource supplies only a contract fixture for `error/generic`; Reporting/UI owns the production shared template.
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, browser accessibility, or Iteration 2 invitation/exit/completion pages. Server-side activation authorization and Task-assignee atomicity are covered by Project domain and PostgreSQL integration tests.
|
||||
This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, or live-browser accessibility. Reporting/UI owns the final production `error/generic` template and will consume the documented three-key model contract after merging this pin; Project deliberately does not edit that shared asset. Iteration 2 invitation/exit/completion pages remain out of scope. Server-side activation authorization and Task-assignee atomicity are covered by Project domain and PostgreSQL integration tests.
|
||||
|
||||
+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);
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
<a id="primary-action" class="button button-primary" th:href="@{/projects/{id}/tasks(id=${project.id})}">View Tasks</a>
|
||||
<main>
|
||||
<p class="page-description" th:text="${project.description == null || #strings.isEmpty(project.description) ? 'No Project description.' : project.description}">Project description.</p>
|
||||
<p class="alert alert-error" role="alert" th:if="${projectError}" th:text="${projectError}"></p>
|
||||
<section class="panel metric-strip" aria-label="Project details">
|
||||
<div class="metric"><div class="metric-label">Status</div><div class="metric-value"><span class="badge" th:classappend="${project.status == 'ACTIVE' ? ' badge-success' : ''}" th:text="${project.status}">PLANNED</span></div></div>
|
||||
<div class="metric"><div class="metric-label">Mentor</div><div class="metric-value" th:text="${project.mentorName}">Mentor</div></div>
|
||||
<div class="metric"><div class="metric-label">Leader</div><div class="metric-value" th:text="${project.leaderName}">Leader</div></div>
|
||||
<div class="metric"><div class="metric-label">Leader</div><div class="metric-value" th:text="${project.leaderName ?: 'No current Leader'}">Leader</div></div>
|
||||
<div class="metric"><div class="metric-label">Project window</div><div class="metric-value" th:text="${#temporals.format(project.startDate, 'dd/MM/yyyy') + ' – ' + #temporals.format(project.endDate, 'dd/MM/yyyy')}">15/08/2026 – 30/09/2026</div></div>
|
||||
</section>
|
||||
<form class="inline-actions" th:if="${project.canManage and project.status == 'PLANNED'}" th:action="@{/projects/{id}/activate(id=${project.id})}" method="post">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<form class="panel form-panel form-grid" method="post" th:action="@{/projects}" th:object="${projectForm}">
|
||||
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">
|
||||
<strong>Please correct the highlighted fields.</strong>
|
||||
<ul><li th:each="error : ${#fields.allErrors()}" th:text="${error}">Validation error</li></ul>
|
||||
<ul><li th:each="fieldError : ${#fields.allErrors()}" th:text="${fieldError}">Validation error</li></ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="name">Name</label>
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}">Leader</td><td th:text="${#temporals.format(term.startedAt, 'dd/MM/yyyy HH:mm')}">Started</td><td th:text="${term.endedAt == null ? 'Current' : #temporals.format(term.endedAt, 'dd/MM/yyyy HH:mm')}">Current</td></tr></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}">
|
||||
<div class="field"><label class="field-label" for="leader">New Leader user ID</label><input class="control" id="leader" name="internUserId" type="number" min="1" required></div>
|
||||
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}" th:object="${projectMemberForm}">
|
||||
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the Leader selection.</div>
|
||||
<div class="field"><label class="field-label" for="leader">New Leader user ID</label><input class="control" id="leader" th:field="*{internUserId}" type="number" min="1" required th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'leadership-intern-user-error' : null}"><p class="field-error" id="leadership-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p></div>
|
||||
<span></span><button class="button button-primary" type="submit">Change Leader</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}">Intern</td><td th:text="${#temporals.format(member.joinedAt, 'dd/MM/yyyy HH:mm')}">Joined</td><td th:text="${member.leftAt == null ? 'Current' : #temporals.format(member.leftAt, 'dd/MM/yyyy HH:mm')}">Current</td><td><span class="badge" th:classappend="${member.currentLeader ? ' badge-success' : ''}" th:text="${member.currentLeader ? 'Leader' : 'Member'}">Member</span></td></tr></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}">
|
||||
<div class="field"><label class="field-label" for="intern">Intern user ID</label><input class="control" id="intern" name="internUserId" type="number" min="1" required></div>
|
||||
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}" th:object="${projectMemberForm}">
|
||||
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the member selection.</div>
|
||||
<div class="field"><label class="field-label" for="intern">Intern user ID</label><input class="control" id="intern" th:field="*{internUserId}" type="number" min="1" required th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'member-intern-user-error' : null}"><p class="field-error" id="member-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p></div>
|
||||
<span></span><button class="button button-primary" type="submit">Add member</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
+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