refactor(project): adopt feature JPA boundaries
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package com.lab.labtimesheet.feature.project.controller;
|
||||
|
||||
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;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.security.Principal;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/projects")
|
||||
public class ProjectController {
|
||||
|
||||
private final ProjectQueryService pages;
|
||||
private final ProjectService projects;
|
||||
|
||||
public ProjectController(ProjectQueryService pages, ProjectService projects) {
|
||||
this.pages = pages;
|
||||
this.projects = projects;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String list(Principal principal, Model model) {
|
||||
model.addAttribute("projects", pages.listVisible(actorId(principal)));
|
||||
return "projects/list";
|
||||
}
|
||||
|
||||
@GetMapping("/new")
|
||||
public String createForm(Model model) {
|
||||
model.addAttribute("projectForm", new ProjectCreateForm());
|
||||
return "projects/form";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String create(
|
||||
Principal principal,
|
||||
@Valid @ModelAttribute("projectForm") ProjectCreateForm projectForm,
|
||||
BindingResult bindingResult) {
|
||||
if (bindingResult.hasErrors()) {
|
||||
return "projects/form";
|
||||
}
|
||||
long projectId = projects.create(actorId(principal), projectForm.toCommand());
|
||||
return "redirect:/projects/" + projectId;
|
||||
}
|
||||
|
||||
@GetMapping("/{projectId}")
|
||||
public String detail(Principal principal, @PathVariable long projectId, Model model) {
|
||||
model.addAttribute("project", pages.detail(actorId(principal), projectId));
|
||||
return "projects/detail";
|
||||
}
|
||||
|
||||
@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));
|
||||
return "projects/members";
|
||||
}
|
||||
|
||||
@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";
|
||||
}
|
||||
|
||||
@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));
|
||||
return "projects/leadership";
|
||||
}
|
||||
|
||||
@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";
|
||||
}
|
||||
|
||||
private long actorId(Principal principal) {
|
||||
return pages.authenticatedUserId(principal.getName());
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.lab.labtimesheet.feature.project.exception;
|
||||
|
||||
public final class ProjectAccessDeniedException extends RuntimeException {
|
||||
|
||||
public ProjectAccessDeniedException() {
|
||||
super("Project access denied");
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
|
||||
@RestControllerAdvice(assignableTypes = ProjectController.class)
|
||||
public class ProjectControllerAdvice {
|
||||
|
||||
@ExceptionHandler(ProjectAccessDeniedException.class)
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public void accessDenied() {
|
||||
}
|
||||
|
||||
@ExceptionHandler(ProjectRuleViolationException.class)
|
||||
@ResponseStatus(HttpStatus.CONFLICT)
|
||||
public void conflict() {
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.lab.labtimesheet.feature.project.exception;
|
||||
|
||||
public final class ProjectRuleViolationException extends RuntimeException {
|
||||
|
||||
public ProjectRuleViolationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.lab.labtimesheet.feature.project.model;
|
||||
|
||||
public record ProjectInternEligibility(long userId, boolean eligible) {
|
||||
|
||||
public ProjectInternEligibility {
|
||||
if (userId <= 0) {
|
||||
throw new IllegalArgumentException("Intern user ID must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isEligible() {
|
||||
return eligible;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.lab.labtimesheet.feature.project.model;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.model.entity.ProjectMembershipEntity;
|
||||
import java.time.Instant;
|
||||
|
||||
public record ProjectLeaderChange(ProjectMembershipEntity replacement, Instant effectiveAt) {
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
package com.lab.labtimesheet.feature.project.model;
|
||||
|
||||
public enum ProjectStatus {
|
||||
PLANNED,
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.lab.labtimesheet.feature.project.model.dto;
|
||||
|
||||
public record ProjectActorView(long userId, String role) {
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.lab.labtimesheet.feature.project.model.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record ProjectCreateCommand(
|
||||
String name,
|
||||
String description,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
long initialLeaderUserId) {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.lab.labtimesheet.feature.project.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDate;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
public record ProjectCreateForm(
|
||||
@NotBlank @Size(max = 160) String name,
|
||||
String description,
|
||||
@NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@NotNull @Positive Long initialLeaderUserId) {
|
||||
|
||||
public ProjectCreateForm() {
|
||||
this(null, null, null, null, null);
|
||||
}
|
||||
|
||||
@AssertTrue(message = "End date must not precede start date")
|
||||
public boolean isDateRangeValid() {
|
||||
return startDate == null || endDate == null || !endDate.isBefore(startDate);
|
||||
}
|
||||
|
||||
public ProjectCreateCommand toCommand() {
|
||||
return new ProjectCreateCommand(name, description, startDate, endDate, initialLeaderUserId);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package com.lab.labtimesheet.feature.project.model.dto;
|
||||
|
||||
public record ProjectDashboardSummary(long activeProjectCount, long distinctActiveMemberCount) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.lab.labtimesheet.feature.project.model.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record ProjectDetail(
|
||||
long id,
|
||||
String name,
|
||||
String description,
|
||||
String status,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String mentorName,
|
||||
String leaderName) {
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.lab.labtimesheet.feature.project.model.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ProjectLeadershipTermView(
|
||||
long id,
|
||||
String leaderName,
|
||||
Instant startedAt,
|
||||
Instant endedAt) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lab.labtimesheet.feature.project.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
public record ProjectMemberForm(@Positive long internUserId) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lab.labtimesheet.feature.project.model.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ProjectMemberView(
|
||||
long membershipId,
|
||||
long internUserId,
|
||||
String displayName,
|
||||
Instant joinedAt,
|
||||
Instant leftAt,
|
||||
boolean currentLeader) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lab.labtimesheet.feature.project.model.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record ProjectSummary(long id, String name, String status, LocalDate startDate, LocalDate endDate) {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.lab.labtimesheet.feature.project.model.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record ProjectTaskContext(
|
||||
long projectId,
|
||||
long mentorUserId,
|
||||
String status,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
Long currentLeaderMembershipId,
|
||||
List<ProjectTaskMemberView> activeMembers) {
|
||||
|
||||
public ProjectTaskContext {
|
||||
activeMembers = List.copyOf(activeMembers);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package com.lab.labtimesheet.feature.project.model.dto;
|
||||
|
||||
public record ProjectTaskMemberView(long membershipId, long userId, String displayName) {
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package com.lab.labtimesheet.feature.project.model.entity;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
||||
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
|
||||
import com.lab.labtimesheet.feature.project.model.ProjectLeaderChange;
|
||||
import com.lab.labtimesheet.feature.project.model.ProjectStatus;
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Entity
|
||||
@Table(name = "projects")
|
||||
public class ProjectEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "mentor_user_id", nullable = false)
|
||||
private long mentorUserId;
|
||||
|
||||
@Column(nullable = false, length = 160)
|
||||
private String name;
|
||||
|
||||
@Column
|
||||
private String description;
|
||||
|
||||
@Column(name = "start_date", nullable = false)
|
||||
private LocalDate startDate;
|
||||
|
||||
@Column(name = "end_date", nullable = false)
|
||||
private LocalDate endDate;
|
||||
|
||||
@OneToMany(mappedBy = "project", cascade = CascadeType.ALL)
|
||||
private List<ProjectMembershipEntity> memberships = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "project", cascade = CascadeType.ALL)
|
||||
private List<ProjectLeadershipTermEntity> leadershipTerms = new ArrayList<>();
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
private ProjectStatus status = ProjectStatus.PLANNED;
|
||||
|
||||
@Column(name = "activated_at")
|
||||
private Instant activatedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
protected ProjectEntity() {
|
||||
}
|
||||
|
||||
private ProjectEntity(
|
||||
long mentorUserId,
|
||||
String name,
|
||||
String description,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
Instant createdAt) {
|
||||
this.mentorUserId = mentorUserId;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.startDate = startDate;
|
||||
this.endDate = endDate;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = createdAt;
|
||||
}
|
||||
|
||||
public static ProjectEntity plan(
|
||||
long mentorUserId,
|
||||
String name,
|
||||
String description,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
ProjectInternEligibility initialLeader,
|
||||
Instant at) {
|
||||
if (mentorUserId <= 0) {
|
||||
throw new IllegalArgumentException("Mentor user ID must be positive");
|
||||
}
|
||||
var normalizedName = requireText(name, "Project name is required");
|
||||
Objects.requireNonNull(startDate, "startDate");
|
||||
Objects.requireNonNull(endDate, "endDate");
|
||||
Objects.requireNonNull(at, "at");
|
||||
if (endDate.isBefore(startDate)) {
|
||||
throw new ProjectRuleViolationException("Project end date must not precede its start date");
|
||||
}
|
||||
requireEligible(initialLeader);
|
||||
|
||||
var project = new ProjectEntity(
|
||||
mentorUserId,
|
||||
normalizedName,
|
||||
normalizeOptionalText(description),
|
||||
startDate,
|
||||
endDate,
|
||||
at);
|
||||
var membership = project.addEligibleMember(initialLeader, mentorUserId, at);
|
||||
project.leadershipTerms.add(new ProjectLeadershipTermEntity(project, membership, at, mentorUserId));
|
||||
return project;
|
||||
}
|
||||
|
||||
public ProjectMembershipEntity addMember(
|
||||
long actorMentorUserId, ProjectInternEligibility intern, Instant at) {
|
||||
requireOwner(actorMentorUserId);
|
||||
requireMutable();
|
||||
requireEligible(intern);
|
||||
Objects.requireNonNull(at, "at");
|
||||
if (hasCurrentMember(intern.userId())) {
|
||||
throw new ProjectRuleViolationException("Intern is already a current Project member");
|
||||
}
|
||||
return addEligibleMember(intern, actorMentorUserId, at);
|
||||
}
|
||||
|
||||
public ProjectLeaderChange prepareLeaderChange(
|
||||
long actorMentorUserId, ProjectInternEligibility intern, Instant at) {
|
||||
requireOwner(actorMentorUserId);
|
||||
requireMutable();
|
||||
requireEligible(intern);
|
||||
Objects.requireNonNull(at, "at");
|
||||
var replacement = currentMembership(intern.userId());
|
||||
var current = currentLeadershipTerm();
|
||||
if (current.internUserId() == intern.userId()) {
|
||||
throw new ProjectRuleViolationException("Selected Intern is already the current Leader");
|
||||
}
|
||||
|
||||
var effectiveAt = current.end(at, actorMentorUserId);
|
||||
updatedAt = effectiveAt;
|
||||
return new ProjectLeaderChange(replacement, effectiveAt);
|
||||
}
|
||||
|
||||
public void completeLeaderChange(long actorMentorUserId, ProjectLeaderChange change) {
|
||||
requireOwner(actorMentorUserId);
|
||||
Objects.requireNonNull(change, "change");
|
||||
if (leadershipTerms.stream().anyMatch(ProjectLeadershipTermEntity::isCurrent)) {
|
||||
throw new ProjectRuleViolationException("Current Leader must be closed before replacement");
|
||||
}
|
||||
leadershipTerms.add(new ProjectLeadershipTermEntity(
|
||||
this, change.replacement(), change.effectiveAt(), actorMentorUserId));
|
||||
}
|
||||
|
||||
public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) {
|
||||
requireOwner(actorMentorUserId);
|
||||
Objects.requireNonNull(at, "at");
|
||||
if (status != ProjectStatus.PLANNED) {
|
||||
throw new ProjectRuleViolationException("Only a planned Project can be activated");
|
||||
}
|
||||
if (memberships.stream().noneMatch(ProjectMembershipEntity::isCurrent)
|
||||
|| leadershipTerms.stream().noneMatch(ProjectLeadershipTermEntity::isCurrent)) {
|
||||
throw new ProjectRuleViolationException("Project requires a current member and Leader");
|
||||
}
|
||||
if (!allTaskAssigneesAreCurrent) {
|
||||
throw new ProjectRuleViolationException("Every current Task assignee must be an active Project member");
|
||||
}
|
||||
status = ProjectStatus.ACTIVE;
|
||||
activatedAt = at;
|
||||
updatedAt = at;
|
||||
}
|
||||
|
||||
public Long id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public long mentorUserId() {
|
||||
return mentorUserId;
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String description() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public LocalDate startDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public LocalDate endDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
public ProjectStatus status() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public Instant activatedAt() {
|
||||
return activatedAt;
|
||||
}
|
||||
|
||||
public List<ProjectMembershipEntity> memberships() {
|
||||
return List.copyOf(memberships);
|
||||
}
|
||||
|
||||
public List<ProjectLeadershipTermEntity> leadershipTerms() {
|
||||
return List.copyOf(leadershipTerms);
|
||||
}
|
||||
|
||||
public void authorizeOwner(long actorMentorUserId) {
|
||||
requireOwner(actorMentorUserId);
|
||||
}
|
||||
|
||||
public boolean hasCurrentMember(long internUserId) {
|
||||
return memberships.stream()
|
||||
.anyMatch(membership -> membership.internUserId() == internUserId && membership.isCurrent());
|
||||
}
|
||||
|
||||
public boolean hasEverHadMember(long internUserId) {
|
||||
return memberships.stream().anyMatch(membership -> membership.internUserId() == internUserId);
|
||||
}
|
||||
|
||||
public ProjectMembershipEntity currentLeader() {
|
||||
return currentMembership(currentLeadershipTerm().internUserId());
|
||||
}
|
||||
|
||||
private ProjectMembershipEntity addEligibleMember(
|
||||
ProjectInternEligibility intern, long addedByUserId, Instant at) {
|
||||
var membership = new ProjectMembershipEntity(this, intern.userId(), at, addedByUserId);
|
||||
memberships.add(membership);
|
||||
updatedAt = at;
|
||||
return membership;
|
||||
}
|
||||
|
||||
private ProjectMembershipEntity currentMembership(long internUserId) {
|
||||
return memberships.stream()
|
||||
.filter(membership -> membership.internUserId() == internUserId && membership.isCurrent())
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ProjectRuleViolationException(
|
||||
"Leader must be a current same-Project member"));
|
||||
}
|
||||
|
||||
private ProjectLeadershipTermEntity currentLeadershipTerm() {
|
||||
return leadershipTerms.stream()
|
||||
.filter(ProjectLeadershipTermEntity::isCurrent)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ProjectRuleViolationException("Project has no current Leader"));
|
||||
}
|
||||
|
||||
private void requireOwner(long actorMentorUserId) {
|
||||
if (mentorUserId != actorMentorUserId) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
}
|
||||
|
||||
private void requireMutable() {
|
||||
if (status == ProjectStatus.COMPLETED) {
|
||||
throw new ProjectRuleViolationException("Completed Projects are read-only");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireEligible(ProjectInternEligibility intern) {
|
||||
Objects.requireNonNull(intern, "intern");
|
||||
if (!intern.isEligible()) {
|
||||
throw new ProjectRuleViolationException("Intern must have an active account and internship");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireText(String value, String message) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new ProjectRuleViolationException(message);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeOptionalText(String value) {
|
||||
return value == null || value.trim().isEmpty() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.lab.labtimesheet.feature.project.model.entity;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "project_leadership_terms")
|
||||
public class ProjectLeadershipTermEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "project_id", nullable = false)
|
||||
private ProjectEntity project;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "membership_id", nullable = false)
|
||||
private ProjectMembershipEntity membership;
|
||||
|
||||
@Column(name = "started_at", nullable = false)
|
||||
private Instant startedAt;
|
||||
|
||||
@Column(name = "appointed_by_mentor_user_id", nullable = false)
|
||||
private long appointedByMentorUserId;
|
||||
|
||||
@Column(name = "ended_at")
|
||||
private Instant endedAt;
|
||||
|
||||
@Column(name = "ended_by_mentor_user_id")
|
||||
private Long endedByMentorUserId;
|
||||
|
||||
protected ProjectLeadershipTermEntity() {
|
||||
}
|
||||
|
||||
ProjectLeadershipTermEntity(
|
||||
ProjectEntity project,
|
||||
ProjectMembershipEntity membership,
|
||||
Instant startedAt,
|
||||
long appointedByMentorUserId) {
|
||||
this.project = project;
|
||||
this.membership = membership;
|
||||
this.startedAt = startedAt;
|
||||
this.appointedByMentorUserId = appointedByMentorUserId;
|
||||
}
|
||||
|
||||
public Long id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public long internUserId() {
|
||||
return membership.internUserId();
|
||||
}
|
||||
|
||||
public Instant startedAt() {
|
||||
return startedAt;
|
||||
}
|
||||
|
||||
public long appointedByMentorUserId() {
|
||||
return appointedByMentorUserId;
|
||||
}
|
||||
|
||||
public Instant endedAt() {
|
||||
return endedAt;
|
||||
}
|
||||
|
||||
public Long endedByMentorUserId() {
|
||||
return endedByMentorUserId;
|
||||
}
|
||||
|
||||
public boolean isCurrent() {
|
||||
return endedAt == null;
|
||||
}
|
||||
|
||||
Instant end(Instant at, long mentorUserId) {
|
||||
if (!isCurrent() || at.isBefore(startedAt)) {
|
||||
throw new ProjectRuleViolationException("Leadership term end must follow its start");
|
||||
}
|
||||
endedAt = at.equals(startedAt) ? startedAt.plusNanos(1_000) : at;
|
||||
endedByMentorUserId = mentorUserId;
|
||||
return endedAt;
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.lab.labtimesheet.feature.project.model.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "project_memberships")
|
||||
public class ProjectMembershipEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "project_id", nullable = false)
|
||||
private ProjectEntity project;
|
||||
|
||||
@Column(name = "intern_user_id", nullable = false)
|
||||
private long internUserId;
|
||||
|
||||
@Column(name = "joined_at", nullable = false)
|
||||
private Instant joinedAt;
|
||||
|
||||
@Column(name = "added_by_user_id", nullable = false)
|
||||
private long addedByUserId;
|
||||
|
||||
@Column(name = "left_at")
|
||||
private Instant leftAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
protected ProjectMembershipEntity() {
|
||||
}
|
||||
|
||||
ProjectMembershipEntity(ProjectEntity project, long internUserId, Instant joinedAt, long addedByUserId) {
|
||||
this.project = project;
|
||||
this.internUserId = internUserId;
|
||||
this.joinedAt = joinedAt;
|
||||
this.addedByUserId = addedByUserId;
|
||||
this.updatedAt = joinedAt;
|
||||
}
|
||||
|
||||
public Long id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public long internUserId() {
|
||||
return internUserId;
|
||||
}
|
||||
|
||||
public Instant joinedAt() {
|
||||
return joinedAt;
|
||||
}
|
||||
|
||||
public long addedByUserId() {
|
||||
return addedByUserId;
|
||||
}
|
||||
|
||||
public Instant leftAt() {
|
||||
return leftAt;
|
||||
}
|
||||
|
||||
public boolean isCurrent() {
|
||||
return leftAt == null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.lab.labtimesheet.feature.project.repository;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface ProjectRepository extends JpaRepository<ProjectEntity, Long> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select project from ProjectEntity project where project.id = :id")
|
||||
Optional<ProjectEntity> findLockedById(@Param("id") long id);
|
||||
|
||||
List<ProjectEntity> findAllByOrderByUpdatedAtDescIdDesc();
|
||||
|
||||
List<ProjectEntity> findByMentorUserIdOrderByUpdatedAtDescIdDesc(long mentorUserId);
|
||||
|
||||
@Query("""
|
||||
select distinct project from ProjectEntity project
|
||||
join project.memberships membership
|
||||
where membership.internUserId = :internUserId
|
||||
order by project.updatedAt desc, project.id desc
|
||||
""")
|
||||
List<ProjectEntity> findVisibleToIntern(@Param("internUserId") long internUserId);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.lab.labtimesheet.feature.project.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.model.ProjectStatus;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectDashboardSummary;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectLeadershipTermView;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberView;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView;
|
||||
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
|
||||
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class ProjectQueryService {
|
||||
|
||||
private final ProjectRepository projects;
|
||||
private final AccountService accounts;
|
||||
|
||||
public ProjectQueryService(ProjectRepository projects, AccountService accounts) {
|
||||
this.projects = projects;
|
||||
this.accounts = accounts;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long authenticatedUserId(String email) {
|
||||
return authenticatedActor(email).userId();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ProjectActorView authenticatedActor(String email) {
|
||||
try {
|
||||
var actor = accounts.requireIdentityByEmail(email);
|
||||
if (!"ACTIVE".equals(actor.status().name())) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
return new ProjectActorView(actor.id(), actor.role().name());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ProjectSummary> listVisible(long actorUserId) {
|
||||
var actor = activeActor(actorUserId);
|
||||
return visibleProjects(actor, actorUserId).stream().map(ProjectQueryService::summary).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ProjectDetail detail(long actorUserId, long projectId) {
|
||||
var project = visibleProject(actorUserId, projectId);
|
||||
return new ProjectDetail(
|
||||
project.id(),
|
||||
project.name(),
|
||||
project.description(),
|
||||
project.status().name(),
|
||||
project.startDate(),
|
||||
project.endDate(),
|
||||
displayName(project.mentorUserId()),
|
||||
displayName(project.currentLeader().internUserId()));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ProjectMemberView> members(long actorUserId, long projectId) {
|
||||
var project = visibleProject(actorUserId, projectId);
|
||||
var leaderUserId = project.currentLeader().internUserId();
|
||||
return project.memberships().stream()
|
||||
.map(membership -> new ProjectMemberView(
|
||||
membership.id(),
|
||||
membership.internUserId(),
|
||||
displayName(membership.internUserId()),
|
||||
membership.joinedAt(),
|
||||
membership.leftAt(),
|
||||
membership.isCurrent() && membership.internUserId() == leaderUserId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ProjectLeadershipTermView> leadership(long actorUserId, long projectId) {
|
||||
return visibleProject(actorUserId, projectId).leadershipTerms().stream()
|
||||
.sorted((left, right) -> right.startedAt().compareTo(left.startedAt()))
|
||||
.map(term -> new ProjectLeadershipTermView(
|
||||
term.id(),
|
||||
displayName(term.internUserId()),
|
||||
term.startedAt(),
|
||||
term.endedAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ProjectTaskContext taskContext(long actorUserId, long projectId) {
|
||||
var project = visibleProject(actorUserId, projectId);
|
||||
var activeMembers = project.memberships().stream()
|
||||
.filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId()))
|
||||
.map(membership -> new ProjectTaskMemberView(
|
||||
membership.id(),
|
||||
membership.internUserId(),
|
||||
displayName(membership.internUserId())))
|
||||
.toList();
|
||||
var currentLeader = project.currentLeader();
|
||||
var currentLeaderMembershipId = activeMembers.stream()
|
||||
.filter(member -> member.membershipId() == currentLeader.id())
|
||||
.map(ProjectTaskMemberView::membershipId)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
return new ProjectTaskContext(
|
||||
project.id(),
|
||||
project.mentorUserId(),
|
||||
project.status().name(),
|
||||
project.startDate(),
|
||||
project.endDate(),
|
||||
currentLeaderMembershipId,
|
||||
activeMembers);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ProjectDashboardSummary dashboardSummary(long actorUserId) {
|
||||
var actor = activeActor(actorUserId);
|
||||
var activeProjects = visibleProjects(actor, actorUserId).stream()
|
||||
.filter(project -> project.status() == ProjectStatus.ACTIVE)
|
||||
.filter(project -> !"INTERN".equals(actor.role().name())
|
||||
|| (accounts.isEligibleIntern(actorUserId) && project.hasCurrentMember(actorUserId)))
|
||||
.toList();
|
||||
var distinctActiveMembers = "MENTOR".equals(actor.role().name())
|
||||
? activeProjects.stream()
|
||||
.flatMap(project -> project.memberships().stream())
|
||||
.filter(membership -> membership.isCurrent()
|
||||
&& accounts.isEligibleIntern(membership.internUserId()))
|
||||
.map(membership -> membership.internUserId())
|
||||
.distinct()
|
||||
.count()
|
||||
: 0L;
|
||||
return new ProjectDashboardSummary(activeProjects.size(), distinctActiveMembers);
|
||||
}
|
||||
|
||||
private ProjectEntity visibleProject(long actorUserId, long projectId) {
|
||||
var actor = activeActor(actorUserId);
|
||||
var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new);
|
||||
var visible = "ADMIN".equals(actor.role().name())
|
||||
|| ("MENTOR".equals(actor.role().name()) && project.mentorUserId() == actorUserId)
|
||||
|| ("INTERN".equals(actor.role().name()) && project.hasEverHadMember(actorUserId));
|
||||
if (!visible) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
private List<ProjectEntity> visibleProjects(AccountIdentity actor, long actorUserId) {
|
||||
return switch (actor.role().name()) {
|
||||
case "ADMIN" -> projects.findAllByOrderByUpdatedAtDescIdDesc();
|
||||
case "MENTOR" -> projects.findByMentorUserIdOrderByUpdatedAtDescIdDesc(actorUserId);
|
||||
case "INTERN" -> projects.findVisibleToIntern(actorUserId);
|
||||
default -> throw new ProjectAccessDeniedException();
|
||||
};
|
||||
}
|
||||
|
||||
private AccountIdentity activeActor(long actorUserId) {
|
||||
try {
|
||||
var actor = accounts.requireIdentityById(actorUserId);
|
||||
if (!"ACTIVE".equals(actor.status().name())) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
return actor;
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
}
|
||||
|
||||
private String displayName(long userId) {
|
||||
try {
|
||||
return accounts.requireIdentityById(userId).displayName();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isEligibleIntern(long userId) {
|
||||
return accounts.isEligibleIntern(userId);
|
||||
}
|
||||
|
||||
private static ProjectSummary summary(ProjectEntity project) {
|
||||
return new ProjectSummary(
|
||||
project.id(),
|
||||
project.name(),
|
||||
project.status().name(),
|
||||
project.startDate(),
|
||||
project.endDate());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.lab.labtimesheet.feature.project.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
||||
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
|
||||
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
|
||||
import java.time.Clock;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class ProjectService {
|
||||
|
||||
private final ProjectRepository projects;
|
||||
private final AccountService accounts;
|
||||
private final Clock clock;
|
||||
|
||||
public ProjectService(
|
||||
ProjectRepository projects,
|
||||
AccountService accounts,
|
||||
Clock clock) {
|
||||
this.projects = projects;
|
||||
this.accounts = accounts;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public long create(long actorUserId, ProjectCreateCommand command) {
|
||||
requireActiveMentor(actorUserId);
|
||||
var project = ProjectEntity.plan(
|
||||
actorUserId,
|
||||
command.name(),
|
||||
command.description(),
|
||||
command.startDate(),
|
||||
command.endDate(),
|
||||
eligibleIntern(command.initialLeaderUserId()),
|
||||
clock.instant());
|
||||
return projects.saveAndFlush(project).id();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void addMember(long actorUserId, long projectId, long internUserId) {
|
||||
var project = lockedProject(projectId);
|
||||
project.authorizeOwner(actorUserId);
|
||||
project.addMember(actorUserId, eligibleIntern(internUserId), clock.instant());
|
||||
projects.flush();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void changeLeader(long actorUserId, long projectId, long internUserId) {
|
||||
var project = lockedProject(projectId);
|
||||
project.authorizeOwner(actorUserId);
|
||||
var change = project.prepareLeaderChange(actorUserId, eligibleIntern(internUserId), clock.instant());
|
||||
|
||||
// PostgreSQL rejects overlapping terms immediately. Flush the old term's
|
||||
// end before inserting its replacement; the transaction remains atomic.
|
||||
projects.flush();
|
||||
project.completeLeaderChange(actorUserId, change);
|
||||
projects.flush();
|
||||
}
|
||||
|
||||
private ProjectEntity lockedProject(long projectId) {
|
||||
return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new);
|
||||
}
|
||||
|
||||
private ProjectInternEligibility eligibleIntern(long userId) {
|
||||
return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId));
|
||||
}
|
||||
|
||||
private void requireActiveMentor(long userId) {
|
||||
try {
|
||||
var identity = accounts.requireIdentityById(userId);
|
||||
if (!"MENTOR".equals(identity.role().name()) || !"ACTIVE".equals(identity.status().name())) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
public record EligibleIntern(long userId, boolean accountActive, boolean internshipActive) {
|
||||
|
||||
public EligibleIntern {
|
||||
if (userId <= 0) {
|
||||
throw new IllegalArgumentException("Intern user ID must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isEligible() {
|
||||
return accountActive && internshipActive;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public final class LeadershipTerm {
|
||||
|
||||
private final ProjectMembership membership;
|
||||
private final Instant startedAt;
|
||||
private final long appointedByMentorUserId;
|
||||
private Instant endedAt;
|
||||
private Long endedByMentorUserId;
|
||||
|
||||
LeadershipTerm(ProjectMembership membership, Instant startedAt, long appointedByMentorUserId) {
|
||||
this.membership = membership;
|
||||
this.startedAt = startedAt;
|
||||
this.appointedByMentorUserId = appointedByMentorUserId;
|
||||
}
|
||||
|
||||
public long internUserId() {
|
||||
return membership.internUserId();
|
||||
}
|
||||
|
||||
public Instant startedAt() {
|
||||
return startedAt;
|
||||
}
|
||||
|
||||
public long appointedByMentorUserId() {
|
||||
return appointedByMentorUserId;
|
||||
}
|
||||
|
||||
public Instant endedAt() {
|
||||
return endedAt;
|
||||
}
|
||||
|
||||
public Long endedByMentorUserId() {
|
||||
return endedByMentorUserId;
|
||||
}
|
||||
|
||||
public boolean isCurrent() {
|
||||
return endedAt == null;
|
||||
}
|
||||
|
||||
void end(Instant at, long mentorUserId) {
|
||||
if (!isCurrent() || !at.isAfter(startedAt)) {
|
||||
throw new ProjectRuleViolation("Leadership term end must follow its start");
|
||||
}
|
||||
endedAt = at;
|
||||
endedByMentorUserId = mentorUserId;
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class Project {
|
||||
|
||||
private final long mentorUserId;
|
||||
private final String name;
|
||||
private final String description;
|
||||
private final LocalDate startDate;
|
||||
private final LocalDate endDate;
|
||||
private final List<ProjectMembership> memberships = new ArrayList<>();
|
||||
private final List<LeadershipTerm> leadershipTerms = new ArrayList<>();
|
||||
private ProjectStatus status = ProjectStatus.PLANNED;
|
||||
private Instant activatedAt;
|
||||
|
||||
private Project(
|
||||
long mentorUserId,
|
||||
String name,
|
||||
String description,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
this.mentorUserId = mentorUserId;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.startDate = startDate;
|
||||
this.endDate = endDate;
|
||||
}
|
||||
|
||||
public static Project plan(
|
||||
long mentorUserId,
|
||||
String name,
|
||||
String description,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
EligibleIntern initialLeader,
|
||||
Instant at) {
|
||||
if (mentorUserId <= 0) {
|
||||
throw new IllegalArgumentException("Mentor user ID must be positive");
|
||||
}
|
||||
var normalizedName = requireText(name, "Project name is required");
|
||||
Objects.requireNonNull(startDate, "startDate");
|
||||
Objects.requireNonNull(endDate, "endDate");
|
||||
Objects.requireNonNull(at, "at");
|
||||
if (endDate.isBefore(startDate)) {
|
||||
throw new ProjectRuleViolation("Project end date must not precede its start date");
|
||||
}
|
||||
requireEligible(initialLeader);
|
||||
|
||||
var project = new Project(
|
||||
mentorUserId,
|
||||
normalizedName,
|
||||
normalizeOptionalText(description),
|
||||
startDate,
|
||||
endDate);
|
||||
var membership = project.addEligibleMember(initialLeader, mentorUserId, at);
|
||||
project.leadershipTerms.add(new LeadershipTerm(membership, at, mentorUserId));
|
||||
return project;
|
||||
}
|
||||
|
||||
public ProjectMembership addMember(long actorMentorUserId, EligibleIntern intern, Instant at) {
|
||||
requireOwner(actorMentorUserId);
|
||||
requireMutable();
|
||||
requireEligible(intern);
|
||||
Objects.requireNonNull(at, "at");
|
||||
if (hasCurrentMember(intern.userId())) {
|
||||
throw new ProjectRuleViolation("Intern is already a current Project member");
|
||||
}
|
||||
return addEligibleMember(intern, actorMentorUserId, at);
|
||||
}
|
||||
|
||||
public void changeLeader(long actorMentorUserId, EligibleIntern intern, Instant at) {
|
||||
requireOwner(actorMentorUserId);
|
||||
requireMutable();
|
||||
requireEligible(intern);
|
||||
Objects.requireNonNull(at, "at");
|
||||
var replacement = currentMembership(intern.userId());
|
||||
var current = currentLeadershipTerm();
|
||||
if (current.internUserId() == intern.userId()) {
|
||||
throw new ProjectRuleViolation("Selected Intern is already the current Leader");
|
||||
}
|
||||
|
||||
current.end(at, actorMentorUserId);
|
||||
leadershipTerms.add(new LeadershipTerm(replacement, at, actorMentorUserId));
|
||||
}
|
||||
|
||||
public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) {
|
||||
requireOwner(actorMentorUserId);
|
||||
Objects.requireNonNull(at, "at");
|
||||
if (status != ProjectStatus.PLANNED) {
|
||||
throw new ProjectRuleViolation("Only a planned Project can be activated");
|
||||
}
|
||||
if (memberships.stream().noneMatch(ProjectMembership::isCurrent)
|
||||
|| leadershipTerms.stream().noneMatch(LeadershipTerm::isCurrent)) {
|
||||
throw new ProjectRuleViolation("Project requires a current member and Leader");
|
||||
}
|
||||
if (!allTaskAssigneesAreCurrent) {
|
||||
throw new ProjectRuleViolation("Every current Task assignee must be an active Project member");
|
||||
}
|
||||
status = ProjectStatus.ACTIVE;
|
||||
activatedAt = at;
|
||||
}
|
||||
|
||||
public long mentorUserId() {
|
||||
return mentorUserId;
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String description() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public LocalDate startDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public LocalDate endDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
public ProjectStatus status() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public Instant activatedAt() {
|
||||
return activatedAt;
|
||||
}
|
||||
|
||||
public List<ProjectMembership> memberships() {
|
||||
return List.copyOf(memberships);
|
||||
}
|
||||
|
||||
public List<LeadershipTerm> leadershipTerms() {
|
||||
return List.copyOf(leadershipTerms);
|
||||
}
|
||||
|
||||
public boolean hasCurrentMember(long internUserId) {
|
||||
return memberships.stream()
|
||||
.anyMatch(membership -> membership.internUserId() == internUserId && membership.isCurrent());
|
||||
}
|
||||
|
||||
public ProjectMembership currentLeader() {
|
||||
return currentMembership(currentLeadershipTerm().internUserId());
|
||||
}
|
||||
|
||||
private ProjectMembership addEligibleMember(EligibleIntern intern, long addedByUserId, Instant at) {
|
||||
var membership = new ProjectMembership(intern.userId(), at, addedByUserId);
|
||||
memberships.add(membership);
|
||||
return membership;
|
||||
}
|
||||
|
||||
private ProjectMembership currentMembership(long internUserId) {
|
||||
return memberships.stream()
|
||||
.filter(membership -> membership.internUserId() == internUserId && membership.isCurrent())
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ProjectRuleViolation("Leader must be a current same-Project member"));
|
||||
}
|
||||
|
||||
private LeadershipTerm currentLeadershipTerm() {
|
||||
return leadershipTerms.stream()
|
||||
.filter(LeadershipTerm::isCurrent)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ProjectRuleViolation("Project has no current Leader"));
|
||||
}
|
||||
|
||||
private void requireOwner(long actorMentorUserId) {
|
||||
if (mentorUserId != actorMentorUserId) {
|
||||
throw new ProjectAccessDenied();
|
||||
}
|
||||
}
|
||||
|
||||
private void requireMutable() {
|
||||
if (status == ProjectStatus.COMPLETED) {
|
||||
throw new ProjectRuleViolation("Completed Projects are read-only");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireEligible(EligibleIntern intern) {
|
||||
Objects.requireNonNull(intern, "intern");
|
||||
if (!intern.isEligible()) {
|
||||
throw new ProjectRuleViolation("Intern must have an active account and internship");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireText(String value, String message) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new ProjectRuleViolation(message);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeOptionalText(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
public final class ProjectAccessDenied extends RuntimeException {
|
||||
|
||||
public ProjectAccessDenied() {
|
||||
super("Project access denied");
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public final class ProjectMembership {
|
||||
|
||||
private final long internUserId;
|
||||
private final Instant joinedAt;
|
||||
private final long addedByUserId;
|
||||
private Instant leftAt;
|
||||
|
||||
ProjectMembership(long internUserId, Instant joinedAt, long addedByUserId) {
|
||||
this.internUserId = internUserId;
|
||||
this.joinedAt = joinedAt;
|
||||
this.addedByUserId = addedByUserId;
|
||||
}
|
||||
|
||||
public long internUserId() {
|
||||
return internUserId;
|
||||
}
|
||||
|
||||
public Instant joinedAt() {
|
||||
return joinedAt;
|
||||
}
|
||||
|
||||
public long addedByUserId() {
|
||||
return addedByUserId;
|
||||
}
|
||||
|
||||
public Instant leftAt() {
|
||||
return leftAt;
|
||||
}
|
||||
|
||||
public boolean isCurrent() {
|
||||
return leftAt == null;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
public final class ProjectRuleViolation extends RuntimeException {
|
||||
|
||||
public ProjectRuleViolation(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title th:text="${project.name}">Project</title></head>
|
||||
<body>
|
||||
<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>
|
||||
<nav aria-label="Project sections"><a th:href="@{/projects/{id}/members(id=${project.id})}">Members</a> <a th:href="@{/projects/{id}/leadership(id=${project.id})}">Leadership</a></nav>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title>Create Project</title></head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Create Project</h1>
|
||||
<form method="post" th:action="@{/projects}" th:object="${projectForm}">
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title>Project leadership</title></head>
|
||||
<body>
|
||||
<main>
|
||||
<h1><span th:text="${project.name}">Project</span> leadership</h1>
|
||||
<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 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>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title>Projects</title></head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Projects</h1>
|
||||
<a href="/projects/new">Create Project</a>
|
||||
<p th:if="${#lists.isEmpty(projects)}">No authorized Projects.</p>
|
||||
<table th:unless="${#lists.isEmpty(projects)}">
|
||||
<caption>Authorized Projects</caption>
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Dates</th></tr></thead>
|
||||
<tbody><tr th:each="project : ${projects}">
|
||||
<td><a th:href="@{/projects/{id}(id=${project.id})}" th:text="${project.name}">Project</a></td>
|
||||
<td th:text="${project.status}">PLANNED</td>
|
||||
<td><span th:text="${project.startDate}">Start</span> – <span th:text="${project.endDate}">End</span></td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title>Project members</title></head>
|
||||
<body>
|
||||
<main>
|
||||
<h1><span th:text="${project.name}">Project</span> members</h1>
|
||||
<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 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>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package com.lab.labtimesheet.feature.project.controller;
|
||||
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
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;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectService;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@WebMvcTest(ProjectController.class)
|
||||
class ProjectControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@MockitoBean
|
||||
private ProjectQueryService pages;
|
||||
|
||||
@MockitoBean
|
||||
private ProjectService projects;
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
|
||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||
when(pages.listVisible(10L)).thenReturn(List.of(new ProjectSummary(
|
||||
30L,
|
||||
"Intern Portal Refresh",
|
||||
"PLANNED",
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30))));
|
||||
|
||||
mvc.perform(get("/projects"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/list"))
|
||||
.andExpect(model().attributeExists("projects"));
|
||||
|
||||
verify(pages).listVisible(10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "member@example.test")
|
||||
void guessedProjectIdReturnsTheSameNotFoundResponseAsAMissingProject() throws Exception {
|
||||
when(pages.authenticatedUserId("member@example.test")).thenReturn(20L);
|
||||
when(pages.detail(20L, 999L)).thenThrow(new ProjectAccessDeniedException());
|
||||
|
||||
mvc.perform(get("/projects/999"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "member@example.test")
|
||||
void memberAndLeadershipPagesUseTheSameProjectScopedAuthorization() throws Exception {
|
||||
when(pages.authenticatedUserId("member@example.test")).thenReturn(20L);
|
||||
when(pages.detail(20L, 30L)).thenReturn(new ProjectDetail(
|
||||
30L,
|
||||
"Intern Portal Refresh",
|
||||
null,
|
||||
"PLANNED",
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
"Mentor",
|
||||
"Leader"));
|
||||
when(pages.members(20L, 30L)).thenReturn(List.of());
|
||||
when(pages.leadership(20L, 30L)).thenReturn(List.of());
|
||||
|
||||
mvc.perform(get("/projects/30/members"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/members"));
|
||||
mvc.perform(get("/projects/30/leadership"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/leadership"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void validCreateSubmissionUsesAuthenticatedMentorAndRedirectsToDetail() throws Exception {
|
||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||
when(projects.create(
|
||||
10L,
|
||||
new ProjectCreateCommand(
|
||||
"Intern Portal Refresh",
|
||||
"Refresh portal",
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
20L)))
|
||||
.thenReturn(30L);
|
||||
|
||||
mvc.perform(post("/projects")
|
||||
.with(csrf())
|
||||
.param("name", "Intern Portal Refresh")
|
||||
.param("description", "Refresh portal")
|
||||
.param("startDate", "2026-08-15")
|
||||
.param("endDate", "2026-09-30")
|
||||
.param("initialLeaderUserId", "20"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("/projects/30"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception {
|
||||
mvc.perform(post("/projects")
|
||||
.with(csrf())
|
||||
.param("name", " ")
|
||||
.param("startDate", "2026-09-30")
|
||||
.param("endDate", "2026-08-15")
|
||||
.param("initialLeaderUserId", "0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/form"))
|
||||
.andExpect(model().attributeHasFieldErrors(
|
||||
"projectForm", "name", "initialLeaderUserId"));
|
||||
|
||||
verify(projects, never()).create(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void stateChangingRoutesRequireCsrf() throws Exception {
|
||||
mvc.perform(post("/projects"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
+27
-22
@@ -1,21 +1,25 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
package com.lab.labtimesheet.feature.project.model.entity;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
||||
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
|
||||
import com.lab.labtimesheet.feature.project.model.ProjectStatus;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProjectTest {
|
||||
class ProjectEntityTest {
|
||||
|
||||
private static final Instant CREATED_AT = Instant.parse("2026-08-14T02:00:00Z");
|
||||
|
||||
@Test
|
||||
void planningCreatesTheInitialLeaderMembershipAndTermTogether() {
|
||||
var project = Project.plan(
|
||||
var project = ProjectEntity.plan(
|
||||
10L,
|
||||
" Intern Portal Refresh ",
|
||||
" Refresh the portal ",
|
||||
@@ -36,7 +40,7 @@ class ProjectTest {
|
||||
|
||||
@Test
|
||||
void planningRejectsAnIneligibleInitialLeaderAndInvalidDates() {
|
||||
assertThrows(ProjectRuleViolation.class, () -> Project.plan(
|
||||
assertThrows(ProjectRuleViolationException.class, () -> ProjectEntity.plan(
|
||||
10L,
|
||||
"Project",
|
||||
null,
|
||||
@@ -44,13 +48,13 @@ class ProjectTest {
|
||||
LocalDate.of(2026, 8, 31),
|
||||
activeIntern(20L),
|
||||
CREATED_AT));
|
||||
assertThrows(ProjectRuleViolation.class, () -> Project.plan(
|
||||
assertThrows(ProjectRuleViolationException.class, () -> ProjectEntity.plan(
|
||||
10L,
|
||||
"Project",
|
||||
null,
|
||||
LocalDate.of(2026, 8, 1),
|
||||
LocalDate.of(2026, 8, 31),
|
||||
new EligibleIntern(20L, false, true),
|
||||
new ProjectInternEligibility(20L, false),
|
||||
CREATED_AT));
|
||||
}
|
||||
|
||||
@@ -62,16 +66,16 @@ class ProjectTest {
|
||||
|
||||
assertEquals(2, project.memberships().size());
|
||||
assertTrue(project.hasCurrentMember(21L));
|
||||
assertThrows(ProjectRuleViolation.class,
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(120)));
|
||||
assertThrows(ProjectAccessDenied.class,
|
||||
assertThrows(ProjectAccessDeniedException.class,
|
||||
() -> project.addMember(11L, activeIntern(22L), CREATED_AT.plusSeconds(120)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSameInternCanBelongToSeparateProjects() {
|
||||
var first = plannedProject();
|
||||
var second = Project.plan(
|
||||
var second = ProjectEntity.plan(
|
||||
11L,
|
||||
"Second",
|
||||
null,
|
||||
@@ -91,38 +95,39 @@ class ProjectTest {
|
||||
var project = plannedProject();
|
||||
project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(60));
|
||||
|
||||
project.changeLeader(10L, activeIntern(21L), CREATED_AT.plusSeconds(120));
|
||||
var change = project.prepareLeaderChange(10L, activeIntern(21L), CREATED_AT.plusSeconds(120));
|
||||
project.completeLeaderChange(10L, change);
|
||||
|
||||
assertEquals(2, project.memberships().size());
|
||||
assertEquals(2, project.leadershipTerms().size());
|
||||
assertEquals(1, project.leadershipTerms().stream().filter(LeadershipTerm::isCurrent).count());
|
||||
assertEquals(1, project.leadershipTerms().stream().filter(ProjectLeadershipTermEntity::isCurrent).count());
|
||||
assertEquals(21L, project.currentLeader().internUserId());
|
||||
assertFalse(project.leadershipTerms().getFirst().isCurrent());
|
||||
assertThrows(ProjectRuleViolation.class,
|
||||
() -> project.changeLeader(10L, activeIntern(21L), CREATED_AT.plusSeconds(180)));
|
||||
assertThrows(ProjectRuleViolation.class,
|
||||
() -> project.changeLeader(10L, activeIntern(22L), CREATED_AT.plusSeconds(180)));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> project.prepareLeaderChange(10L, activeIntern(21L), CREATED_AT.plusSeconds(180)));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> project.prepareLeaderChange(10L, activeIntern(22L), CREATED_AT.plusSeconds(180)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void activationRequiresOwnerAndValidCurrentTaskAssignees() {
|
||||
var project = plannedProject();
|
||||
|
||||
assertThrows(ProjectAccessDenied.class,
|
||||
assertThrows(ProjectAccessDeniedException.class,
|
||||
() -> project.activate(11L, true, CREATED_AT.plusSeconds(60)));
|
||||
assertThrows(ProjectRuleViolation.class,
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> project.activate(10L, false, CREATED_AT.plusSeconds(60)));
|
||||
|
||||
project.activate(10L, true, CREATED_AT.plusSeconds(60));
|
||||
|
||||
assertEquals(ProjectStatus.ACTIVE, project.status());
|
||||
assertEquals(CREATED_AT.plusSeconds(60), project.activatedAt());
|
||||
assertThrows(ProjectRuleViolation.class,
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> project.activate(10L, true, CREATED_AT.plusSeconds(120)));
|
||||
}
|
||||
|
||||
private static Project plannedProject() {
|
||||
return Project.plan(
|
||||
private static ProjectEntity plannedProject() {
|
||||
return ProjectEntity.plan(
|
||||
10L,
|
||||
"Project",
|
||||
null,
|
||||
@@ -132,7 +137,7 @@ class ProjectTest {
|
||||
CREATED_AT);
|
||||
}
|
||||
|
||||
private static EligibleIntern activeIntern(long userId) {
|
||||
return new EligibleIntern(userId, true, true);
|
||||
private static ProjectInternEligibility activeIntern(long userId) {
|
||||
return new ProjectInternEligibility(userId, true);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.lab.labtimesheet.feature.project.repository;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectService;
|
||||
import java.util.Arrays;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
|
||||
class ProjectPersistenceStructureTest {
|
||||
|
||||
@Test
|
||||
void projectPersistenceUsesTheRequiredLayerPackagesAndSpringDataJpa() {
|
||||
assertTrue(ProjectEntity.class.getPackageName().startsWith("com.lab.labtimesheet.feature.project.model.entity"));
|
||||
assertTrue(ProjectService.class.getPackageName().startsWith("com.lab.labtimesheet.feature.project.service"));
|
||||
assertTrue(JpaRepository.class.isAssignableFrom(ProjectRepository.class));
|
||||
assertTrue(ProjectRepository.class.getInterfaces().length > 0);
|
||||
assertTrue(ProjectEntity.class.isAnnotationPresent(jakarta.persistence.Entity.class));
|
||||
assertFalse(Arrays.stream(ProjectService.class.getDeclaredFields())
|
||||
.map(field -> field.getType())
|
||||
.anyMatch(JdbcOperations.class::isAssignableFrom));
|
||||
assertThrows(ClassNotFoundException.class,
|
||||
() -> Class.forName("com.lab.labtimesheet.feature.project.model.entity.ProjectUserEntity"));
|
||||
assertThrows(ClassNotFoundException.class,
|
||||
() -> Class.forName("com.lab.labtimesheet.feature.project.model.entity.ProjectTaskEntity"));
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
package com.lab.labtimesheet.feature.project.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
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 java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class ProjectServiceIntegrationTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-14T00:00:00Z");
|
||||
|
||||
@Autowired
|
||||
private ProjectService projectService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private ProjectQueryService projectPages;
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Test
|
||||
void createsProjectMembershipAndLeadershipInOneTransaction() {
|
||||
long mentorId = user("mentor-create@example.test", "MENTOR");
|
||||
long leaderId = intern("leader-create@example.test", "I001");
|
||||
|
||||
long projectId = projectService.create(
|
||||
mentorId,
|
||||
new ProjectCreateCommand(
|
||||
"Intern Portal Refresh",
|
||||
"Refresh the portal",
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
leaderId));
|
||||
|
||||
assertEquals("PLANNED", text("select status from projects where id = ?", projectId));
|
||||
assertEquals(1, count("select count(*) from project_memberships where project_id = ? and left_at is null", projectId));
|
||||
assertEquals(1, count("select count(*) from project_leadership_terms where project_id = ? and ended_at is null", projectId));
|
||||
assertEquals(leaderId, number("""
|
||||
select membership.intern_user_id
|
||||
from project_leadership_terms leadership
|
||||
join project_memberships membership on membership.id = leadership.membership_id
|
||||
where leadership.project_id = ? and leadership.ended_at is null
|
||||
""", projectId));
|
||||
|
||||
long nonMentorId = intern("not-mentor@example.test", "I002");
|
||||
assertThrows(ProjectAccessDeniedException.class, () -> projectService.create(
|
||||
nonMentorId,
|
||||
new ProjectCreateCommand(
|
||||
"Denied",
|
||||
null,
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 8, 31),
|
||||
leaderId)));
|
||||
assertEquals(0, count("select count(*) from projects where name = 'Denied'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerAddsEligibleMemberAndDuplicateCurrentMembershipIsRejected() {
|
||||
long mentorId = user("mentor-add@example.test", "MENTOR");
|
||||
long leaderId = intern("leader-add@example.test", "I003");
|
||||
long memberId = intern("member-add@example.test", "I004");
|
||||
long projectId = createProject(mentorId, leaderId, "Membership");
|
||||
long otherProjectId = createProject(mentorId, memberId, "Concurrent membership");
|
||||
|
||||
projectService.addMember(mentorId, projectId, memberId);
|
||||
|
||||
assertEquals(1, count("""
|
||||
select count(*) from project_memberships
|
||||
where project_id = ? and intern_user_id = ? and left_at is null
|
||||
""", projectId, memberId));
|
||||
assertEquals(1, count("""
|
||||
select count(*) from project_memberships
|
||||
where project_id = ? and intern_user_id = ? and left_at is null
|
||||
""", otherProjectId, memberId));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMember(mentorId, projectId, memberId));
|
||||
assertThrows(ProjectAccessDeniedException.class,
|
||||
() -> projectService.addMember(
|
||||
user("other-mentor@example.test", "MENTOR"), projectId, Long.MAX_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaderChangeClosesOneTermAndDoesNotMoveTaskAssignments() {
|
||||
long mentorId = user("mentor-leader@example.test", "MENTOR");
|
||||
long firstLeaderId = intern("leader-one@example.test", "I005");
|
||||
long nextLeaderId = intern("leader-two@example.test", "I006");
|
||||
long projectId = createProject(mentorId, firstLeaderId, "Leadership");
|
||||
projectService.addMember(mentorId, projectId, nextLeaderId);
|
||||
long firstMembershipId = membershipId(projectId, firstLeaderId);
|
||||
jdbc.update("""
|
||||
insert into tasks (
|
||||
project_id, assignee_membership_id, title,
|
||||
created_by_membership_id, assigned_by_membership_id)
|
||||
values (?, ?, 'Keep assignee', ?, ?)
|
||||
""", projectId, firstMembershipId, firstMembershipId, firstMembershipId);
|
||||
|
||||
projectService.changeLeader(mentorId, projectId, nextLeaderId);
|
||||
|
||||
assertEquals(1, count("select count(*) from project_leadership_terms where project_id = ? and ended_at is null", projectId));
|
||||
assertEquals(1, count("select count(*) from project_leadership_terms where project_id = ? and ended_at is not null", projectId));
|
||||
assertEquals(firstMembershipId, number("select assignee_membership_id from tasks where project_id = ?", projectId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAndDetailQueriesEnforceRoleOwnershipAndMembershipWithoutIdDisclosure() {
|
||||
long adminId = user("admin-view@example.test", "ADMIN");
|
||||
long mentorId = user("mentor-view@example.test", "MENTOR");
|
||||
long otherMentorId = user("other-mentor-view@example.test", "MENTOR");
|
||||
long leaderId = intern("leader-view@example.test", "I009");
|
||||
long memberId = intern("member-view@example.test", "I010");
|
||||
long unrelatedId = intern("unrelated-view@example.test", "I011");
|
||||
long projectId = createProject(mentorId, leaderId, "Visible project");
|
||||
projectService.addMember(mentorId, projectId, memberId);
|
||||
|
||||
assertEquals(List.of(projectId), projectPages.listVisible(adminId).stream().map(summary -> summary.id()).toList());
|
||||
assertEquals(List.of(projectId), projectPages.listVisible(mentorId).stream().map(summary -> summary.id()).toList());
|
||||
assertEquals(List.of(), projectPages.listVisible(otherMentorId));
|
||||
assertEquals(List.of(projectId), projectPages.listVisible(memberId).stream().map(summary -> summary.id()).toList());
|
||||
assertEquals(List.of(), projectPages.listVisible(unrelatedId));
|
||||
assertEquals(projectId, projectPages.detail(memberId, projectId).id());
|
||||
assertEquals("INTERN", projectPages.authenticatedActor("member-view@example.test").role());
|
||||
var taskContext = projectPages.taskContext(memberId, projectId);
|
||||
assertEquals(mentorId, taskContext.mentorUserId());
|
||||
assertEquals("PLANNED", taskContext.status());
|
||||
assertEquals(2, taskContext.activeMembers().size());
|
||||
assertEquals(membershipId(projectId, leaderId), taskContext.currentLeaderMembershipId());
|
||||
jdbc.update("""
|
||||
update projects set status = 'ACTIVE', activated_at = ?, updated_at = ? where id = ?
|
||||
""", dbTime(NOW.plusSeconds(30)), dbTime(NOW.plusSeconds(30)), projectId);
|
||||
entityManager.clear();
|
||||
assertEquals(1, projectPages.dashboardSummary(adminId).activeProjectCount());
|
||||
assertEquals(1, projectPages.dashboardSummary(mentorId).activeProjectCount());
|
||||
assertEquals(2, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount());
|
||||
assertEquals(1, projectPages.dashboardSummary(memberId).activeProjectCount());
|
||||
assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(otherMentorId, projectId));
|
||||
assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(unrelatedId, projectId));
|
||||
assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(unrelatedId, Long.MAX_VALUE));
|
||||
|
||||
jdbc.update("""
|
||||
update project_memberships
|
||||
set left_at = ?, removed_by_mentor_user_id = ?
|
||||
where project_id = ? and intern_user_id = ?
|
||||
""", dbTime(NOW.plusSeconds(60)), mentorId, projectId, memberId);
|
||||
entityManager.clear();
|
||||
|
||||
assertEquals(projectId, projectPages.detail(memberId, projectId).id());
|
||||
assertTrue(projectPages.taskContext(memberId, projectId).activeMembers().stream()
|
||||
.noneMatch(member -> member.userId() == memberId));
|
||||
assertEquals(0, projectPages.dashboardSummary(memberId).activeProjectCount());
|
||||
assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount());
|
||||
}
|
||||
|
||||
private long createProject(long mentorId, long leaderId, String name) {
|
||||
return projectService.create(
|
||||
mentorId,
|
||||
new ProjectCreateCommand(
|
||||
name,
|
||||
null,
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
leaderId));
|
||||
}
|
||||
|
||||
private long user(String email, String role) {
|
||||
return jdbc.queryForObject("""
|
||||
insert into app_users (
|
||||
email, display_name, password_hash, global_role, account_status, activated_at)
|
||||
values (?, ?, '{noop}password-password', ?, 'ACTIVE', ?)
|
||||
returning id
|
||||
""", Long.class, email, email, role, dbTime(NOW));
|
||||
}
|
||||
|
||||
private long intern(String email, String studentCode) {
|
||||
long userId = user(email, "INTERN");
|
||||
jdbc.update("""
|
||||
insert into intern_profiles (
|
||||
user_id, student_code, internship_start_date, internship_end_date,
|
||||
internship_status, activated_at)
|
||||
values (?, ?, date '2026-08-01', date '2026-12-31', 'ACTIVE', ?)
|
||||
""", userId, studentCode, dbTime(NOW));
|
||||
return userId;
|
||||
}
|
||||
|
||||
private long membershipId(long projectId, long internUserId) {
|
||||
return number("""
|
||||
select id from project_memberships
|
||||
where project_id = ? and intern_user_id = ? and left_at is null
|
||||
""", projectId, internUserId);
|
||||
}
|
||||
|
||||
private int count(String sql, Object... arguments) {
|
||||
return jdbc.queryForObject(sql, Integer.class, arguments);
|
||||
}
|
||||
|
||||
private long number(String sql, Object... arguments) {
|
||||
return jdbc.queryForObject(sql, Long.class, arguments);
|
||||
}
|
||||
|
||||
private String text(String sql, Object... arguments) {
|
||||
return jdbc.queryForObject(sql, String.class, arguments);
|
||||
}
|
||||
|
||||
private OffsetDateTime dbTime(Instant instant) {
|
||||
return instant.atOffset(ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user