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>
264 lines
11 KiB
Java
264 lines
11 KiB
Java
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());
|
|
}
|
|
}
|