Remove shopping list draft/publish workflow — lists are always live

Shopping lists no longer go through a draft → published lifecycle.
They are immediately usable upon generation from a week plan.

Removed: status/published_at columns (V021 migration), publish endpoint,
PublishResponse DTO, delete-item guard, and 4 related tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 11:03:54 +02:00
parent 8221a1fd41
commit 03b96e8584
8 changed files with 240 additions and 113 deletions

View File

@@ -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,

View File

@@ -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<String, MergedIngredient> 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<ShoppingListItemResponse> 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<UUID> 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;
}
}
}

View File

@@ -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) {}

View File

@@ -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<ShoppingListItemResponse> items
) {}

View File

@@ -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<ShoppingListItem> 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<ShoppingListItem> getItems() { return items; }
}

View File

@@ -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;

View File

@@ -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(

View File

@@ -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 ──