feat(invite): add GET /v1/invites/{code} + rework POST accept as signup+join

- V027 migration: add invited_by FK column on household_invite
- HouseholdInvite entity: add invitedBy field, set on createInvite
- New DTOs: InviteInfoResponse, AcceptInviteRequest
- HouseholdService: add getInviteInfo(), rewrite acceptInvite(code, name, email, password) — creates UserAccount + joins household in one transaction
- HouseholdController: GET /v1/invites/{code} (unauthenticated), POST /v1/invites/{code}/accept creates session after join
- SecurityConfig: permitAll() for /v1/invites/*, sessionFixation().changeSessionId()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 21:24:26 +02:00
parent 60d84c0c94
commit 92f25e56fc
10 changed files with 271 additions and 63 deletions

View File

@@ -6,6 +6,7 @@ import com.recipeapp.common.ConflictException;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ValidationException;
import com.recipeapp.household.dto.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.recipeapp.household.entity.Household;
import com.recipeapp.household.entity.HouseholdInvite;
import com.recipeapp.household.entity.HouseholdMember;
@@ -38,6 +39,7 @@ public class HouseholdService {
private final IngredientCategoryRepository ingredientCategoryRepository;
private final TagRepository tagRepository;
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
private final PasswordEncoder passwordEncoder;
@Value("${app.base-url}")
private String baseUrl;
@@ -52,7 +54,8 @@ public class HouseholdService {
IngredientRepository ingredientRepository,
IngredientCategoryRepository ingredientCategoryRepository,
TagRepository tagRepository,
VarietyScoreConfigRepository varietyScoreConfigRepository) {
VarietyScoreConfigRepository varietyScoreConfigRepository,
PasswordEncoder passwordEncoder) {
this.userAccountRepository = userAccountRepository;
this.householdRepository = householdRepository;
this.householdMemberRepository = householdMemberRepository;
@@ -61,6 +64,7 @@ public class HouseholdService {
this.ingredientCategoryRepository = ingredientCategoryRepository;
this.tagRepository = tagRepository;
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
@@ -167,30 +171,45 @@ public class HouseholdService {
String code = generateInviteCode();
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
HouseholdInvite invite = householdInviteRepository.save(
new HouseholdInvite(household, code, expiresAt));
HouseholdInvite invite = new HouseholdInvite(household, code, expiresAt);
invite.setInvitedBy(member.getUser());
householdInviteRepository.save(invite);
return toInviteResponse(invite);
}
@Transactional
public AcceptInviteResponse acceptInvite(String userEmail, String code) {
UserAccount user = findUser(userEmail);
@Transactional(readOnly = true)
public InviteInfoResponse getInviteInfo(String code) {
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
throw new ConflictException("User is already in a household");
if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) {
throw new ResourceNotFoundException("Invite not found or invalid");
}
String inviterName = invite.getInvitedBy() != null
? invite.getInvitedBy().getDisplayName()
: invite.getHousehold().getCreatedBy().getDisplayName();
return new InviteInfoResponse(invite.getHousehold().getName(), inviterName);
}
@Transactional
public AcceptInviteResponse acceptInvite(String code, String name, String email, String rawPassword) {
if (userAccountRepository.existsByEmailIgnoreCase(email)) {
throw new ConflictException("Email already registered");
}
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
.orElseThrow(() -> new ResourceNotFoundException("Invite not found"));
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
if ("used".equals(invite.getStatus())) {
throw new ConflictException("Invite code already used");
}
if (invite.getExpiresAt().isBefore(Instant.now())) {
throw new ValidationException("Invite code has expired");
if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) {
throw new ResourceNotFoundException("Invite not found or invalid");
}
UserAccount user = userAccountRepository.save(
new UserAccount(email, name, passwordEncoder.encode(rawPassword)));
invite.setStatus("used");
householdInviteRepository.save(invite);