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:
2026-04-01 21:56:51 +02:00
parent 4f457303d8
commit 9ec703abcd
88 changed files with 5267 additions and 0 deletions

View File

@@ -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());
}
}