diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingListController.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingListController.java index ca25fa4..b5fd800 100644 --- a/backend/src/main/java/com/recipeapp/shopping/ShoppingListController.java +++ b/backend/src/main/java/com/recipeapp/shopping/ShoppingListController.java @@ -32,12 +32,6 @@ public class ShoppingListController { return shoppingService.getShoppingList(householdId, id); } - @PostMapping("/v1/shopping-lists/{id}/publish") - public PublishResponse publish(@PathVariable UUID id, Principal principal) { - UUID householdId = householdResolver.resolve(principal.getName()); - return shoppingService.publish(householdId, id); - } - @PatchMapping("/v1/shopping-lists/{listId}/items/{itemId}") public ShoppingListItemResponse checkItem(@PathVariable UUID listId, @PathVariable UUID itemId, diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java index da6aa58..90ebc21 100644 --- a/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java +++ b/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java @@ -1,21 +1,246 @@ package com.recipeapp.shopping; +import com.recipeapp.auth.UserAccountRepository; +import com.recipeapp.auth.entity.UserAccount; +import com.recipeapp.common.ResourceNotFoundException; +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.entity.Ingredient; +import com.recipeapp.recipe.entity.RecipeIngredient; import com.recipeapp.shopping.dto.*; +import com.recipeapp.shopping.entity.ShoppingList; +import com.recipeapp.shopping.entity.ShoppingListItem; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import java.util.UUID; +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; -public interface ShoppingService { +@Service +@Transactional +public class ShoppingService { - ShoppingListResponse generateFromPlan(UUID householdId, UUID weekPlanId); + private final ShoppingListRepository shoppingListRepository; + private final ShoppingListItemRepository shoppingListItemRepository; + private final WeekPlanRepository weekPlanRepository; + private final HouseholdRepository householdRepository; + private final IngredientRepository ingredientRepository; + private final UserAccountRepository userAccountRepository; - ShoppingListResponse getShoppingList(UUID householdId, UUID shoppingListId); + public ShoppingService(ShoppingListRepository shoppingListRepository, + ShoppingListItemRepository shoppingListItemRepository, + WeekPlanRepository weekPlanRepository, + HouseholdRepository householdRepository, + IngredientRepository ingredientRepository, + UserAccountRepository userAccountRepository) { + this.shoppingListRepository = shoppingListRepository; + this.shoppingListItemRepository = shoppingListItemRepository; + this.weekPlanRepository = weekPlanRepository; + this.householdRepository = householdRepository; + this.ingredientRepository = ingredientRepository; + this.userAccountRepository = userAccountRepository; + } - PublishResponse publish(UUID householdId, UUID shoppingListId); - ShoppingListItemResponse checkItem(UUID householdId, UUID listId, UUID itemId, - CheckItemRequest request, UUID userId); + public ShoppingListResponse generateFromPlan(UUID householdId, UUID weekPlanId) { + WeekPlan weekPlan = weekPlanRepository.findById(weekPlanId) + .orElseThrow(() -> new ResourceNotFoundException("Week plan not found")); - ShoppingListItemResponse addItem(UUID householdId, UUID shoppingListId, AddItemRequest request); + if (!weekPlan.getHousehold().getId().equals(householdId)) { + throw new ResourceNotFoundException("Week plan not found"); + } - void deleteItem(UUID householdId, UUID listId, UUID itemId); + var household = weekPlan.getHousehold(); + + ShoppingList shoppingList = new ShoppingList(household, weekPlan); + shoppingList = shoppingListRepository.save(shoppingList); + + // Aggregate ingredients across all slots/recipes + // Key: ingredientId + unit -> merged data + Map merged = new LinkedHashMap<>(); + + for (var slot : weekPlan.getSlots()) { + var recipe = slot.getRecipe(); + for (RecipeIngredient ri : recipe.getIngredients()) { + Ingredient ingredient = ri.getIngredient(); + + // Filter out staples + if (ingredient.isStaple()) { + continue; + } + + String key = ingredient.getId().toString() + "|" + ri.getUnit(); + merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit())) + .addQuantity(ri.getQuantity()) + .addRecipeId(recipe.getId()); + } + } + + // Create shopping list items + for (MergedIngredient mi : merged.values()) { + ShoppingListItem item = new ShoppingListItem( + shoppingList, + mi.ingredient, + null, + mi.totalQuantity, + mi.unit, + mi.recipeIds.stream().distinct().toArray(UUID[]::new) + ); + shoppingList.getItems().add(item); + } + + shoppingListRepository.save(shoppingList); + + return toResponse(shoppingList); + } + + + @Transactional(readOnly = true) + public ShoppingListResponse getShoppingList(UUID householdId, UUID shoppingListId) { + ShoppingList list = findList(householdId, shoppingListId); + return toResponse(list); + } + + + public ShoppingListItemResponse checkItem(UUID householdId, UUID listId, UUID itemId, + CheckItemRequest request, UUID userId) { + ShoppingList list = findList(householdId, listId); + ShoppingListItem item = findItem(list, itemId); + + item.setChecked(request.isChecked()); + + if (request.isChecked()) { + UserAccount user = userAccountRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + item.setCheckedBy(user); + } else { + item.setCheckedBy(null); + } + + shoppingListItemRepository.save(item); + return toItemResponse(item); + } + + + public ShoppingListItemResponse addItem(UUID householdId, UUID shoppingListId, AddItemRequest request) { + ShoppingList list = findList(householdId, shoppingListId); + + Ingredient ingredient = null; + if (request.ingredientId() != null) { + ingredient = ingredientRepository.findById(request.ingredientId()) + .orElseThrow(() -> new ResourceNotFoundException("Ingredient not found")); + } + + ShoppingListItem item = new ShoppingListItem( + list, + ingredient, + request.customName(), + request.quantity(), + request.unit(), + new UUID[0] + ); + + item = shoppingListItemRepository.save(item); + list.getItems().add(item); + + return toItemResponse(item); + } + + + public void deleteItem(UUID householdId, UUID listId, UUID itemId) { + ShoppingList list = findList(householdId, listId); + ShoppingListItem item = findItem(list, itemId); + list.getItems().remove(item); + shoppingListItemRepository.delete(item); + } + + // ── Helpers ── + + private ShoppingList findList(UUID householdId, UUID shoppingListId) { + ShoppingList list = shoppingListRepository.findById(shoppingListId) + .orElseThrow(() -> new ResourceNotFoundException("Shopping list not found")); + + if (!list.getHousehold().getId().equals(householdId)) { + throw new ResourceNotFoundException("Shopping list not found"); + } + + return list; + } + + private ShoppingListItem findItem(ShoppingList list, UUID itemId) { + return list.getItems().stream() + .filter(i -> i.getId().equals(itemId)) + .findFirst() + .orElseThrow(() -> new ResourceNotFoundException("Shopping list item not found")); + } + + private ShoppingListResponse toResponse(ShoppingList list) { + List items = list.getItems().stream() + .map(this::toItemResponse) + .toList(); + + return new ShoppingListResponse( + list.getId(), + list.getWeekPlan().getId(), + items + ); + } + + private ShoppingListItemResponse toItemResponse(ShoppingListItem item) { + String name; + ShoppingListItemResponse.CategoryRef categoryRef = null; + UUID ingredientId = null; + + if (item.getIngredient() != null) { + ingredientId = item.getIngredient().getId(); + name = item.getIngredient().getName(); + if (item.getIngredient().getCategory() != null) { + categoryRef = new ShoppingListItemResponse.CategoryRef( + item.getIngredient().getCategory().getId(), + item.getIngredient().getCategory().getName() + ); + } + } else { + name = item.getCustomName(); + } + + return new ShoppingListItemResponse( + item.getId(), + ingredientId, + name, + categoryRef, + item.getQuantity(), + item.getUnit(), + item.isChecked(), + item.getCheckedBy() != null ? item.getCheckedBy().getId() : null, + item.getSourceRecipes() != null ? Arrays.asList(item.getSourceRecipes()) : List.of() + ); + } + + private static class MergedIngredient { + final Ingredient ingredient; + final String unit; + BigDecimal totalQuantity = BigDecimal.ZERO; + final List recipeIds = new ArrayList<>(); + + MergedIngredient(Ingredient ingredient, String unit) { + this.ingredient = ingredient; + this.unit = unit; + } + + MergedIngredient addQuantity(BigDecimal qty) { + if (qty != null) { + this.totalQuantity = this.totalQuantity.add(qty); + } + return this; + } + + MergedIngredient addRecipeId(UUID recipeId) { + this.recipeIds.add(recipeId); + return this; + } + } } diff --git a/backend/src/main/java/com/recipeapp/shopping/dto/PublishResponse.java b/backend/src/main/java/com/recipeapp/shopping/dto/PublishResponse.java deleted file mode 100644 index ab895f9..0000000 --- a/backend/src/main/java/com/recipeapp/shopping/dto/PublishResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.recipeapp.shopping.dto; - -import java.time.Instant; -import java.util.UUID; - -public record PublishResponse(UUID id, String status, Instant publishedAt) {} 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 f87de5a..8cf4828 100644 --- a/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListResponse.java +++ b/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListResponse.java @@ -1,13 +1,10 @@ package com.recipeapp.shopping.dto; -import java.time.Instant; import java.util.List; import java.util.UUID; public record ShoppingListResponse( UUID id, UUID weekPlanId, - String status, - Instant publishedAt, List items ) {} diff --git a/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingList.java b/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingList.java index 41b491e..5c0679c 100644 --- a/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingList.java +++ b/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingList.java @@ -3,7 +3,6 @@ package com.recipeapp.shopping.entity; import com.recipeapp.household.entity.Household; import com.recipeapp.planning.entity.WeekPlan; import jakarta.persistence.*; -import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -24,12 +23,6 @@ public class ShoppingList { @JoinColumn(name = "week_plan_id", nullable = false) private WeekPlan weekPlan; - @Column(nullable = false, length = 10) - private String status = "draft"; - - @Column(name = "published_at") - private Instant publishedAt; - @OneToMany(mappedBy = "shoppingList", cascade = CascadeType.ALL, orphanRemoval = true) private List items = new ArrayList<>(); @@ -43,9 +36,5 @@ public class ShoppingList { public UUID getId() { return id; } public Household getHousehold() { return household; } public WeekPlan getWeekPlan() { return weekPlan; } - public String getStatus() { return status; } - public void setStatus(String status) { this.status = status; } - public Instant getPublishedAt() { return publishedAt; } - public void setPublishedAt(Instant publishedAt) { this.publishedAt = publishedAt; } public List getItems() { return items; } } diff --git a/backend/src/main/resources/db/migration/V021__drop_shopping_list_status.sql b/backend/src/main/resources/db/migration/V021__drop_shopping_list_status.sql new file mode 100644 index 0000000..ff9e093 --- /dev/null +++ b/backend/src/main/resources/db/migration/V021__drop_shopping_list_status.sql @@ -0,0 +1,3 @@ +-- Shopping lists are now always live — no draft/publish workflow. +ALTER TABLE shopping_list DROP COLUMN status; +ALTER TABLE shopping_list DROP COLUMN published_at; diff --git a/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java b/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java index 6f6d181..fc7870a 100644 --- a/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java +++ b/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java @@ -3,7 +3,6 @@ package com.recipeapp.shopping; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.recipeapp.common.GlobalExceptionHandler; -import com.recipeapp.common.ValidationException; import com.recipeapp.recipe.HouseholdResolver; import com.recipeapp.shopping.dto.*; import org.junit.jupiter.api.BeforeEach; @@ -17,7 +16,6 @@ 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; @@ -56,7 +54,7 @@ class ShoppingListControllerTest { 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, "draft", null, List.of(item)); + var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of(item)); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response); @@ -65,13 +63,12 @@ class ShoppingListControllerTest { .principal(() -> "sarah@example.com")) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value(LIST_ID.toString())) - .andExpect(jsonPath("$.status").value("draft")) .andExpect(jsonPath("$.items[0].name").value("Tomatoes")); } @Test void getShoppingListShouldReturn200() throws Exception { - var response = new ShoppingListResponse(LIST_ID, PLAN_ID, "draft", null, List.of()); + var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of()); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response); @@ -83,20 +80,6 @@ class ShoppingListControllerTest { .andExpect(jsonPath("$.weekPlanId").value(PLAN_ID.toString())); } - @Test - void publishShouldReturn200() throws Exception { - var now = Instant.now(); - var response = new PublishResponse(LIST_ID, "published", now); - - when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); - when(shoppingService.publish(HOUSEHOLD_ID, LIST_ID)).thenReturn(response); - - mockMvc.perform(post("/v1/shopping-lists/{id}/publish", LIST_ID) - .principal(() -> "sarah@example.com")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("published")); - } - @Test void checkItemShouldReturn200() throws Exception { var response = new ShoppingListItemResponse( diff --git a/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java index 9fb6cce..a106398 100644 --- a/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java +++ b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java @@ -3,7 +3,6 @@ package com.recipeapp.shopping; import com.recipeapp.auth.UserAccountRepository; import com.recipeapp.auth.entity.UserAccount; import com.recipeapp.common.ResourceNotFoundException; -import com.recipeapp.common.ValidationException; import com.recipeapp.household.HouseholdRepository; import com.recipeapp.household.entity.Household; import com.recipeapp.planning.WeekPlanRepository; @@ -41,7 +40,7 @@ class ShoppingServiceTest { @Mock private IngredientRepository ingredientRepository; @Mock private UserAccountRepository userAccountRepository; - @InjectMocks private ShoppingServiceImpl shoppingService; + @InjectMocks private ShoppingService shoppingService; private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); private static final LocalDate WEEK_START = LocalDate.of(2026, 4, 6); @@ -128,7 +127,6 @@ class ShoppingServiceTest { ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId()); - assertThat(result.status()).isEqualTo("draft"); assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered) var tomatoItem = result.items().stream() @@ -170,36 +168,6 @@ class ShoppingServiceTest { assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes"); } - // ── Publish ── - - @Test - void publishShouldSetStatusAndTimestamp() { - var household = testHousehold(); - var plan = testWeekPlan(household); - var list = testShoppingList(household, plan); - - when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); - when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0)); - - PublishResponse result = shoppingService.publish(HOUSEHOLD_ID, list.getId()); - - assertThat(result.status()).isEqualTo("published"); - assertThat(result.publishedAt()).isNotNull(); - } - - @Test - void publishShouldThrowWhenAlreadyPublished() { - var household = testHousehold(); - var plan = testWeekPlan(household); - var list = testShoppingList(household, plan); - list.setStatus("published"); - - when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); - - assertThatThrownBy(() -> shoppingService.publish(HOUSEHOLD_ID, list.getId())) - .isInstanceOf(ValidationException.class); - } - // ── Check Item ── @Test @@ -267,22 +235,6 @@ class ShoppingServiceTest { assertThat(list.getItems()).isEmpty(); } - @Test - void deleteItemShouldThrowWhenListIsPublished() { - var household = testHousehold(); - var plan = testWeekPlan(household); - var list = testShoppingList(household, plan); - list.setStatus("published"); - var ingredient = testIngredient(household, "Tomatoes", false); - var item = testItem(list, ingredient, new BigDecimal("5.00"), "pcs"); - list.getItems().add(item); - - when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); - - assertThatThrownBy(() -> shoppingService.deleteItem(HOUSEHOLD_ID, list.getId(), item.getId())) - .isInstanceOf(ValidationException.class); - } - // ── Household mismatch ── @Test @@ -415,15 +367,6 @@ class ShoppingServiceTest { .isInstanceOf(ResourceNotFoundException.class); } - @Test - void publishShouldThrowWhenListNotFound() { - var listId = UUID.randomUUID(); - when(shoppingListRepository.findById(listId)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> shoppingService.publish(HOUSEHOLD_ID, listId)) - .isInstanceOf(ResourceNotFoundException.class); - } - // ── Generate from plan with empty slots ── @Test @@ -442,7 +385,6 @@ class ShoppingServiceTest { ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId()); assertThat(result.items()).isEmpty(); - assertThat(result.status()).isEqualTo("draft"); } // ── Item with category ──