diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java index 90ebc21..ab33398 100644 --- a/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java +++ b/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java @@ -7,7 +7,9 @@ import com.recipeapp.household.HouseholdRepository; import com.recipeapp.planning.WeekPlanRepository; import com.recipeapp.planning.entity.WeekPlan; import com.recipeapp.recipe.IngredientRepository; +import com.recipeapp.recipe.RecipeRepository; import com.recipeapp.recipe.entity.Ingredient; +import com.recipeapp.recipe.entity.Recipe; import com.recipeapp.recipe.entity.RecipeIngredient; import com.recipeapp.shopping.dto.*; import com.recipeapp.shopping.entity.ShoppingList; @@ -18,6 +20,8 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.util.*; import java.util.stream.Collectors; +import java.util.Set; +import java.util.Map; @Service @Transactional @@ -29,19 +33,22 @@ public class ShoppingService { private final HouseholdRepository householdRepository; private final IngredientRepository ingredientRepository; private final UserAccountRepository userAccountRepository; + private final RecipeRepository recipeRepository; public ShoppingService(ShoppingListRepository shoppingListRepository, ShoppingListItemRepository shoppingListItemRepository, WeekPlanRepository weekPlanRepository, HouseholdRepository householdRepository, IngredientRepository ingredientRepository, - UserAccountRepository userAccountRepository) { + UserAccountRepository userAccountRepository, + RecipeRepository recipeRepository) { this.shoppingListRepository = shoppingListRepository; this.shoppingListItemRepository = shoppingListItemRepository; this.weekPlanRepository = weekPlanRepository; this.householdRepository = householdRepository; this.ingredientRepository = ingredientRepository; this.userAccountRepository = userAccountRepository; + this.recipeRepository = recipeRepository; } @@ -121,7 +128,7 @@ public class ShoppingService { } shoppingListItemRepository.save(item); - return toItemResponse(item); + return toItemResponseWithNames(item); } @@ -146,7 +153,7 @@ public class ShoppingService { item = shoppingListItemRepository.save(item); list.getItems().add(item); - return toItemResponse(item); + return toItemResponseWithNames(item); } @@ -178,18 +185,53 @@ public class ShoppingService { } private ShoppingListResponse toResponse(ShoppingList list) { + // Batch-fetch recipe names for source references + Set allRecipeIds = list.getItems().stream() + .filter(i -> i.getSourceRecipes() != null) + .flatMap(i -> Arrays.stream(i.getSourceRecipes())) + .collect(Collectors.toSet()); + + Map recipeNames = allRecipeIds.isEmpty() + ? Map.of() + : recipeRepository.findAllById(allRecipeIds).stream() + .collect(Collectors.toMap(Recipe::getId, Recipe::getName)); + List items = list.getItems().stream() - .map(this::toItemResponse) + .map(item -> toItemResponse(item, recipeNames)) .toList(); + // Count filtered staples from the week plan + int filteredStaplesCount = countFilteredStaples(list.getWeekPlan()); + return new ShoppingListResponse( list.getId(), list.getWeekPlan().getId(), + list.getGeneratedAt(), + filteredStaplesCount, items ); } - private ShoppingListItemResponse toItemResponse(ShoppingListItem item) { + private int countFilteredStaples(WeekPlan weekPlan) { + return (int) weekPlan.getSlots().stream() + .flatMap(slot -> slot.getRecipe().getIngredients().stream()) + .map(RecipeIngredient::getIngredient) + .filter(Ingredient::isStaple) + .map(Ingredient::getId) + .distinct() + .count(); + } + + private ShoppingListItemResponse toItemResponseWithNames(ShoppingListItem item) { + Map recipeNames = Map.of(); + if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) { + recipeNames = recipeRepository.findAllById(Arrays.asList(item.getSourceRecipes())).stream() + .collect(Collectors.toMap(Recipe::getId, Recipe::getName)); + } + return toItemResponse(item, recipeNames); + } + + private ShoppingListItemResponse toItemResponse(ShoppingListItem item, Map recipeNames) { String name; ShoppingListItemResponse.CategoryRef categoryRef = null; UUID ingredientId = null; @@ -207,6 +249,14 @@ public class ShoppingService { name = item.getCustomName(); } + List sourceRefs = item.getSourceRecipes() != null + ? Arrays.stream(item.getSourceRecipes()) + .distinct() + .filter(recipeNames::containsKey) + .map(id -> new ShoppingListItemResponse.RecipeRef(id, recipeNames.get(id))) + .toList() + : List.of(); + return new ShoppingListItemResponse( item.getId(), ingredientId, @@ -216,7 +266,7 @@ public class ShoppingService { item.getUnit(), item.isChecked(), item.getCheckedBy() != null ? item.getCheckedBy().getId() : null, - item.getSourceRecipes() != null ? Arrays.asList(item.getSourceRecipes()) : List.of() + sourceRefs ); } diff --git a/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListItemResponse.java b/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListItemResponse.java index 22d1bb3..52d0327 100644 --- a/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListItemResponse.java +++ b/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListItemResponse.java @@ -13,7 +13,8 @@ public record ShoppingListItemResponse( String unit, boolean isChecked, UUID checkedBy, - List sourceRecipes + List sourceRecipes ) { public record CategoryRef(UUID id, String name) {} + public record RecipeRef(UUID id, String name) {} } diff --git a/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListResponse.java b/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListResponse.java index 8cf4828..33d4cb9 100644 --- a/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListResponse.java +++ b/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListResponse.java @@ -1,10 +1,13 @@ package com.recipeapp.shopping.dto; +import java.time.Instant; import java.util.List; import java.util.UUID; public record ShoppingListResponse( UUID id, UUID weekPlanId, + Instant generatedAt, + int filteredStaplesCount, List items ) {} diff --git a/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java b/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java index fc7870a..cdfdbe9 100644 --- a/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java +++ b/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java @@ -16,6 +16,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.math.BigDecimal; +import java.time.Instant; import java.util.List; import java.util.UUID; @@ -50,11 +51,13 @@ class ShoppingListControllerTest { @Test void generateFromPlanShouldReturn201() throws Exception { + var recipeId = UUID.randomUUID(); var item = new ShoppingListItemResponse( ITEM_ID, UUID.randomUUID(), "Tomatoes", new ShoppingListItemResponse.CategoryRef(UUID.randomUUID(), "Produce"), - new BigDecimal("4.00"), "pcs", false, null, List.of(UUID.randomUUID())); - var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of(item)); + new BigDecimal("4.00"), "pcs", false, null, + List.of(new ShoppingListItemResponse.RecipeRef(recipeId, "Spaghetti"))); + var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 2, List.of(item)); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response); @@ -68,7 +71,7 @@ class ShoppingListControllerTest { @Test void getShoppingListShouldReturn200() throws Exception { - var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of()); + var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 0, List.of()); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response); @@ -84,7 +87,8 @@ class ShoppingListControllerTest { void checkItemShouldReturn200() throws Exception { var response = new ShoppingListItemResponse( ITEM_ID, UUID.randomUUID(), "Tomatoes", null, - new BigDecimal("4.00"), "pcs", true, USER_ID, List.of()); + new BigDecimal("4.00"), "pcs", true, USER_ID, + List.of()); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolveUserId("sarah@example.com")).thenReturn(USER_ID); @@ -104,7 +108,8 @@ class ShoppingListControllerTest { void addItemShouldReturn201() throws Exception { var response = new ShoppingListItemResponse( ITEM_ID, null, "Paper towels", null, - new BigDecimal("1"), "", false, null, List.of()); + new BigDecimal("1"), "", false, null, + List.of()); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(shoppingService.addItem(eq(HOUSEHOLD_ID), eq(LIST_ID), any(AddItemRequest.class))) diff --git a/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java index a106398..e374c07 100644 --- a/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java +++ b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java @@ -9,6 +9,7 @@ import com.recipeapp.planning.WeekPlanRepository; import com.recipeapp.planning.entity.WeekPlan; import com.recipeapp.planning.entity.WeekPlanSlot; import com.recipeapp.recipe.IngredientRepository; +import com.recipeapp.recipe.RecipeRepository; import com.recipeapp.recipe.entity.Ingredient; import com.recipeapp.recipe.entity.IngredientCategory; import com.recipeapp.recipe.entity.Recipe; @@ -39,6 +40,7 @@ class ShoppingServiceTest { @Mock private HouseholdRepository householdRepository; @Mock private IngredientRepository ingredientRepository; @Mock private UserAccountRepository userAccountRepository; + @Mock private RecipeRepository recipeRepository; @InjectMocks private ShoppingService shoppingService; @@ -124,15 +126,18 @@ class ShoppingServiceTest { setId(sl, ShoppingList.class, UUID.randomUUID()); return sl; }); + when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe1, recipe2)); ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId()); assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered) + assertThat(result.filteredStaplesCount()).isEqualTo(1); // salt var tomatoItem = result.items().stream() .filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow(); assertThat(tomatoItem.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); // 2 + 3 assertThat(tomatoItem.sourceRecipes()).hasSize(2); + assertThat(tomatoItem.sourceRecipes().get(0).name()).isNotNull(); var cheeseItem = result.items().stream() .filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow(); @@ -164,6 +169,7 @@ class ShoppingServiceTest { ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId()); assertThat(result.id()).isEqualTo(list.getId()); + assertThat(result.generatedAt()).isNotNull(); assertThat(result.items()).hasSize(1); assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes"); }