feat(shopping): extend response with generatedAt, filteredStaplesCount, RecipeRef

Shopping list response now includes generatedAt timestamp, count of
filtered staples, and recipe names (not just UUIDs) in source references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 18:29:07 +02:00
parent 7e254fc280
commit 93e8bf9e41
5 changed files with 77 additions and 12 deletions

View File

@@ -7,7 +7,9 @@ import com.recipeapp.household.HouseholdRepository;
import com.recipeapp.planning.WeekPlanRepository; import com.recipeapp.planning.WeekPlanRepository;
import com.recipeapp.planning.entity.WeekPlan; import com.recipeapp.planning.entity.WeekPlan;
import com.recipeapp.recipe.IngredientRepository; import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.RecipeRepository;
import com.recipeapp.recipe.entity.Ingredient; import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.Recipe;
import com.recipeapp.recipe.entity.RecipeIngredient; import com.recipeapp.recipe.entity.RecipeIngredient;
import com.recipeapp.shopping.dto.*; import com.recipeapp.shopping.dto.*;
import com.recipeapp.shopping.entity.ShoppingList; import com.recipeapp.shopping.entity.ShoppingList;
@@ -18,6 +20,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.Set;
import java.util.Map;
@Service @Service
@Transactional @Transactional
@@ -29,19 +33,22 @@ public class ShoppingService {
private final HouseholdRepository householdRepository; private final HouseholdRepository householdRepository;
private final IngredientRepository ingredientRepository; private final IngredientRepository ingredientRepository;
private final UserAccountRepository userAccountRepository; private final UserAccountRepository userAccountRepository;
private final RecipeRepository recipeRepository;
public ShoppingService(ShoppingListRepository shoppingListRepository, public ShoppingService(ShoppingListRepository shoppingListRepository,
ShoppingListItemRepository shoppingListItemRepository, ShoppingListItemRepository shoppingListItemRepository,
WeekPlanRepository weekPlanRepository, WeekPlanRepository weekPlanRepository,
HouseholdRepository householdRepository, HouseholdRepository householdRepository,
IngredientRepository ingredientRepository, IngredientRepository ingredientRepository,
UserAccountRepository userAccountRepository) { UserAccountRepository userAccountRepository,
RecipeRepository recipeRepository) {
this.shoppingListRepository = shoppingListRepository; this.shoppingListRepository = shoppingListRepository;
this.shoppingListItemRepository = shoppingListItemRepository; this.shoppingListItemRepository = shoppingListItemRepository;
this.weekPlanRepository = weekPlanRepository; this.weekPlanRepository = weekPlanRepository;
this.householdRepository = householdRepository; this.householdRepository = householdRepository;
this.ingredientRepository = ingredientRepository; this.ingredientRepository = ingredientRepository;
this.userAccountRepository = userAccountRepository; this.userAccountRepository = userAccountRepository;
this.recipeRepository = recipeRepository;
} }
@@ -121,7 +128,7 @@ public class ShoppingService {
} }
shoppingListItemRepository.save(item); shoppingListItemRepository.save(item);
return toItemResponse(item); return toItemResponseWithNames(item);
} }
@@ -146,7 +153,7 @@ public class ShoppingService {
item = shoppingListItemRepository.save(item); item = shoppingListItemRepository.save(item);
list.getItems().add(item); list.getItems().add(item);
return toItemResponse(item); return toItemResponseWithNames(item);
} }
@@ -178,18 +185,53 @@ public class ShoppingService {
} }
private ShoppingListResponse toResponse(ShoppingList list) { private ShoppingListResponse toResponse(ShoppingList list) {
// Batch-fetch recipe names for source references
Set<UUID> allRecipeIds = list.getItems().stream()
.filter(i -> i.getSourceRecipes() != null)
.flatMap(i -> Arrays.stream(i.getSourceRecipes()))
.collect(Collectors.toSet());
Map<UUID, String> recipeNames = allRecipeIds.isEmpty()
? Map.of()
: recipeRepository.findAllById(allRecipeIds).stream()
.collect(Collectors.toMap(Recipe::getId, Recipe::getName));
List<ShoppingListItemResponse> items = list.getItems().stream() List<ShoppingListItemResponse> items = list.getItems().stream()
.map(this::toItemResponse) .map(item -> toItemResponse(item, recipeNames))
.toList(); .toList();
// Count filtered staples from the week plan
int filteredStaplesCount = countFilteredStaples(list.getWeekPlan());
return new ShoppingListResponse( return new ShoppingListResponse(
list.getId(), list.getId(),
list.getWeekPlan().getId(), list.getWeekPlan().getId(),
list.getGeneratedAt(),
filteredStaplesCount,
items 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<UUID, String> 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<UUID, String> recipeNames) {
String name; String name;
ShoppingListItemResponse.CategoryRef categoryRef = null; ShoppingListItemResponse.CategoryRef categoryRef = null;
UUID ingredientId = null; UUID ingredientId = null;
@@ -207,6 +249,14 @@ public class ShoppingService {
name = item.getCustomName(); name = item.getCustomName();
} }
List<ShoppingListItemResponse.RecipeRef> 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( return new ShoppingListItemResponse(
item.getId(), item.getId(),
ingredientId, ingredientId,
@@ -216,7 +266,7 @@ public class ShoppingService {
item.getUnit(), item.getUnit(),
item.isChecked(), item.isChecked(),
item.getCheckedBy() != null ? item.getCheckedBy().getId() : null, item.getCheckedBy() != null ? item.getCheckedBy().getId() : null,
item.getSourceRecipes() != null ? Arrays.asList(item.getSourceRecipes()) : List.of() sourceRefs
); );
} }

View File

@@ -13,7 +13,8 @@ public record ShoppingListItemResponse(
String unit, String unit,
boolean isChecked, boolean isChecked,
UUID checkedBy, UUID checkedBy,
List<UUID> sourceRecipes List<RecipeRef> sourceRecipes
) { ) {
public record CategoryRef(UUID id, String name) {} public record CategoryRef(UUID id, String name) {}
public record RecipeRef(UUID id, String name) {}
} }

View File

@@ -1,10 +1,13 @@
package com.recipeapp.shopping.dto; package com.recipeapp.shopping.dto;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
public record ShoppingListResponse( public record ShoppingListResponse(
UUID id, UUID id,
UUID weekPlanId, UUID weekPlanId,
Instant generatedAt,
int filteredStaplesCount,
List<ShoppingListItemResponse> items List<ShoppingListItemResponse> items
) {} ) {}

View File

@@ -16,6 +16,7 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -50,11 +51,13 @@ class ShoppingListControllerTest {
@Test @Test
void generateFromPlanShouldReturn201() throws Exception { void generateFromPlanShouldReturn201() throws Exception {
var recipeId = UUID.randomUUID();
var item = new ShoppingListItemResponse( var item = new ShoppingListItemResponse(
ITEM_ID, UUID.randomUUID(), "Tomatoes", ITEM_ID, UUID.randomUUID(), "Tomatoes",
new ShoppingListItemResponse.CategoryRef(UUID.randomUUID(), "Produce"), new ShoppingListItemResponse.CategoryRef(UUID.randomUUID(), "Produce"),
new BigDecimal("4.00"), "pcs", false, null, List.of(UUID.randomUUID())); new BigDecimal("4.00"), "pcs", false, null,
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of(item)); 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(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response); when(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response);
@@ -68,7 +71,7 @@ class ShoppingListControllerTest {
@Test @Test
void getShoppingListShouldReturn200() throws Exception { 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(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response); when(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response);
@@ -84,7 +87,8 @@ class ShoppingListControllerTest {
void checkItemShouldReturn200() throws Exception { void checkItemShouldReturn200() throws Exception {
var response = new ShoppingListItemResponse( var response = new ShoppingListItemResponse(
ITEM_ID, UUID.randomUUID(), "Tomatoes", null, 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.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(householdResolver.resolveUserId("sarah@example.com")).thenReturn(USER_ID); when(householdResolver.resolveUserId("sarah@example.com")).thenReturn(USER_ID);
@@ -104,7 +108,8 @@ class ShoppingListControllerTest {
void addItemShouldReturn201() throws Exception { void addItemShouldReturn201() throws Exception {
var response = new ShoppingListItemResponse( var response = new ShoppingListItemResponse(
ITEM_ID, null, "Paper towels", null, 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(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.addItem(eq(HOUSEHOLD_ID), eq(LIST_ID), any(AddItemRequest.class))) when(shoppingService.addItem(eq(HOUSEHOLD_ID), eq(LIST_ID), any(AddItemRequest.class)))

View File

@@ -9,6 +9,7 @@ import com.recipeapp.planning.WeekPlanRepository;
import com.recipeapp.planning.entity.WeekPlan; import com.recipeapp.planning.entity.WeekPlan;
import com.recipeapp.planning.entity.WeekPlanSlot; import com.recipeapp.planning.entity.WeekPlanSlot;
import com.recipeapp.recipe.IngredientRepository; import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.RecipeRepository;
import com.recipeapp.recipe.entity.Ingredient; import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.IngredientCategory; import com.recipeapp.recipe.entity.IngredientCategory;
import com.recipeapp.recipe.entity.Recipe; import com.recipeapp.recipe.entity.Recipe;
@@ -39,6 +40,7 @@ class ShoppingServiceTest {
@Mock private HouseholdRepository householdRepository; @Mock private HouseholdRepository householdRepository;
@Mock private IngredientRepository ingredientRepository; @Mock private IngredientRepository ingredientRepository;
@Mock private UserAccountRepository userAccountRepository; @Mock private UserAccountRepository userAccountRepository;
@Mock private RecipeRepository recipeRepository;
@InjectMocks private ShoppingService shoppingService; @InjectMocks private ShoppingService shoppingService;
@@ -124,15 +126,18 @@ class ShoppingServiceTest {
setId(sl, ShoppingList.class, UUID.randomUUID()); setId(sl, ShoppingList.class, UUID.randomUUID());
return sl; return sl;
}); });
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe1, recipe2));
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId()); ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered) assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered)
assertThat(result.filteredStaplesCount()).isEqualTo(1); // salt
var tomatoItem = result.items().stream() var tomatoItem = result.items().stream()
.filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow(); .filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow();
assertThat(tomatoItem.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); // 2 + 3 assertThat(tomatoItem.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); // 2 + 3
assertThat(tomatoItem.sourceRecipes()).hasSize(2); assertThat(tomatoItem.sourceRecipes()).hasSize(2);
assertThat(tomatoItem.sourceRecipes().get(0).name()).isNotNull();
var cheeseItem = result.items().stream() var cheeseItem = result.items().stream()
.filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow(); .filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow();
@@ -164,6 +169,7 @@ class ShoppingServiceTest {
ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId()); ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId());
assertThat(result.id()).isEqualTo(list.getId()); assertThat(result.id()).isEqualTo(list.getId());
assertThat(result.generatedAt()).isNotNull();
assertThat(result.items()).hasSize(1); assertThat(result.items()).hasSize(1);
assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes"); assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes");
} }