feat(projects): add Intern members atomically

This commit is contained in:
sechmachine
2026-08-15 15:01:48 +07:00
parent 826054c3c5
commit 64c9370aa0
3 changed files with 129 additions and 2 deletions
@@ -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) {
@@ -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");