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 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 searchIngredients(UUID householdId, String search, Boolean isStaple) { List 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 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 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 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 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 tagIds) { if (tagIds == null || tagIds.isEmpty()) return; List 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()); } }