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 com.recipeapp.household.entity.Household; import com.recipeapp.household.entity.HouseholdInvite; import com.recipeapp.household.entity.HouseholdMember; 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.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; import java.time.Instant; import java.util.List; @Service public class HouseholdServiceImpl implements 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 static final SecureRandom RANDOM = new SecureRandom(); private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; public HouseholdServiceImpl(UserAccountRepository userAccountRepository, HouseholdRepository householdRepository, HouseholdMemberRepository householdMemberRepository, HouseholdInviteRepository householdInviteRepository, IngredientRepository ingredientRepository, IngredientCategoryRepository ingredientCategoryRepository, TagRepository tagRepository) { this.userAccountRepository = userAccountRepository; this.householdRepository = householdRepository; this.householdMemberRepository = householdMemberRepository; this.householdInviteRepository = householdInviteRepository; this.ingredientRepository = ingredientRepository; this.ingredientCategoryRepository = ingredientCategoryRepository; this.tagRepository = tagRepository; } @Override @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)); } @Override @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); } @Override @Transactional(readOnly = true) public List getMembers(String userEmail) { HouseholdMember member = findMembership(userEmail); return householdMemberRepository.findByHouseholdId(member.getHousehold().getId()) .stream() .map(this::toMemberResponse) .toList(); } @Override @Transactional public InviteResponse createInvite(String userEmail) { HouseholdMember member = findMembership(userEmail); Household household = member.getHousehold(); String code = generateInviteCode(); Instant expiresAt = Instant.now().plusSeconds(48 * 3600); HouseholdInvite invite = householdInviteRepository.save( new HouseholdInvite(household, code, expiresAt)); return new InviteResponse( invite.getInviteCode(), "https://yourapp.com/join/" + invite.getInviteCode(), invite.getExpiresAt()); } @Override @Transactional public AcceptInviteResponse acceptInvite(String userEmail, String code) { UserAccount user = findUser(userEmail); if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) { throw new ConflictException("User is already in a household"); } HouseholdInvite invite = householdInviteRepository.findByInviteCode(code) .orElseThrow(() -> new ResourceNotFoundException("Invite not found")); 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"); } 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))); } 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()); } }