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,31 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.household.HouseholdMemberRepository;
|
||||
import com.recipeapp.household.entity.HouseholdMember;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
public class HouseholdResolver {
|
||||
|
||||
private final HouseholdMemberRepository householdMemberRepository;
|
||||
|
||||
public HouseholdResolver(HouseholdMemberRepository householdMemberRepository) {
|
||||
this.householdMemberRepository = householdMemberRepository;
|
||||
}
|
||||
|
||||
public UUID resolve(String userEmail) {
|
||||
return findMembership(userEmail).getHousehold().getId();
|
||||
}
|
||||
|
||||
public UUID resolveUserId(String userEmail) {
|
||||
return findMembership(userEmail).getUser().getId();
|
||||
}
|
||||
|
||||
private HouseholdMember findMembership(String userEmail) {
|
||||
return householdMemberRepository.findByUserEmailIgnoreCase(userEmail)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1/ingredient-categories")
|
||||
public class IngredientCategoryController {
|
||||
|
||||
private final RecipeService recipeService;
|
||||
private final HouseholdResolver householdResolver;
|
||||
|
||||
public IngredientCategoryController(RecipeService recipeService, HouseholdResolver householdResolver) {
|
||||
this.recipeService = recipeService;
|
||||
this.householdResolver = householdResolver;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<IngredientCategoryResponse> listCategories(Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return recipeService.listCategories(householdId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<IngredientCategoryResponse> createCategory(
|
||||
Principal principal,
|
||||
@Valid @RequestBody IngredientCategoryCreateRequest request) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
IngredientCategoryResponse response = recipeService.createCategory(householdId, request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,6 @@ import java.util.UUID;
|
||||
|
||||
public interface IngredientCategoryRepository extends JpaRepository<IngredientCategory, UUID> {
|
||||
List<IngredientCategory> findByHouseholdIdOrderBySortOrder(UUID householdId);
|
||||
boolean existsByHouseholdIdAndNameIgnoreCase(UUID householdId, String name);
|
||||
long countByHouseholdId(UUID householdId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1/ingredients")
|
||||
public class IngredientController {
|
||||
|
||||
private final RecipeService recipeService;
|
||||
private final HouseholdResolver householdResolver;
|
||||
|
||||
public IngredientController(RecipeService recipeService, HouseholdResolver householdResolver) {
|
||||
this.recipeService = recipeService;
|
||||
this.householdResolver = householdResolver;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<IngredientResponse> searchIngredients(
|
||||
Principal principal,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) Boolean isStaple) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return recipeService.searchIngredients(householdId, search, isStaple);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
public IngredientResponse patchIngredient(
|
||||
Principal principal,
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody IngredientPatchRequest request) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return recipeService.patchIngredient(householdId, id, request);
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,7 @@ import java.util.UUID;
|
||||
|
||||
public interface IngredientRepository extends JpaRepository<Ingredient, UUID> {
|
||||
List<Ingredient> findByHouseholdId(UUID householdId);
|
||||
List<Ingredient> findByHouseholdIdAndNameContainingIgnoreCase(UUID householdId, String name);
|
||||
List<Ingredient> findByHouseholdIdAndIsStaple(UUID householdId, boolean isStaple);
|
||||
List<Ingredient> findByHouseholdIdAndNameContainingIgnoreCaseAndIsStaple(UUID householdId, String name, boolean isStaple);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.common.ApiResponse;
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.net.URI;
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1/recipes")
|
||||
public class RecipeController {
|
||||
|
||||
private final RecipeService recipeService;
|
||||
private final HouseholdResolver householdResolver;
|
||||
|
||||
public RecipeController(RecipeService recipeService, HouseholdResolver householdResolver) {
|
||||
this.recipeService = recipeService;
|
||||
this.householdResolver = householdResolver;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<List<RecipeSummaryResponse>>> listRecipes(
|
||||
Principal principal,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) String effort,
|
||||
@RequestParam(required = false) Boolean isChildFriendly,
|
||||
@RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin,
|
||||
@RequestParam(required = false) String sort,
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
List<RecipeSummaryResponse> recipes = recipeService.listRecipes(
|
||||
householdId, search, effort, isChildFriendly, cookTimeMaxMin, sort, limit, offset);
|
||||
long total = recipeService.countRecipes(
|
||||
householdId, search, effort, isChildFriendly, cookTimeMaxMin);
|
||||
|
||||
var pagination = new ApiResponse.Pagination(total, limit, offset, offset + limit < total);
|
||||
var meta = new ApiResponse.Meta(pagination);
|
||||
return ResponseEntity.ok(ApiResponse.success(recipes, meta));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public RecipeDetailResponse getRecipe(Principal principal, @PathVariable UUID id) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return recipeService.getRecipe(householdId, id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<RecipeDetailResponse> createRecipe(
|
||||
Principal principal,
|
||||
@Valid @RequestBody RecipeCreateRequest request) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
RecipeDetailResponse detail = recipeService.createRecipe(householdId, request);
|
||||
return ResponseEntity.created(URI.create("/v1/recipes/" + detail.id())).body(detail);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public RecipeDetailResponse updateRecipe(
|
||||
Principal principal,
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody RecipeCreateRequest request) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return recipeService.updateRecipe(householdId, id, request);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteRecipe(Principal principal, @PathVariable UUID id) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
recipeService.deleteRecipe(householdId, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.recipe.dto.RecipeSummaryResponse;
|
||||
import com.recipeapp.recipe.entity.Recipe;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
|
||||
|
||||
Optional<Recipe> findByIdAndHouseholdIdAndDeletedAtIsNull(UUID id, UUID householdId);
|
||||
|
||||
List<Recipe> findByHouseholdIdAndDeletedAtIsNull(UUID householdId);
|
||||
|
||||
@Query("""
|
||||
SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse(
|
||||
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.isChildFriendly, r.heroImageUrl)
|
||||
FROM Recipe r
|
||||
WHERE r.household.id = :householdId
|
||||
AND r.deletedAt IS NULL
|
||||
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%')))
|
||||
AND (:effort IS NULL OR r.effort = :effort)
|
||||
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
|
||||
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||
ORDER BY r.createdAt DESC
|
||||
""")
|
||||
List<RecipeSummaryResponse> findFiltered(
|
||||
@Param("householdId") UUID householdId,
|
||||
@Param("search") String search,
|
||||
@Param("effort") String effort,
|
||||
@Param("isChildFriendly") Boolean isChildFriendly,
|
||||
@Param("cookTimeMaxMin") Integer cookTimeMaxMin,
|
||||
@Param("sort") String sort,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@Query("""
|
||||
SELECT COUNT(r)
|
||||
FROM Recipe r
|
||||
WHERE r.household.id = :householdId
|
||||
AND r.deletedAt IS NULL
|
||||
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%')))
|
||||
AND (:effort IS NULL OR r.effort = :effort)
|
||||
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
|
||||
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||
""")
|
||||
long countFiltered(
|
||||
@Param("householdId") UUID householdId,
|
||||
@Param("search") String search,
|
||||
@Param("effort") String effort,
|
||||
@Param("isChildFriendly") Boolean isChildFriendly,
|
||||
@Param("cookTimeMaxMin") Integer cookTimeMaxMin);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface RecipeService {
|
||||
|
||||
List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin,
|
||||
String sort, int limit, int offset);
|
||||
|
||||
long countRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin);
|
||||
|
||||
RecipeDetailResponse getRecipe(UUID householdId, UUID recipeId);
|
||||
|
||||
RecipeDetailResponse createRecipe(UUID householdId, RecipeCreateRequest request);
|
||||
|
||||
RecipeDetailResponse updateRecipe(UUID householdId, UUID recipeId, RecipeCreateRequest request);
|
||||
|
||||
void deleteRecipe(UUID householdId, UUID recipeId);
|
||||
|
||||
List<IngredientResponse> searchIngredients(UUID householdId, String search, Boolean isStaple);
|
||||
|
||||
IngredientResponse patchIngredient(UUID householdId, UUID ingredientId, IngredientPatchRequest request);
|
||||
|
||||
List<TagResponse> listTags(UUID householdId);
|
||||
|
||||
TagResponse createTag(UUID householdId, TagCreateRequest request);
|
||||
|
||||
List<IngredientCategoryResponse> listCategories(UUID householdId);
|
||||
|
||||
IngredientCategoryResponse createCategory(UUID householdId, IngredientCategoryCreateRequest request);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1/tags")
|
||||
public class TagController {
|
||||
|
||||
private final RecipeService recipeService;
|
||||
private final HouseholdResolver householdResolver;
|
||||
|
||||
public TagController(RecipeService recipeService, HouseholdResolver householdResolver) {
|
||||
this.recipeService = recipeService;
|
||||
this.householdResolver = householdResolver;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<TagResponse> listTags(Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return recipeService.listTags(householdId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<TagResponse> createTag(
|
||||
Principal principal,
|
||||
@Valid @RequestBody TagCreateRequest request) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
TagResponse response = recipeService.createTag(householdId, request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,5 @@ import java.util.UUID;
|
||||
|
||||
public interface TagRepository extends JpaRepository<Tag, UUID> {
|
||||
List<Tag> findByHouseholdId(UUID householdId);
|
||||
boolean existsByHouseholdIdAndNameIgnoreCase(UUID householdId, String name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record IngredientCategoryCreateRequest(
|
||||
@NotBlank @Size(max = 50) String name
|
||||
) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record IngredientCategoryResponse(UUID id, String name) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record IngredientPatchRequest(
|
||||
String name,
|
||||
Boolean isStaple,
|
||||
UUID categoryId
|
||||
) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record IngredientResponse(
|
||||
UUID id,
|
||||
String name,
|
||||
RecipeDetailResponse.CategoryRef category,
|
||||
boolean isStaple
|
||||
) {}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record RecipeCreateRequest(
|
||||
@NotBlank @Size(max = 200) String name,
|
||||
@Min(1) @Max(20) short serves,
|
||||
@Min(0) short cookTimeMin,
|
||||
@NotBlank @Pattern(regexp = "easy|medium|hard") String effort,
|
||||
boolean isChildFriendly,
|
||||
@Size(max = 500) String heroImageUrl,
|
||||
@NotEmpty @Valid List<IngredientEntry> ingredients,
|
||||
@Valid List<StepEntry> steps,
|
||||
@NotEmpty List<UUID> tagIds
|
||||
) {
|
||||
public record IngredientEntry(
|
||||
UUID ingredientId,
|
||||
@Size(max = 200) String newIngredientName,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal quantity,
|
||||
@NotBlank @Size(max = 20) String unit,
|
||||
short sortOrder
|
||||
) {}
|
||||
|
||||
public record StepEntry(
|
||||
@Min(1) short stepNumber,
|
||||
@NotBlank String instruction
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record RecipeDetailResponse(
|
||||
UUID id,
|
||||
String name,
|
||||
short serves,
|
||||
short cookTimeMin,
|
||||
String effort,
|
||||
boolean isChildFriendly,
|
||||
String heroImageUrl,
|
||||
List<IngredientItem> ingredients,
|
||||
List<StepItem> steps,
|
||||
List<TagItem> tags
|
||||
) {
|
||||
public record IngredientItem(
|
||||
UUID ingredientId,
|
||||
String name,
|
||||
CategoryRef category,
|
||||
BigDecimal quantity,
|
||||
String unit,
|
||||
short sortOrder
|
||||
) {}
|
||||
|
||||
public record CategoryRef(UUID id, String name) {}
|
||||
|
||||
public record StepItem(short stepNumber, String instruction) {}
|
||||
|
||||
public record TagItem(UUID id, String name, String tagType) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record RecipeSummaryResponse(
|
||||
UUID id,
|
||||
String name,
|
||||
short serves,
|
||||
short cookTimeMin,
|
||||
String effort,
|
||||
boolean isChildFriendly,
|
||||
String heroImageUrl
|
||||
) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record TagCreateRequest(
|
||||
@NotBlank @Size(max = 50) String name,
|
||||
@NotBlank @Pattern(regexp = "protein|dietary|cuisine|other") String tagType
|
||||
) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record TagResponse(UUID id, String name, String tagType) {}
|
||||
112
backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java
Normal file
112
backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java
Normal file
@@ -0,0 +1,112 @@
|
||||
package com.recipeapp.recipe.entity;
|
||||
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "recipe")
|
||||
public class Recipe {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "household_id", nullable = false)
|
||||
private Household household;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private short serves;
|
||||
|
||||
@Column(name = "cook_time_min", nullable = false)
|
||||
private short cookTimeMin;
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
private String effort;
|
||||
|
||||
@Column(name = "is_child_friendly", nullable = false)
|
||||
private boolean isChildFriendly;
|
||||
|
||||
@Column(name = "hero_image_url", length = 500)
|
||||
private String heroImageUrl;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private Instant deletedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("sortOrder")
|
||||
private List<RecipeIngredient> ingredients = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("stepNumber")
|
||||
private List<RecipeStep> steps = new ArrayList<>();
|
||||
|
||||
@ManyToMany
|
||||
@JoinTable(name = "recipe_tag",
|
||||
joinColumns = @JoinColumn(name = "recipe_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
||||
private Set<Tag> tags = new HashSet<>();
|
||||
|
||||
protected Recipe() {}
|
||||
|
||||
public Recipe(Household household, String name, short serves, short cookTimeMin,
|
||||
String effort, boolean isChildFriendly) {
|
||||
this.household = household;
|
||||
this.name = name;
|
||||
this.serves = serves;
|
||||
this.cookTimeMin = cookTimeMin;
|
||||
this.effort = effort;
|
||||
this.isChildFriendly = isChildFriendly;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public short getServes() { return serves; }
|
||||
public void setServes(short serves) { this.serves = serves; }
|
||||
public short getCookTimeMin() { return cookTimeMin; }
|
||||
public void setCookTimeMin(short cookTimeMin) { this.cookTimeMin = cookTimeMin; }
|
||||
public String getEffort() { return effort; }
|
||||
public void setEffort(String effort) { this.effort = effort; }
|
||||
public boolean isChildFriendly() { return isChildFriendly; }
|
||||
public void setChildFriendly(boolean childFriendly) { isChildFriendly = childFriendly; }
|
||||
public String getHeroImageUrl() { return heroImageUrl; }
|
||||
public void setHeroImageUrl(String heroImageUrl) { this.heroImageUrl = heroImageUrl; }
|
||||
public Instant getDeletedAt() { return deletedAt; }
|
||||
public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
public List<RecipeIngredient> getIngredients() { return ingredients; }
|
||||
public List<RecipeStep> getSteps() { return steps; }
|
||||
public Set<Tag> getTags() { return tags; }
|
||||
public void setTags(Set<Tag> tags) { this.tags = tags; }
|
||||
|
||||
public boolean isDeleted() { return deletedAt != null; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.recipeapp.recipe.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "recipe_ingredient")
|
||||
public class RecipeIngredient {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "recipe_id", nullable = false)
|
||||
private Recipe recipe;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "ingredient_id", nullable = false)
|
||||
private Ingredient ingredient;
|
||||
|
||||
@Column(nullable = false, precision = 8, scale = 2)
|
||||
private BigDecimal quantity;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String unit;
|
||||
|
||||
@Column(name = "sort_order", nullable = false)
|
||||
private short sortOrder;
|
||||
|
||||
protected RecipeIngredient() {}
|
||||
|
||||
public RecipeIngredient(Recipe recipe, Ingredient ingredient, BigDecimal quantity,
|
||||
String unit, short sortOrder) {
|
||||
this.recipe = recipe;
|
||||
this.ingredient = ingredient;
|
||||
this.quantity = quantity;
|
||||
this.unit = unit;
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Recipe getRecipe() { return recipe; }
|
||||
public Ingredient getIngredient() { return ingredient; }
|
||||
public BigDecimal getQuantity() { return quantity; }
|
||||
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
|
||||
public String getUnit() { return unit; }
|
||||
public void setUnit(String unit) { this.unit = unit; }
|
||||
public short getSortOrder() { return sortOrder; }
|
||||
public void setSortOrder(short sortOrder) { this.sortOrder = sortOrder; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.recipeapp.recipe.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "recipe_step")
|
||||
public class RecipeStep {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "recipe_id", nullable = false)
|
||||
private Recipe recipe;
|
||||
|
||||
@Column(name = "step_number", nullable = false)
|
||||
private short stepNumber;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "text")
|
||||
private String instruction;
|
||||
|
||||
protected RecipeStep() {}
|
||||
|
||||
public RecipeStep(Recipe recipe, short stepNumber, String instruction) {
|
||||
this.recipe = recipe;
|
||||
this.stepNumber = stepNumber;
|
||||
this.instruction = instruction;
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Recipe getRecipe() { return recipe; }
|
||||
public short getStepNumber() { return stepNumber; }
|
||||
public void setStepNumber(short stepNumber) { this.stepNumber = stepNumber; }
|
||||
public String getInstruction() { return instruction; }
|
||||
public void setInstruction(String instruction) { this.instruction = instruction; }
|
||||
}
|
||||
Reference in New Issue
Block a user