refactor(invite): move user creation into UserService, add generateCode limit

InviteService was directly injecting AppUserRepository, UserGroupRepository,
and PasswordEncoder — crossing domain boundaries that UserService owns.

- Add UserService.createUser() with duplicate-email guard
- Add UserService.findGroupsByIds() delegation method
- InviteService now only injects UserService (not user repositories)
- generateCode() now throws INTERNAL_ERROR after 10 failed attempts
  instead of looping indefinitely

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-19 09:03:29 +02:00
parent 103d454e14
commit f8f5ea634e
4 changed files with 126 additions and 62 deletions

View File

@@ -10,10 +10,7 @@ import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.InviteToken;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.InviteTokenRepository;
import org.raddatz.familienarchiv.repository.UserGroupRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -28,19 +25,20 @@ public class InviteService {
static final int MIN_PASSWORD_LENGTH = 8;
private static final String CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private static final int CODE_LENGTH = 10;
private static final int MAX_CODE_ATTEMPTS = 10;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final InviteTokenRepository inviteTokenRepository;
private final AppUserRepository appUserRepository;
private final UserGroupRepository userGroupRepository;
private final PasswordEncoder passwordEncoder;
private final UserService userService;
public String generateCode() {
String code;
do {
code = buildRandomCode();
} while (inviteTokenRepository.findByCode(code).isPresent());
return code;
for (int attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
String code = buildRandomCode();
if (inviteTokenRepository.findByCode(code).isEmpty()) {
return code;
}
}
throw DomainException.internal(ErrorCode.INTERNAL_ERROR, "Failed to generate unique invite code after " + MAX_CODE_ATTEMPTS + " attempts");
}
public InviteToken validateCode(String code) {
@@ -54,7 +52,7 @@ public class InviteService {
public InviteToken createInvite(CreateInviteRequest dto, AppUser creator) {
Set<UUID> groupIds = new HashSet<>();
if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) {
List<UserGroup> groups = userGroupRepository.findAllById(dto.getGroupIds());
List<UserGroup> groups = userService.findGroupsByIds(dto.getGroupIds());
groups.forEach(g -> groupIds.add(g.getId()));
}
@@ -85,34 +83,19 @@ public class InviteService {
"Password must be at least " + MIN_PASSWORD_LENGTH + " characters");
}
if (dto.getEmail() != null) {
appUserRepository.findByEmail(dto.getEmail()).ifPresent(existing -> {
throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE,
"Email already registered: " + dto.getEmail());
});
}
Set<UserGroup> groups = new HashSet<>();
if (!token.getGroupIds().isEmpty()) {
groups.addAll(userGroupRepository.findAllById(token.getGroupIds()));
}
AppUser user = AppUser.builder()
.email(dto.getEmail())
.password(passwordEncoder.encode(dto.getPassword()))
.firstName(dto.getFirstName())
.lastName(dto.getLastName())
.groups(groups)
.enabled(true)
.build();
AppUser saved = appUserRepository.save(user);
AppUser user = userService.createUser(
dto.getEmail(),
dto.getPassword(),
dto.getFirstName(),
dto.getLastName(),
token.getGroupIds()
);
token.setUseCount(token.getUseCount() + 1);
inviteTokenRepository.save(token);
log.info("User {} registered via invite code {}", dto.getEmail(), dto.getCode());
return saved;
return user;
}
@Transactional

View File

@@ -66,6 +66,33 @@ public class UserService {
return userRepository.save(user);
}
@Transactional
public AppUser createUser(String email, String rawPassword, String firstName, String lastName, Set<UUID> groupIds) {
userRepository.findByEmail(email).ifPresent(existing -> {
throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE, "Email already registered: " + email);
});
Set<UserGroup> groups = new HashSet<>();
if (groupIds != null && !groupIds.isEmpty()) {
groups.addAll(groupRepository.findAllById(groupIds));
}
AppUser user = AppUser.builder()
.email(email)
.password(passwordEncoder.encode(rawPassword))
.firstName(firstName)
.lastName(lastName)
.groups(groups)
.enabled(true)
.build();
return userRepository.save(user);
}
public List<UserGroup> findGroupsByIds(Collection<UUID> ids) {
return groupRepository.findAllById(ids);
}
@Transactional
public void deleteUser(UUID userId) {
AppUser user = userRepository.findById(userId)