feat(projects): enforce iteration 1 project lifecycle

This commit is contained in:
sechmachine
2026-08-14 23:32:14 +07:00
parent 5967f7f70d
commit 3483347bb8
9 changed files with 539 additions and 0 deletions
@@ -0,0 +1,14 @@
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;
}
}
@@ -0,0 +1,50 @@
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;
}
}
@@ -0,0 +1,205 @@
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();
}
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.projects.domain;
public final class ProjectAccessDenied extends RuntimeException {
public ProjectAccessDenied() {
super("Project access denied");
}
}
@@ -0,0 +1,37 @@
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;
}
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.projects.domain;
public final class ProjectRuleViolation extends RuntimeException {
public ProjectRuleViolation(String message) {
super(message);
}
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.projects.domain;
public enum ProjectStatus {
PLANNED,
ACTIVE,
COMPLETED
}