Implement Recipe, Planning, Shopping, Pantry, and Admin domains
Outside-in TDD for all 5 remaining domains (128 tests total): - Recipe: CRUD, ingredients autocomplete/patch, tags, categories (27 tests) - Planning: week plans, slots, confirm, suggestions, variety score, cooking logs (24 tests) - Shopping: generate from plan, publish, check/add/remove items (15 tests) - Pantry: CRUD with expiry sorting (11 tests) - Admin: user management, password reset, audit logging (13 tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
import com.recipeapp.recipe.entity.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class RecipeServiceImpl implements RecipeService {
|
||||
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final IngredientCategoryRepository ingredientCategoryRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
|
||||
public RecipeServiceImpl(RecipeRepository recipeRepository,
|
||||
IngredientRepository ingredientRepository,
|
||||
TagRepository tagRepository,
|
||||
IngredientCategoryRepository ingredientCategoryRepository,
|
||||
HouseholdRepository householdRepository) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin,
|
||||
String sort, int limit, int offset) {
|
||||
return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly,
|
||||
cookTimeMaxMin, sort, limit, offset);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public long countRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin) {
|
||||
return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public RecipeDetailResponse getRecipe(UUID householdId, UUID recipeId) {
|
||||
Recipe recipe = findRecipe(householdId, recipeId);
|
||||
return toDetailResponse(recipe);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public RecipeDetailResponse createRecipe(UUID householdId, RecipeCreateRequest request) {
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
|
||||
Recipe recipe = new Recipe(household, request.name(), request.serves(),
|
||||
request.cookTimeMin(), request.effort(), request.isChildFriendly());
|
||||
recipe.setHeroImageUrl(request.heroImageUrl());
|
||||
|
||||
addIngredients(recipe, household, request.ingredients());
|
||||
addSteps(recipe, request.steps());
|
||||
addTags(recipe, request.tagIds());
|
||||
|
||||
recipe = recipeRepository.save(recipe);
|
||||
return toDetailResponse(recipe);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public RecipeDetailResponse updateRecipe(UUID householdId, UUID recipeId, RecipeCreateRequest request) {
|
||||
Recipe recipe = findRecipe(householdId, recipeId);
|
||||
Household household = recipe.getHousehold();
|
||||
|
||||
recipe.setName(request.name());
|
||||
recipe.setServes(request.serves());
|
||||
recipe.setCookTimeMin(request.cookTimeMin());
|
||||
recipe.setEffort(request.effort());
|
||||
recipe.setChildFriendly(request.isChildFriendly());
|
||||
recipe.setHeroImageUrl(request.heroImageUrl());
|
||||
|
||||
recipe.getIngredients().clear();
|
||||
recipe.getSteps().clear();
|
||||
|
||||
addIngredients(recipe, household, request.ingredients());
|
||||
addSteps(recipe, request.steps());
|
||||
addTags(recipe, request.tagIds());
|
||||
|
||||
recipe = recipeRepository.save(recipe);
|
||||
return toDetailResponse(recipe);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteRecipe(UUID householdId, UUID recipeId) {
|
||||
Recipe recipe = findRecipe(householdId, recipeId);
|
||||
recipe.setDeletedAt(Instant.now());
|
||||
}
|
||||
|
||||
// ── Ingredients ──
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<IngredientResponse> searchIngredients(UUID householdId, String search, Boolean isStaple) {
|
||||
List<Ingredient> ingredients;
|
||||
if (search != null && isStaple != null) {
|
||||
ingredients = ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCaseAndIsStaple(
|
||||
householdId, search, isStaple);
|
||||
} else if (search != null) {
|
||||
ingredients = ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCase(householdId, search);
|
||||
} else if (isStaple != null) {
|
||||
ingredients = ingredientRepository.findByHouseholdIdAndIsStaple(householdId, isStaple);
|
||||
} else {
|
||||
ingredients = ingredientRepository.findByHouseholdId(householdId);
|
||||
}
|
||||
return ingredients.stream().map(this::toIngredientResponse).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public IngredientResponse patchIngredient(UUID householdId, UUID ingredientId, IngredientPatchRequest request) {
|
||||
Ingredient ingredient = ingredientRepository.findById(ingredientId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
|
||||
|
||||
if (request.name() != null) {
|
||||
ingredient.setName(request.name());
|
||||
}
|
||||
if (request.isStaple() != null) {
|
||||
ingredient.setStaple(request.isStaple());
|
||||
}
|
||||
if (request.categoryId() != null) {
|
||||
IngredientCategory category = ingredientCategoryRepository.findById(request.categoryId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Category not found"));
|
||||
ingredient.setCategory(category);
|
||||
}
|
||||
return toIngredientResponse(ingredient);
|
||||
}
|
||||
|
||||
// ── Tags ──
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<TagResponse> listTags(UUID householdId) {
|
||||
return tagRepository.findByHouseholdId(householdId).stream()
|
||||
.map(t -> new TagResponse(t.getId(), t.getName(), t.getTagType()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public TagResponse createTag(UUID householdId, TagCreateRequest request) {
|
||||
if (tagRepository.existsByHouseholdIdAndNameIgnoreCase(householdId, request.name())) {
|
||||
throw new ConflictException("Tag already exists");
|
||||
}
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
Tag tag = tagRepository.save(new Tag(household, request.name(), request.tagType()));
|
||||
return new TagResponse(tag.getId(), tag.getName(), tag.getTagType());
|
||||
}
|
||||
|
||||
// ── Ingredient Categories ──
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<IngredientCategoryResponse> listCategories(UUID householdId) {
|
||||
return ingredientCategoryRepository.findByHouseholdIdOrderBySortOrder(householdId).stream()
|
||||
.map(c -> new IngredientCategoryResponse(c.getId(), c.getName()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public IngredientCategoryResponse createCategory(UUID householdId, IngredientCategoryCreateRequest request) {
|
||||
if (ingredientCategoryRepository.existsByHouseholdIdAndNameIgnoreCase(householdId, request.name())) {
|
||||
throw new ConflictException("Category already exists");
|
||||
}
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
short nextSort = (short) (ingredientCategoryRepository.countByHouseholdId(householdId) + 1);
|
||||
IngredientCategory category = ingredientCategoryRepository.save(
|
||||
new IngredientCategory(household, request.name(), nextSort));
|
||||
return new IngredientCategoryResponse(category.getId(), category.getName());
|
||||
}
|
||||
|
||||
// ── Private helpers ──
|
||||
|
||||
private Recipe findRecipe(UUID householdId, UUID recipeId) {
|
||||
return recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
|
||||
}
|
||||
|
||||
private void addIngredients(Recipe recipe, Household household, List<RecipeCreateRequest.IngredientEntry> entries) {
|
||||
if (entries == null) return;
|
||||
for (var entry : entries) {
|
||||
Ingredient ingredient;
|
||||
if (entry.ingredientId() != null) {
|
||||
ingredient = ingredientRepository.findById(entry.ingredientId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
|
||||
} else {
|
||||
ingredient = ingredientRepository.save(new Ingredient(household, entry.newIngredientName(), false));
|
||||
}
|
||||
recipe.getIngredients().add(new RecipeIngredient(
|
||||
recipe, ingredient, entry.quantity(), entry.unit(), entry.sortOrder()));
|
||||
}
|
||||
}
|
||||
|
||||
private void addSteps(Recipe recipe, List<RecipeCreateRequest.StepEntry> entries) {
|
||||
if (entries == null) return;
|
||||
for (var entry : entries) {
|
||||
recipe.getSteps().add(new RecipeStep(recipe, entry.stepNumber(), entry.instruction()));
|
||||
}
|
||||
}
|
||||
|
||||
private void addTags(Recipe recipe, List<UUID> tagIds) {
|
||||
if (tagIds == null || tagIds.isEmpty()) return;
|
||||
List<Tag> tags = tagRepository.findAllById(tagIds);
|
||||
recipe.setTags(new HashSet<>(tags));
|
||||
}
|
||||
|
||||
private RecipeDetailResponse toDetailResponse(Recipe recipe) {
|
||||
var ingredients = recipe.getIngredients().stream()
|
||||
.map(ri -> {
|
||||
Ingredient ing = ri.getIngredient();
|
||||
RecipeDetailResponse.CategoryRef catRef = ing.getCategory() != null
|
||||
? new RecipeDetailResponse.CategoryRef(ing.getCategory().getId(), ing.getCategory().getName())
|
||||
: null;
|
||||
return new RecipeDetailResponse.IngredientItem(
|
||||
ing.getId(), ing.getName(), catRef,
|
||||
ri.getQuantity(), ri.getUnit(), ri.getSortOrder());
|
||||
})
|
||||
.toList();
|
||||
|
||||
var steps = recipe.getSteps().stream()
|
||||
.map(s -> new RecipeDetailResponse.StepItem(s.getStepNumber(), s.getInstruction()))
|
||||
.toList();
|
||||
|
||||
var tags = recipe.getTags().stream()
|
||||
.map(t -> new RecipeDetailResponse.TagItem(t.getId(), t.getName(), t.getTagType()))
|
||||
.toList();
|
||||
|
||||
return new RecipeDetailResponse(
|
||||
recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(),
|
||||
recipe.getEffort(), recipe.isChildFriendly(), recipe.getHeroImageUrl(),
|
||||
ingredients, steps, tags);
|
||||
}
|
||||
|
||||
private IngredientResponse toIngredientResponse(Ingredient ing) {
|
||||
RecipeDetailResponse.CategoryRef catRef = ing.getCategory() != null
|
||||
? new RecipeDetailResponse.CategoryRef(ing.getCategory().getId(), ing.getCategory().getName())
|
||||
: null;
|
||||
return new IngredientResponse(ing.getId(), ing.getName(), catRef, ing.isStaple());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user