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>
|
||||
Reference in New Issue
Block a user