feat(projects): add Intern members atomically
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package com.lab.labtimesheet.feature.project.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
||||
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;
|
||||
@@ -9,6 +10,9 @@ import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
|
||||
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
|
||||
import com.lab.labtimesheet.feature.task.service.TaskQueryService;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -68,9 +72,42 @@ public class ProjectService {
|
||||
*/
|
||||
@Transactional
|
||||
public void addMember(long actorUserId, long projectId, long internUserId) {
|
||||
addMembers(actorUserId, projectId, List.of(internUserId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a complete selection of eligible nonmembers while holding one Project write lock.
|
||||
* Every identifier is revalidated after owner authorization and before the aggregate changes,
|
||||
* so missing, duplicate, stale, ineligible, or current-member selections leave membership
|
||||
* unchanged.
|
||||
*
|
||||
* @param actorUserId authenticated owning Mentor
|
||||
* @param projectId Project to update
|
||||
* @param internUserIds distinct eligible Intern account identifiers
|
||||
* @throws com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException when
|
||||
* the selection is null, empty, malformed, duplicate, stale, ineligible, or already
|
||||
* contains a current member
|
||||
*/
|
||||
@Transactional
|
||||
public void addMembers(long actorUserId, long projectId, List<Long> internUserIds) {
|
||||
var project = lockedProject(projectId);
|
||||
project.authorizeOwner(actorUserId);
|
||||
project.addMember(actorUserId, eligibleIntern(internUserId), clock.instant());
|
||||
if (internUserIds == null || internUserIds.isEmpty()) {
|
||||
throw new ProjectRuleViolationException("Select at least one Intern");
|
||||
}
|
||||
if (internUserIds.stream().anyMatch(userId -> userId == null || userId <= 0)
|
||||
|| new HashSet<>(internUserIds).size() != internUserIds.size()) {
|
||||
throw new ProjectRuleViolationException("Intern selection is invalid");
|
||||
}
|
||||
|
||||
var selectedInterns = internUserIds.stream().map(this::eligibleIntern).toList();
|
||||
if (selectedInterns.stream().anyMatch(intern -> !intern.isEligible())
|
||||
|| selectedInterns.stream().anyMatch(intern -> project.hasCurrentMember(intern.userId()))) {
|
||||
throw new ProjectRuleViolationException("One or more selected Interns are no longer eligible");
|
||||
}
|
||||
|
||||
var addedAt = clock.instant();
|
||||
selectedInterns.forEach(intern -> project.addMember(actorUserId, intern, addedAt));
|
||||
projects.flush();
|
||||
}
|
||||
|
||||
@@ -145,7 +182,7 @@ public class ProjectService {
|
||||
}
|
||||
|
||||
private ProjectInternEligibility eligibleIntern(long userId) {
|
||||
return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId));
|
||||
return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId, LocalDate.now(clock)));
|
||||
}
|
||||
|
||||
private void requireActiveMentor(long userId) {
|
||||
|
||||
+47
@@ -106,6 +106,53 @@ class ProjectServiceIntegrationTest {
|
||||
user("other-mentor@example.test", "MENTOR"), projectId, Long.MAX_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerAddsSeveralEligibleMembersInOneLockedTransaction() {
|
||||
long mentorId = user("mentor-batch-add@example.test", "MENTOR");
|
||||
long leaderId = intern("leader-batch-add@example.test", "I017");
|
||||
long firstMemberId = intern("first-batch-add@example.test", "I018");
|
||||
long secondMemberId = intern("second-batch-add@example.test", "I019");
|
||||
long projectId = createProject(mentorId, leaderId, "Batch membership");
|
||||
|
||||
projectService.addMembers(mentorId, projectId, List.of(firstMemberId, secondMemberId));
|
||||
|
||||
assertEquals(3, count("""
|
||||
select count(*) from project_memberships
|
||||
where project_id = ? and left_at is null
|
||||
""", projectId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void memberBatchRejectsMissingDuplicateCurrentAndStaleSelectionsWithoutPartialMutation() {
|
||||
long mentorId = user("mentor-batch-guard@example.test", "MENTOR");
|
||||
long leaderId = intern("leader-batch-guard@example.test", "I020");
|
||||
long eligibleId = intern("eligible-batch-guard@example.test", "I021");
|
||||
long staleId = intern("stale-batch-guard@example.test", "I022");
|
||||
long projectId = createProject(mentorId, leaderId, "Batch guard");
|
||||
jdbc.update("update intern_profiles set internship_end_date = date '2026-08-13' where user_id = ?", staleId);
|
||||
entityManager.clear();
|
||||
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, null));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, List.of()));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, List.of(eligibleId, eligibleId)));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, List.of(Long.MAX_VALUE)));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, List.of(mentorId)));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, List.of(leaderId)));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, List.of(eligibleId, staleId)));
|
||||
|
||||
assertEquals(0, count("""
|
||||
select count(*) from project_memberships
|
||||
where project_id = ? and intern_user_id in (?, ?) and left_at is null
|
||||
""", projectId, eligibleId, staleId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaderChangeClosesOneTermAndDoesNotMoveTaskAssignments() {
|
||||
long mentorId = user("mentor-leader@example.test", "MENTOR");
|
||||
|
||||
Reference in New Issue
Block a user