Controller (5 tests): create household, get mine, get members, create invite, accept invite. Service (10 tests): household creation with planner role + seed data (categories, tags, staple ingredients), conflict when already in household, invite code generation with 48h expiry, accept invite with expired/used/conflict validation. Also includes: - Household, HouseholdMember, HouseholdInvite JPA entities - HouseholdInvite repository with findByInviteCode - Ingredient, IngredientCategory, Tag entities + repositories (created early for seed data, will be extended in recipe domain) - Fixed BackendApplicationTests to use AbstractIntegrationTest Total: 38 tests passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
206 lines
8.8 KiB
Java
206 lines
8.8 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 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<HouseholdMember> members = householdMemberRepository.findByHouseholdId(household.getId());
|
|
return toHouseholdResponse(household, members);
|
|
}
|
|
|
|
@Override
|
|
@Transactional(readOnly = true)
|
|
public List<MemberResponse> 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<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());
|
|
}
|
|
}
|