- 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>
299 lines
13 KiB
Java
299 lines
13 KiB
Java
package com.recipeapp.household;
|
|
|
|
import com.recipeapp.auth.UserAccountRepository;
|
|
import com.recipeapp.auth.entity.UserAccount;
|
|
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;
|
|
import com.recipeapp.planning.VarietyScoreConfigRepository;
|
|
import com.recipeapp.planning.entity.VarietyScoreConfig;
|
|
import com.recipeapp.recipe.IngredientCategoryRepository;
|
|
import com.recipeapp.recipe.IngredientRepository;
|
|
import com.recipeapp.recipe.TagRepository;
|
|
import com.recipeapp.recipe.entity.Ingredient;
|
|
import com.recipeapp.recipe.entity.IngredientCategory;
|
|
import com.recipeapp.recipe.entity.Tag;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.security.SecureRandom;
|
|
import java.time.Instant;
|
|
import java.util.List;
|
|
import java.util.Optional;
|
|
import java.util.UUID;
|
|
|
|
@Service
|
|
public class HouseholdService {
|
|
|
|
private final UserAccountRepository userAccountRepository;
|
|
private final HouseholdRepository householdRepository;
|
|
private final HouseholdMemberRepository householdMemberRepository;
|
|
private final HouseholdInviteRepository householdInviteRepository;
|
|
private final IngredientRepository ingredientRepository;
|
|
private final IngredientCategoryRepository ingredientCategoryRepository;
|
|
private final TagRepository tagRepository;
|
|
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
|
|
private final PasswordEncoder passwordEncoder;
|
|
|
|
@Value("${app.base-url}")
|
|
private String baseUrl;
|
|
|
|
private static final SecureRandom RANDOM = new SecureRandom();
|
|
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
|
|
public HouseholdService(UserAccountRepository userAccountRepository,
|
|
HouseholdRepository householdRepository,
|
|
HouseholdMemberRepository householdMemberRepository,
|
|
HouseholdInviteRepository householdInviteRepository,
|
|
IngredientRepository ingredientRepository,
|
|
IngredientCategoryRepository ingredientCategoryRepository,
|
|
TagRepository tagRepository,
|
|
VarietyScoreConfigRepository varietyScoreConfigRepository,
|
|
PasswordEncoder passwordEncoder) {
|
|
this.userAccountRepository = userAccountRepository;
|
|
this.householdRepository = householdRepository;
|
|
this.householdMemberRepository = householdMemberRepository;
|
|
this.householdInviteRepository = householdInviteRepository;
|
|
this.ingredientRepository = ingredientRepository;
|
|
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
|
this.tagRepository = tagRepository;
|
|
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
|
this.passwordEncoder = passwordEncoder;
|
|
}
|
|
|
|
@Transactional
|
|
public HouseholdResponse createHousehold(String userEmail, CreateHouseholdRequest request) {
|
|
UserAccount user = findUser(userEmail);
|
|
|
|
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
|
|
throw new ConflictException("User is already in a household");
|
|
}
|
|
|
|
Household household = householdRepository.save(new Household(request.name(), user));
|
|
HouseholdMember member = householdMemberRepository.save(
|
|
new HouseholdMember(household, user, "planner"));
|
|
|
|
seedDefaultData(household);
|
|
|
|
return toHouseholdResponse(household, List.of(member));
|
|
}
|
|
|
|
@Transactional(readOnly = true)
|
|
public HouseholdResponse getMyHousehold(String userEmail) {
|
|
HouseholdMember member = findMembership(userEmail);
|
|
Household household = member.getHousehold();
|
|
List<HouseholdMember> members = householdMemberRepository.findByHouseholdId(household.getId());
|
|
return toHouseholdResponse(household, members);
|
|
}
|
|
|
|
@Transactional(readOnly = true)
|
|
public List<MemberResponse> getMembers(String userEmail) {
|
|
HouseholdMember member = findMembership(userEmail);
|
|
return householdMemberRepository.findByHouseholdId(member.getHousehold().getId())
|
|
.stream()
|
|
.map(this::toMemberResponse)
|
|
.toList();
|
|
}
|
|
|
|
@Transactional
|
|
public MemberResponse changeMemberRole(String requesterEmail, UUID targetUserId, String newRole) {
|
|
HouseholdMember requester = findMembership(requesterEmail);
|
|
UUID householdId = requester.getHousehold().getId();
|
|
|
|
HouseholdMember target = householdMemberRepository
|
|
.findByHouseholdIdAndUserId(householdId, targetUserId)
|
|
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
|
|
|
|
if (target.getRole().equals(newRole)) {
|
|
return toMemberResponse(target);
|
|
}
|
|
|
|
if ("member".equals(newRole) && "planner".equals(target.getRole())) {
|
|
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
|
|
if (plannerCount <= 1) {
|
|
throw new ConflictException("Cannot degrade the last planner");
|
|
}
|
|
}
|
|
|
|
target.setRole(newRole);
|
|
return toMemberResponse(householdMemberRepository.save(target));
|
|
}
|
|
|
|
@Transactional
|
|
public void removeMember(String requesterEmail, UUID targetUserId) {
|
|
HouseholdMember requester = findMembership(requesterEmail);
|
|
UUID householdId = requester.getHousehold().getId();
|
|
|
|
HouseholdMember target = householdMemberRepository
|
|
.findByHouseholdIdAndUserId(householdId, targetUserId)
|
|
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
|
|
|
|
if (target.getUser().getEmail().equalsIgnoreCase(requesterEmail)) {
|
|
throw new ConflictException("Planner cannot remove yourself");
|
|
}
|
|
|
|
if ("planner".equals(target.getRole())) {
|
|
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
|
|
if (plannerCount <= 1) {
|
|
throw new ConflictException("Cannot remove the last planner");
|
|
}
|
|
}
|
|
|
|
householdMemberRepository.delete(target);
|
|
}
|
|
|
|
@Transactional(readOnly = true)
|
|
public Optional<InviteResponse> getActiveInvite(String userEmail) {
|
|
HouseholdMember member = findMembership(userEmail);
|
|
return householdInviteRepository
|
|
.findByHouseholdIdAndInvalidatedAtIsNull(member.getHousehold().getId())
|
|
.filter(invite -> invite.getExpiresAt().isAfter(Instant.now()))
|
|
.map(this::toInviteResponse);
|
|
}
|
|
|
|
@Transactional
|
|
public InviteResponse createInvite(String userEmail) {
|
|
HouseholdMember member = findMembership(userEmail);
|
|
Household household = member.getHousehold();
|
|
|
|
householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId())
|
|
.ifPresent(existing -> {
|
|
existing.setInvalidatedAt(Instant.now());
|
|
householdInviteRepository.save(existing);
|
|
});
|
|
|
|
String code = generateInviteCode();
|
|
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
|
|
|
|
HouseholdInvite invite = new HouseholdInvite(household, code, expiresAt);
|
|
invite.setInvitedBy(member.getUser());
|
|
householdInviteRepository.save(invite);
|
|
|
|
return toInviteResponse(invite);
|
|
}
|
|
|
|
@Transactional(readOnly = true)
|
|
public InviteInfoResponse getInviteInfo(String code) {
|
|
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
|
|
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
|
|
|
|
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 or invalid"));
|
|
|
|
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);
|
|
|
|
Household household = invite.getHousehold();
|
|
householdMemberRepository.save(new HouseholdMember(household, user, "member"));
|
|
|
|
return new AcceptInviteResponse(household.getId(), household.getName(), "member");
|
|
}
|
|
|
|
private void seedDefaultData(Household household) {
|
|
var categories = ingredientCategoryRepository.saveAll(List.of(
|
|
new IngredientCategory(household, "Produce", (short) 1),
|
|
new IngredientCategory(household, "Fish & Meat", (short) 2),
|
|
new IngredientCategory(household, "Dairy & Eggs", (short) 3),
|
|
new IngredientCategory(household, "Dry Goods & Pasta", (short) 4),
|
|
new IngredientCategory(household, "Canned & Jarred", (short) 5),
|
|
new IngredientCategory(household, "Sauces & Condiments", (short) 6),
|
|
new IngredientCategory(household, "Frozen", (short) 7),
|
|
new IngredientCategory(household, "Bakery & Bread", (short) 8)));
|
|
|
|
tagRepository.saveAll(List.of(
|
|
new Tag(household, "Chicken", "protein"),
|
|
new Tag(household, "Fish", "protein"),
|
|
new Tag(household, "Beef", "protein"),
|
|
new Tag(household, "Pork", "protein"),
|
|
new Tag(household, "Vegetarian", "dietary"),
|
|
new Tag(household, "Vegan", "dietary"),
|
|
new Tag(household, "Pasta", "cuisine"),
|
|
new Tag(household, "Quick meal", "other"),
|
|
new Tag(household, "Child-friendly", "other")));
|
|
|
|
ingredientRepository.saveAll(List.of(
|
|
new Ingredient(household, "Salt", true),
|
|
new Ingredient(household, "Pepper", true),
|
|
new Ingredient(household, "Olive oil", true),
|
|
new Ingredient(household, "Butter", true),
|
|
new Ingredient(household, "Garlic", true),
|
|
new Ingredient(household, "Onion", true),
|
|
new Ingredient(household, "Sugar", true),
|
|
new Ingredient(household, "Flour", true),
|
|
new Ingredient(household, "Rice", true),
|
|
new Ingredient(household, "Pasta", true)));
|
|
|
|
varietyScoreConfigRepository.save(VarietyScoreConfig.defaults(household));
|
|
}
|
|
|
|
private String generateInviteCode() {
|
|
var sb = new StringBuilder(8);
|
|
for (int i = 0; i < 8; i++) {
|
|
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
private UserAccount findUser(String email) {
|
|
return userAccountRepository.findByEmailIgnoreCase(email)
|
|
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
|
}
|
|
|
|
private HouseholdMember findMembership(String email) {
|
|
return householdMemberRepository.findByUserEmailIgnoreCase(email)
|
|
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
|
|
}
|
|
|
|
private HouseholdResponse toHouseholdResponse(Household household, List<HouseholdMember> members) {
|
|
return new HouseholdResponse(
|
|
household.getId(),
|
|
household.getName(),
|
|
members.stream().map(this::toMemberResponse).toList());
|
|
}
|
|
|
|
private MemberResponse toMemberResponse(HouseholdMember member) {
|
|
return new MemberResponse(
|
|
member.getUser().getId(),
|
|
member.getUser().getDisplayName(),
|
|
member.getRole(),
|
|
member.getJoinedAt());
|
|
}
|
|
|
|
private InviteResponse toInviteResponse(HouseholdInvite invite) {
|
|
return new InviteResponse(
|
|
invite.getInviteCode(),
|
|
baseUrl + "/join/" + invite.getInviteCode(),
|
|
invite.getExpiresAt());
|
|
}
|
|
}
|