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 members = householdMemberRepository.findByHouseholdId(household.getId()); return toHouseholdResponse(household, members); } @Transactional(readOnly = true) public List 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 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.saveAndFlush(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.getInvalidatedAt() != null || 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.getInvalidatedAt() != null || 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"); invite.setInvalidatedAt(Instant.now()); 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 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()); } }