diff --git a/backend/src/main/java/com/recipeapp/common/ForbiddenException.java b/backend/src/main/java/com/recipeapp/common/ForbiddenException.java new file mode 100644 index 0000000..fcce6a6 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/ForbiddenException.java @@ -0,0 +1,7 @@ +package com.recipeapp.common; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/recipeapp/common/GlobalExceptionHandler.java b/backend/src/main/java/com/recipeapp/common/GlobalExceptionHandler.java index a196419..56c1e02 100644 --- a/backend/src/main/java/com/recipeapp/common/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/recipeapp/common/GlobalExceptionHandler.java @@ -32,6 +32,12 @@ public class GlobalExceptionHandler { .body(ApiError.of("CONFLICT", ex.getMessage())); } + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handleForbidden(ForbiddenException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiError.of("FORBIDDEN", ex.getMessage())); + } + @ExceptionHandler(ValidationException.class) public ResponseEntity handleBusinessValidation(ValidationException ex) { return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) diff --git a/backend/src/main/java/com/recipeapp/common/HouseholdRoleInterceptor.java b/backend/src/main/java/com/recipeapp/common/HouseholdRoleInterceptor.java new file mode 100644 index 0000000..5adea8a --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/HouseholdRoleInterceptor.java @@ -0,0 +1,43 @@ +package com.recipeapp.common; + +import com.recipeapp.recipe.HouseholdResolver; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class HouseholdRoleInterceptor implements HandlerInterceptor { + + private final HouseholdResolver householdResolver; + + public HouseholdRoleInterceptor(HouseholdResolver householdResolver) { + this.householdResolver = householdResolver; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + + RequiresHouseholdRole annotation = handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class); + if (annotation == null) { + return true; + } + + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) { + throw new ForbiddenException("Not authenticated"); + } + + String actualRole = householdResolver.resolveRole(auth.getName()); + if (!annotation.value().equals(actualRole)) { + throw new ForbiddenException("Insufficient permissions"); + } + + return true; + } +} diff --git a/backend/src/main/java/com/recipeapp/common/RequiresHouseholdRole.java b/backend/src/main/java/com/recipeapp/common/RequiresHouseholdRole.java new file mode 100644 index 0000000..bae7b0d --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/RequiresHouseholdRole.java @@ -0,0 +1,12 @@ +package com.recipeapp.common; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequiresHouseholdRole { + String value(); +} diff --git a/backend/src/main/java/com/recipeapp/common/WebMvcConfig.java b/backend/src/main/java/com/recipeapp/common/WebMvcConfig.java new file mode 100644 index 0000000..f3da006 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.recipeapp.common; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final HouseholdRoleInterceptor householdRoleInterceptor; + + public WebMvcConfig(HouseholdRoleInterceptor householdRoleInterceptor) { + this.householdRoleInterceptor = householdRoleInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(householdRoleInterceptor); + } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/HouseholdResolver.java b/backend/src/main/java/com/recipeapp/recipe/HouseholdResolver.java index 54d0249..a6164bf 100644 --- a/backend/src/main/java/com/recipeapp/recipe/HouseholdResolver.java +++ b/backend/src/main/java/com/recipeapp/recipe/HouseholdResolver.java @@ -24,6 +24,10 @@ public class HouseholdResolver { return findMembership(userEmail).getUser().getId(); } + public String resolveRole(String userEmail) { + return findMembership(userEmail).getRole(); + } + private HouseholdMember findMembership(String userEmail) { return householdMemberRepository.findByUserEmailIgnoreCase(userEmail) .orElseThrow(() -> new ResourceNotFoundException("User is not in a household")); diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingListController.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingListController.java index b5fd800..9e9539c 100644 --- a/backend/src/main/java/com/recipeapp/shopping/ShoppingListController.java +++ b/backend/src/main/java/com/recipeapp/shopping/ShoppingListController.java @@ -1,11 +1,15 @@ package com.recipeapp.shopping; +import com.recipeapp.common.RequiresHouseholdRole; +import com.recipeapp.common.ResourceNotFoundException; import com.recipeapp.recipe.HouseholdResolver; import com.recipeapp.shopping.dto.*; +import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import java.security.Principal; +import java.time.LocalDate; import java.util.UUID; @RestController @@ -19,8 +23,21 @@ public class ShoppingListController { this.householdResolver = householdResolver; } + @GetMapping("/v1/shopping-list") + public ShoppingListResponse getByWeekStart( + @RequestParam(required = false) LocalDate weekStart, + Principal principal) { + UUID householdId = householdResolver.resolve(principal.getName()); + ShoppingListResponse response = shoppingService.getByWeekStart(householdId, weekStart); + if (response == null) { + throw new ResourceNotFoundException("No shopping list for this week"); + } + return response; + } + @PostMapping("/v1/week-plans/{id}/shopping-list") @ResponseStatus(HttpStatus.CREATED) + @RequiresHouseholdRole("planner") public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) { UUID householdId = householdResolver.resolve(principal.getName()); return shoppingService.generateFromPlan(householdId, id); @@ -45,7 +62,7 @@ public class ShoppingListController { @PostMapping("/v1/shopping-lists/{id}/items") @ResponseStatus(HttpStatus.CREATED) public ShoppingListItemResponse addItem(@PathVariable UUID id, - @RequestBody AddItemRequest request, + @Valid @RequestBody AddItemRequest request, Principal principal) { UUID householdId = householdResolver.resolve(principal.getName()); return shoppingService.addItem(householdId, id, request); diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingListRepository.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingListRepository.java index bf2382c..3df4bb2 100644 --- a/backend/src/main/java/com/recipeapp/shopping/ShoppingListRepository.java +++ b/backend/src/main/java/com/recipeapp/shopping/ShoppingListRepository.java @@ -3,7 +3,10 @@ package com.recipeapp.shopping; import com.recipeapp.shopping.entity.ShoppingList; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDate; +import java.util.Optional; import java.util.UUID; public interface ShoppingListRepository extends JpaRepository { + Optional findByHouseholdIdAndWeekPlanWeekStart(UUID householdId, LocalDate weekStart); } diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java index 90ebc21..4acdd3a 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; @@ -16,6 +18,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; import java.util.*; import java.util.stream.Collectors; @@ -29,19 +34,34 @@ 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; + } + + + @Transactional(readOnly = true) + public ShoppingListResponse getByWeekStart(UUID householdId, LocalDate weekStart) { + if (weekStart == null) { + weekStart = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + } + + return shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(householdId, weekStart) + .map(this::toResponse) + .orElse(null); } @@ -53,45 +73,71 @@ public class ShoppingService { throw new ResourceNotFoundException("Week plan not found"); } - var household = weekPlan.getHousehold(); - - ShoppingList shoppingList = new ShoppingList(household, weekPlan); - shoppingList = shoppingListRepository.save(shoppingList); + // Find or create the shopping list + ShoppingList shoppingList = shoppingListRepository + .findByHouseholdIdAndWeekPlanWeekStart(householdId, weekPlan.getWeekStart()) + .orElseGet(() -> { + var newList = new ShoppingList(weekPlan.getHousehold(), weekPlan); + return shoppingListRepository.save(newList); + }); // 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(); + String key = mergeKey(ingredient.getId(), 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); + // Build index of existing generated items by merge key + Map existingByKey = new HashMap<>(); + List customItems = new ArrayList<>(); + for (ShoppingListItem item : shoppingList.getItems()) { + if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) { + // Generated item + String key = mergeKey(item.getIngredient() != null ? item.getIngredient().getId() : null, item.getUnit()); + existingByKey.put(key, item); + } else { + customItems.add(item); + } } + // Merge: update existing, add new, collect keys to keep + Set mergedKeys = new HashSet<>(); + for (MergedIngredient mi : merged.values()) { + String key = mergeKey(mi.ingredient.getId(), mi.unit); + mergedKeys.add(key); + + ShoppingListItem existing = existingByKey.get(key); + if (existing != null) { + // Update quantity and sources, preserve check state + existing.setQuantity(mi.totalQuantity); + existing.setSourceRecipes(mi.recipeIds.stream().distinct().toArray(UUID[]::new)); + } else { + // New item + ShoppingListItem item = new ShoppingListItem( + shoppingList, mi.ingredient, null, mi.totalQuantity, mi.unit, + mi.recipeIds.stream().distinct().toArray(UUID[]::new)); + shoppingList.getItems().add(item); + } + } + + // Remove generated items no longer in the plan + shoppingList.getItems().removeIf(item -> + item.getSourceRecipes() != null && item.getSourceRecipes().length > 0 + && !mergedKeys.contains(mergeKey( + item.getIngredient() != null ? item.getIngredient().getId() : null, + item.getUnit()))); + + shoppingList.setGeneratedAt(java.time.Instant.now()); shoppingListRepository.save(shoppingList); return toResponse(shoppingList); @@ -121,7 +167,7 @@ public class ShoppingService { } shoppingListItemRepository.save(item); - return toItemResponse(item); + return toItemResponseWithNames(item); } @@ -146,7 +192,7 @@ public class ShoppingService { item = shoppingListItemRepository.save(item); list.getItems().add(item); - return toItemResponse(item); + return toItemResponseWithNames(item); } @@ -178,18 +224,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 +288,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,10 +305,14 @@ public class ShoppingService { item.getUnit(), item.isChecked(), item.getCheckedBy() != null ? item.getCheckedBy().getId() : null, - item.getSourceRecipes() != null ? Arrays.asList(item.getSourceRecipes()) : List.of() + sourceRefs ); } + private static String mergeKey(UUID ingredientId, String unit) { + return (ingredientId != null ? ingredientId.toString() : "") + "|" + unit; + } + private static class MergedIngredient { final Ingredient ingredient; final String unit; diff --git a/backend/src/main/java/com/recipeapp/shopping/dto/AddItemRequest.java b/backend/src/main/java/com/recipeapp/shopping/dto/AddItemRequest.java index 4f2e5ef..276e480 100644 --- a/backend/src/main/java/com/recipeapp/shopping/dto/AddItemRequest.java +++ b/backend/src/main/java/com/recipeapp/shopping/dto/AddItemRequest.java @@ -1,11 +1,15 @@ package com.recipeapp.shopping.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + import java.math.BigDecimal; import java.util.UUID; public record AddItemRequest( UUID ingredientId, - String customName, - BigDecimal quantity, + @NotBlank @Size(max = 255) String customName, + @Positive BigDecimal quantity, String unit ) {} 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/main/java/com/recipeapp/shopping/entity/ShoppingList.java b/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingList.java index 5c0679c..7fd3232 100644 --- a/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingList.java +++ b/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingList.java @@ -3,6 +3,7 @@ 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; @@ -23,6 +24,9 @@ public class ShoppingList { @JoinColumn(name = "week_plan_id", nullable = false) private WeekPlan weekPlan; + @Column(name = "generated_at", nullable = false) + private Instant generatedAt = Instant.now(); + @OneToMany(mappedBy = "shoppingList", cascade = CascadeType.ALL, orphanRemoval = true) private List items = new ArrayList<>(); @@ -36,5 +40,7 @@ public class ShoppingList { public UUID getId() { return id; } public Household getHousehold() { return household; } public WeekPlan getWeekPlan() { return weekPlan; } + public Instant getGeneratedAt() { return generatedAt; } + public void setGeneratedAt(Instant generatedAt) { this.generatedAt = generatedAt; } public List getItems() { return items; } } diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml new file mode 100644 index 0000000..c0cb1ec --- /dev/null +++ b/backend/src/main/resources/application-docker.yml @@ -0,0 +1,4 @@ +spring: + flyway: + locations: classpath:db/migration,classpath:db/seed + out-of-order: true diff --git a/backend/src/main/resources/db/migration/V022__add_shopping_list_generated_at.sql b/backend/src/main/resources/db/migration/V022__add_shopping_list_generated_at.sql new file mode 100644 index 0000000..767cb38 --- /dev/null +++ b/backend/src/main/resources/db/migration/V022__add_shopping_list_generated_at.sql @@ -0,0 +1,2 @@ +ALTER TABLE shopping_list + ADD COLUMN IF NOT EXISTS generated_at timestamptz NOT NULL DEFAULT now(); diff --git a/backend/src/test/java/com/recipeapp/common/HouseholdRoleInterceptorTest.java b/backend/src/test/java/com/recipeapp/common/HouseholdRoleInterceptorTest.java new file mode 100644 index 0000000..3b0754c --- /dev/null +++ b/backend/src/test/java/com/recipeapp/common/HouseholdRoleInterceptorTest.java @@ -0,0 +1,84 @@ +package com.recipeapp.common; + +import com.recipeapp.recipe.HouseholdResolver; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.method.HandlerMethod; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class HouseholdRoleInterceptorTest { + + @Mock private HouseholdResolver householdResolver; + @Mock private HttpServletRequest request; + @Mock private HttpServletResponse response; + + @InjectMocks private HouseholdRoleInterceptor interceptor; + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private void authenticateAs(String email) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(email, null)); + } + + @Test + void shouldAllowWhenUserHasRequiredRole() throws Exception { + authenticateAs("planner@example.com"); + when(householdResolver.resolveRole("planner@example.com")).thenReturn("planner"); + + var handlerMethod = mock(HandlerMethod.class); + var annotation = mock(RequiresHouseholdRole.class); + when(annotation.value()).thenReturn("planner"); + when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation); + + boolean result = interceptor.preHandle(request, response, handlerMethod); + + assertThat(result).isTrue(); + } + + @Test + void shouldThrowForbiddenWhenUserLacksRequiredRole() { + authenticateAs("member@example.com"); + when(householdResolver.resolveRole("member@example.com")).thenReturn("member"); + + var handlerMethod = mock(HandlerMethod.class); + var annotation = mock(RequiresHouseholdRole.class); + when(annotation.value()).thenReturn("planner"); + when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation); + + assertThatThrownBy(() -> interceptor.preHandle(request, response, handlerMethod)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("Insufficient permissions"); + } + + @Test + void shouldPassThroughWhenNoAnnotation() throws Exception { + var handlerMethod = mock(HandlerMethod.class); + when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(null); + + boolean result = interceptor.preHandle(request, response, handlerMethod); + + assertThat(result).isTrue(); + } + + @Test + void shouldPassThroughWhenNotHandlerMethod() throws Exception { + boolean result = interceptor.preHandle(request, response, new Object()); + + assertThat(result).isTrue(); + } +} diff --git a/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java b/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java index fc7870a..c19151c 100644 --- a/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java +++ b/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java @@ -3,8 +3,10 @@ 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.HouseholdRoleInterceptor; import com.recipeapp.recipe.HouseholdResolver; import com.recipeapp.shopping.dto.*; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -12,10 +14,13 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; 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; @@ -48,13 +53,46 @@ class ShoppingListControllerTest { .build(); } + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void getByWeekStartShouldReturn200() throws Exception { + var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 3, List.of()); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(shoppingService.getByWeekStart(eq(HOUSEHOLD_ID), any())).thenReturn(response); + + mockMvc.perform(get("/v1/shopping-list") + .param("weekStart", "2026-04-06") + .principal(() -> "sarah@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(LIST_ID.toString())) + .andExpect(jsonPath("$.filteredStaplesCount").value(3)); + } + + @Test + void getByWeekStartShouldReturn404WhenNoListExists() throws Exception { + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(shoppingService.getByWeekStart(eq(HOUSEHOLD_ID), any())).thenReturn(null); + + mockMvc.perform(get("/v1/shopping-list") + .param("weekStart", "2026-04-06") + .principal(() -> "sarah@example.com")) + .andExpect(status().isNotFound()); + } + @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 +106,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 +122,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 +143,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))) @@ -128,4 +168,30 @@ class ShoppingListControllerTest { .principal(() -> "sarah@example.com")) .andExpect(status().isNoContent()); } + + @Test + void addItemShouldReturn400WhenCustomNameIsBlank() throws Exception { + mockMvc.perform(post("/v1/shopping-lists/{id}/items", LIST_ID) + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new AddItemRequest(null, " ", new BigDecimal("1"), "")))) + .andExpect(status().isBadRequest()); + } + + @Test + void generateFromPlanShouldReturn403ForNonPlanner() throws Exception { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("member@example.com", null)); + when(householdResolver.resolveRole("member@example.com")).thenReturn("member"); + + MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(shoppingListController) + .setControllerAdvice(new GlobalExceptionHandler()) + .addInterceptors(new HouseholdRoleInterceptor(householdResolver)) + .build(); + + mockMvcWithInterceptor.perform(post("/v1/week-plans/{id}/shopping-list", PLAN_ID) + .principal(() -> "member@example.com")) + .andExpect(status().isForbidden()); + } } diff --git a/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java index a106398..d5d1f0c 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; @@ -90,6 +92,46 @@ class ShoppingServiceTest { } catch (Exception e) { throw new RuntimeException(e); } } + // ── Get by week start ── + + @Test + void getByWeekStartShouldReturnListForGivenWeek() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + + when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START)) + .thenReturn(Optional.of(list)); + + ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, WEEK_START); + + assertThat(result.id()).isEqualTo(list.getId()); + } + + @Test + void getByWeekStartShouldDefaultToCurrentWeekWhenNull() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + + when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(eq(HOUSEHOLD_ID), any(LocalDate.class))) + .thenReturn(Optional.of(list)); + + ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, null); + + assertThat(result).isNotNull(); + } + + @Test + void getByWeekStartShouldReturnNullWhenNoListExists() { + when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START)) + .thenReturn(Optional.empty()); + + ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, WEEK_START); + + assertThat(result).isNull(); + } + // ── Generate ── @Test @@ -119,26 +161,84 @@ class ShoppingServiceTest { plan.getSlots().add(slot2); when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START)) + .thenReturn(Optional.empty()); when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> { ShoppingList sl = i.getArgument(0); - setId(sl, ShoppingList.class, UUID.randomUUID()); + if (sl.getId() == null) 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(); assertThat(cheeseItem.quantity()).isEqualByComparingTo(new BigDecimal("200.00")); } + @Test + void generateFromPlanShouldMergeWhenListAlreadyExists() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var existingList = testShoppingList(household, plan); + + // Existing generated item: 2 tomatoes + var tomato = testIngredient(household, "Tomatoes", false); + var existingItem = testItem(existingList, tomato, new BigDecimal("2.00"), "pcs"); + existingItem.setSourceRecipes(new UUID[]{UUID.randomUUID()}); + existingList.getItems().add(existingItem); + + // Existing custom item (should be preserved) + var customItem = new ShoppingListItem(existingList, null, "Paper towels", + new BigDecimal("1"), "", new UUID[0]); + setId(customItem, ShoppingListItem.class, UUID.randomUUID()); + customItem.setChecked(true); + existingList.getItems().add(customItem); + + // New plan: 5 tomatoes + cheese (tomato quantity updated, cheese added) + var recipe = testRecipe(household, "Pasta"); + var cheese = testIngredient(household, "Cheese", false); + recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("5.00"), "pcs", (short) 1)); + recipe.getIngredients().add(new RecipeIngredient(recipe, cheese, new BigDecimal("200.00"), "g", (short) 2)); + var slot = new WeekPlanSlot(plan, recipe, WEEK_START); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, plan.getWeekStart())) + .thenReturn(Optional.of(existingList)); + when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0)); + when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe)); + + ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId()); + + // Should have 3 items: tomato (updated), cheese (new), paper towels (preserved custom) + assertThat(result.items()).hasSize(3); + + var tomatoResult = result.items().stream() + .filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow(); + assertThat(tomatoResult.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); + + var cheeseResult = result.items().stream() + .filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow(); + assertThat(cheeseResult.quantity()).isEqualByComparingTo(new BigDecimal("200.00")); + + // Custom item preserved with check state + var customResult = result.items().stream() + .filter(i -> "Paper towels".equals(i.name())).findFirst().orElseThrow(); + assertThat(customResult.isChecked()).isTrue(); + } + @Test void generateFromPlanShouldThrowWhenPlanNotFound() { var planId = UUID.randomUUID(); @@ -164,6 +264,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"); } @@ -367,6 +468,97 @@ class ShoppingServiceTest { .isInstanceOf(ResourceNotFoundException.class); } + // ── Generate removes stale items ── + + @Test + void generateFromPlanShouldRemoveStaleGeneratedItems() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var existingList = testShoppingList(household, plan); + + var tomato = testIngredient(household, "Tomatoes", false); + var onion = testIngredient(household, "Onions", false); + + // Existing list has both tomatoes and onions (generated) + var tomatoItem = testItem(existingList, tomato, new BigDecimal("2.00"), "pcs"); + tomatoItem.setSourceRecipes(new UUID[]{UUID.randomUUID()}); + existingList.getItems().add(tomatoItem); + + var onionItem = testItem(existingList, onion, new BigDecimal("1.00"), "pcs"); + onionItem.setSourceRecipes(new UUID[]{UUID.randomUUID()}); + existingList.getItems().add(onionItem); + + // New plan only has tomatoes — onions removed from recipes + var recipe = testRecipe(household, "Sauce"); + recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("3.00"), "pcs", (short) 1)); + var slot = new WeekPlanSlot(plan, recipe, WEEK_START); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START)) + .thenReturn(Optional.of(existingList)); + when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0)); + when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe)); + + ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes"); + } + + // ── Source recipes deduplication ── + + @Test + void generateFromPlanShouldDeduplicateSourceRecipesWhenSameRecipeInTwoSlots() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var recipe = testRecipe(household, "Pasta"); + var tomato = testIngredient(household, "Tomatoes", false); + recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("2.00"), "pcs", (short) 1)); + + // Same recipe in two slots + var slot1 = new WeekPlanSlot(plan, recipe, WEEK_START); + setId(slot1, WeekPlanSlot.class, UUID.randomUUID()); + var slot2 = new WeekPlanSlot(plan, recipe, WEEK_START.plusDays(2)); + setId(slot2, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot1); + plan.getSlots().add(slot2); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START)) + .thenReturn(Optional.empty()); + when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> { + ShoppingList sl = i.getArgument(0); + if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID()); + return sl; + }); + when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe)); + + ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().getFirst().sourceRecipes()).hasSize(1); // deduplicated + } + + // ── checkItem household isolation ── + + @Test + void checkItemShouldThrowWhenHouseholdMismatch() { + var otherHousehold = new Household("Other family", null); + setId(otherHousehold, Household.class, UUID.randomUUID()); + var plan = new WeekPlan(otherHousehold, WEEK_START); + setId(plan, WeekPlan.class, UUID.randomUUID()); + var list = new ShoppingList(otherHousehold, plan); + setId(list, ShoppingList.class, UUID.randomUUID()); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + + assertThatThrownBy(() -> shoppingService.checkItem( + HOUSEHOLD_ID, list.getId(), UUID.randomUUID(), new CheckItemRequest(true), UUID.randomUUID())) + .isInstanceOf(ResourceNotFoundException.class); + } + // ── Generate from plan with empty slots ── @Test @@ -376,9 +568,11 @@ class ShoppingServiceTest { // no slots added when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START)) + .thenReturn(Optional.empty()); when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> { ShoppingList sl = i.getArgument(0); - setId(sl, ShoppingList.class, UUID.randomUUID()); + if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID()); return sl; }); diff --git a/frontend/src/lib/api/openapi.json b/frontend/src/lib/api/openapi.json index 4caf3f8..0b6b1ec 100644 --- a/frontend/src/lib/api/openapi.json +++ b/frontend/src/lib/api/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"paths":{"/v1/recipes/{id}":{"get":{"tags":["recipe-controller"],"operationId":"getRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}},"put":{"tags":["recipe-controller"],"operationId":"updateRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipeCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}},"delete":{"tags":["recipe-controller"],"operationId":"deleteRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/v1/week-plans":{"get":{"tags":["week-plan-controller"],"operationId":"getWeekPlan","parameters":[{"name":"weekStart","in":"query","required":true,"schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}},"post":{"tags":["week-plan-controller"],"operationId":"createWeekPlan","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWeekPlanRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}}},"/v1/week-plans/{id}/slots":{"post":{"tags":["week-plan-controller"],"operationId":"addSlot","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSlotRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlotResponse"}}}}}}},"/v1/week-plans/{id}/shopping-list":{"post":{"tags":["shopping-list-controller"],"operationId":"generateFromPlan","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/week-plans/{id}/confirm":{"post":{"tags":["week-plan-controller"],"operationId":"confirmPlan","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}}},"/v1/tags":{"get":{"tags":["tag-controller"],"operationId":"listTags","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagResponse"}}}}}}},"post":{"tags":["tag-controller"],"operationId":"createTag","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TagCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TagResponse"}}}}}}},"/v1/shopping-lists/{id}/items":{"post":{"tags":["shopping-list-controller"],"operationId":"addItem","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddItemRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}}}}},"/v1/recipes":{"get":{"tags":["recipe-controller"],"operationId":"listRecipes","parameters":[{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"effort","in":"query","required":false,"schema":{"type":"string"}},{"name":"isChildFriendly","in":"query","required":false,"schema":{"type":"boolean"}},{"name":"cookTimeMin.lte","in":"query","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"sort","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListRecipeSummaryResponse"}}}}}},"post":{"tags":["recipe-controller"],"operationId":"createRecipe","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipeCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}}},"/v1/pantry-items":{"get":{"tags":["pantry-controller"],"operationId":"listItems","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"post":{"tags":["pantry-controller"],"operationId":"createItem","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePantryItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"/v1/invites/{code}/accept":{"post":{"tags":["household-controller"],"operationId":"acceptInvite","parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAcceptInviteResponse"}}}}}}},"/v1/ingredient-categories":{"get":{"tags":["ingredient-category-controller"],"operationId":"listCategories","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IngredientCategoryResponse"}}}}}}},"post":{"tags":["ingredient-category-controller"],"operationId":"createCategory","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngredientCategoryCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IngredientCategoryResponse"}}}}}}},"/v1/households":{"post":{"tags":["household-controller"],"operationId":"createHousehold","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateHouseholdRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseHouseholdResponse"}}}}}}},"/v1/households/mine/invites":{"post":{"tags":["household-controller"],"operationId":"createInvite","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseInviteResponse"}}}}}}},"/v1/cooking-logs":{"get":{"tags":["cooking-log-controller"],"operationId":"listCookingLogs","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":30}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CookingLogResponse"}}}}}}},"post":{"tags":["cooking-log-controller"],"operationId":"createCookingLog","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCookingLogRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CookingLogResponse"}}}}}}},"/v1/auth/signup":{"post":{"tags":["auth-controller"],"operationId":"signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/auth/logout":{"post":{"tags":["auth-controller"],"operationId":"logout","responses":{"200":{"description":"OK"}}}},"/v1/auth/login":{"post":{"tags":["auth-controller"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/admin/users":{"get":{"tags":["admin-controller"],"operationId":"listUsers","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"isActive","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListAdminUserResponse"}}}}}},"post":{"tags":["admin-controller"],"operationId":"createUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminUserResponse"}}}}}}},"/v1/admin/users/{id}/reset-password":{"post":{"tags":["admin-controller"],"operationId":"resetPassword","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResetPasswordRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseResetPasswordResponse"}}}}}}},"/v1/week-plans/{planId}/slots/{slotId}":{"delete":{"tags":["week-plan-controller"],"operationId":"deleteSlot","parameters":[{"name":"planId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["week-plan-controller"],"operationId":"updateSlot","parameters":[{"name":"planId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSlotRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlotResponse"}}}}}}},"/v1/shopping-lists/{listId}/items/{itemId}":{"delete":{"tags":["shopping-list-controller"],"operationId":"deleteItem","parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"itemId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["shopping-list-controller"],"operationId":"checkItem","parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"itemId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}}}}},"/v1/pantry-items/{id}":{"delete":{"tags":["pantry-controller"],"operationId":"deleteItem_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["pantry-controller"],"operationId":"updateItem","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePantryItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"/v1/ingredients/{id}":{"patch":{"tags":["ingredient-controller"],"operationId":"patchIngredient","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngredientPatchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IngredientResponse"}}}}}}},"/v1/auth/me":{"get":{"tags":["auth-controller"],"operationId":"me","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}},"patch":{"tags":["auth-controller"],"operationId":"updateProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/admin/users/{id}":{"patch":{"tags":["admin-controller"],"operationId":"updateUser","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminUserResponse"}}}}}}},"/v1/week-plans/{id}/variety-score":{"get":{"tags":["week-plan-controller"],"operationId":"getVarietyScore","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VarietyScoreResponse"}}}}}}},"/v1/week-plans/{id}/suggestions":{"get":{"tags":["week-plan-controller"],"operationId":"getSuggestions","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotDate","in":"query","required":true,"schema":{"type":"string","format":"date"}},{"name":"tags","in":"query","required":false,"schema":{"type":"array","items":{"type":"string"}}},{"name":"topN","in":"query","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SuggestionResponse"}}}}}}},"/v1/shopping-lists/{id}":{"get":{"tags":["shopping-list-controller"],"operationId":"getShoppingList","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/ingredients":{"get":{"tags":["ingredient-controller"],"operationId":"searchIngredients","parameters":[{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"isStaple","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IngredientResponse"}}}}}}}},"/v1/households/mine":{"get":{"tags":["household-controller"],"operationId":"getMyHousehold","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseHouseholdResponse"}}}}}}},"/v1/households/mine/members":{"get":{"tags":["household-controller"],"operationId":"getMembers","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}}}}}}}},"/v1/admin/audit-log":{"get":{"tags":["admin-controller"],"operationId":"listAuditLog","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"targetUserId","in":"query","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListAuditLogResponse"}}}}}}}},"components":{"schemas":{"IngredientEntry":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"newIngredientName":{"type":"string","maxLength":200,"minLength":0},"quantity":{"type":"number","minimum":0.01},"unit":{"type":"string","maxLength":20,"minLength":0},"sortOrder":{"type":"integer","format":"int32"}},"required":["quantity","unit"]},"RecipeCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":200,"minLength":0},"serves":{"type":"integer","format":"int32","maximum":20,"minimum":1},"cookTimeMin":{"type":"integer","format":"int32","minimum":0},"effort":{"type":"string","minLength":1,"pattern":"easy|medium|hard"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string","maxLength":500,"minLength":0},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientEntry"},"minItems":1},"steps":{"type":"array","items":{"$ref":"#/components/schemas/StepEntry"}},"tagIds":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1}},"required":["effort","ingredients","name","tagIds"]},"StepEntry":{"type":"object","properties":{"stepNumber":{"type":"integer","format":"int32","minimum":1},"instruction":{"type":"string","minLength":1}},"required":["instruction"]},"CategoryRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"IngredientItem":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"sortOrder":{"type":"integer","format":"int32"}}},"RecipeDetailResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serves":{"type":"integer","format":"int32"},"cookTimeMin":{"type":"integer","format":"int32"},"effort":{"type":"string"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string"},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientItem"}},"steps":{"type":"array","items":{"$ref":"#/components/schemas/StepItem"}},"tags":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}},"StepItem":{"type":"object","properties":{"stepNumber":{"type":"integer","format":"int32"},"instruction":{"type":"string"}}},"TagItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"tagType":{"type":"string"}}},"CreateWeekPlanRequest":{"type":"object","properties":{"weekStart":{"type":"string","format":"date"}},"required":["weekStart"]},"SlotRecipe":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"effort":{"type":"string"},"cookTimeMin":{"type":"integer","format":"int32"},"heroImageUrl":{"type":"string"}}},"SlotResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slotDate":{"type":"string","format":"date"},"recipe":{"$ref":"#/components/schemas/SlotRecipe"}}},"WeekPlanResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"weekStart":{"type":"string","format":"date"},"status":{"type":"string"},"confirmedAt":{"type":"string","format":"date-time"},"slots":{"type":"array","items":{"$ref":"#/components/schemas/SlotResponse"}}}},"CreateSlotRequest":{"type":"object","properties":{"slotDate":{"type":"string","format":"date"},"recipeId":{"type":"string","format":"uuid"}},"required":["recipeId","slotDate"]},"ShoppingListItemResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"isChecked":{"type":"boolean"},"checkedBy":{"type":"string","format":"uuid"},"sourceRecipes":{"type":"array","items":{"type":"string","format":"uuid"}}}},"ShoppingListResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"weekPlanId":{"type":"string","format":"uuid"},"items":{"type":"array","items":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}},"TagCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":50,"minLength":0},"tagType":{"type":"string","minLength":1,"pattern":"protein|dietary|cuisine|other"}},"required":["name","tagType"]},"TagResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"tagType":{"type":"string"}}},"AddItemRequest":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"customName":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"}}},"CreatePantryItemRequest":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"customName":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"PantryItemResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"AcceptInviteResponse":{"type":"object","properties":{"householdId":{"type":"string","format":"uuid"},"householdName":{"type":"string"},"role":{"type":"string"}}},"ApiResponseAcceptInviteResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/AcceptInviteResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"Meta":{"type":"object","properties":{"pagination":{"$ref":"#/components/schemas/Pagination"}}},"Pagination":{"type":"object","properties":{"total":{"type":"integer","format":"int64"},"limit":{"type":"integer","format":"int32"},"offset":{"type":"integer","format":"int32"},"hasMore":{"type":"boolean"}}},"IngredientCategoryCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":50,"minLength":0}},"required":["name"]},"IngredientCategoryResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"CreateHouseholdRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":100,"minLength":0}},"required":["name"]},"ApiResponseHouseholdResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/HouseholdResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"HouseholdResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"members":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}}}},"MemberResponse":{"type":"object","properties":{"userId":{"type":"string","format":"uuid"},"displayName":{"type":"string"},"role":{"type":"string"},"joinedAt":{"type":"string","format":"date-time"}}},"ApiResponseInviteResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/InviteResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"InviteResponse":{"type":"object","properties":{"inviteCode":{"type":"string"},"shareUrl":{"type":"string"},"expiresAt":{"type":"string","format":"date-time"}}},"CreateCookingLogRequest":{"type":"object","properties":{"recipeId":{"type":"string","format":"uuid"},"cookedOn":{"type":"string","format":"date"}},"required":["recipeId"]},"CookingLogResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"recipeId":{"type":"string","format":"uuid"},"recipeName":{"type":"string"},"cookedOn":{"type":"string","format":"date"},"cookedBy":{"type":"string","format":"uuid"}}},"SignupRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"password":{"type":"string","maxLength":2147483647,"minLength":8},"displayName":{"type":"string","maxLength":100,"minLength":0}},"required":["displayName","email","password"]},"ApiResponseUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/UserResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"UserResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"displayName":{"type":"string"},"householdId":{"type":"string","format":"uuid"},"householdName":{"type":"string"},"householdRole":{"type":"string"},"systemRole":{"type":"string"}}},"LoginRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"password":{"type":"string","minLength":1}},"required":["email","password"]},"CreateUserRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"displayName":{"type":"string","maxLength":100,"minLength":0},"tempPassword":{"type":"string","maxLength":2147483647,"minLength":8},"systemRole":{"type":"string"}},"required":["displayName","email","tempPassword"]},"AdminUserResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"displayName":{"type":"string"},"systemRole":{"type":"string"},"isActive":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}},"ApiResponseAdminUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminUserResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"ResetPasswordRequest":{"type":"object","properties":{"tempPassword":{"type":"string","maxLength":2147483647,"minLength":8},"reason":{"type":"string"}},"required":["tempPassword"]},"ApiResponseResetPasswordResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/ResetPasswordResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"ResetPasswordResponse":{"type":"object","properties":{"message":{"type":"string"},"mustChangePassword":{"type":"boolean"}}},"UpdateSlotRequest":{"type":"object","properties":{"recipeId":{"type":"string","format":"uuid"}},"required":["recipeId"]},"CheckItemRequest":{"type":"object","properties":{"isChecked":{"type":"boolean"}}},"UpdatePantryItemRequest":{"type":"object","properties":{"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"IngredientPatchRequest":{"type":"object","properties":{"name":{"type":"string"},"isStaple":{"type":"boolean"},"categoryId":{"type":"string","format":"uuid"}}},"IngredientResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"isStaple":{"type":"boolean"}}},"UpdateProfileRequest":{"type":"object","properties":{"displayName":{"type":"string","maxLength":100,"minLength":0},"currentPassword":{"type":"string"},"newPassword":{"type":"string","maxLength":2147483647,"minLength":8}}},"UpdateUserRequest":{"type":"object","properties":{"displayName":{"type":"string"},"email":{"type":"string"},"systemRole":{"type":"string"},"isActive":{"type":"boolean"}}},"IngredientOverlap":{"type":"object","properties":{"ingredientName":{"type":"string"},"days":{"type":"array","items":{"type":"string","format":"date"}}}},"TagRepeat":{"type":"object","properties":{"tagName":{"type":"string"},"tagType":{"type":"string"},"days":{"type":"array","items":{"type":"string","format":"date"}}}},"VarietyScoreResponse":{"type":"object","properties":{"score":{"type":"number","format":"double"},"tagRepeats":{"type":"array","items":{"$ref":"#/components/schemas/TagRepeat"}},"ingredientOverlaps":{"type":"array","items":{"$ref":"#/components/schemas/IngredientOverlap"}},"recentRepeats":{"type":"array","items":{"type":"string"}},"duplicatesInPlan":{"type":"array","items":{"type":"string"}}}},"SuggestionItem":{"type":"object","properties":{"recipe":{"$ref":"#/components/schemas/SlotRecipe"},"simulatedScore":{"type":"number","format":"double"}}},"SuggestionResponse":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/SuggestionItem"}}}},"ApiResponseListRecipeSummaryResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RecipeSummaryResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"RecipeSummaryResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serves":{"type":"integer","format":"int32"},"cookTimeMin":{"type":"integer","format":"int32"},"effort":{"type":"string"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string"}}},"ApiResponseListAdminUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/AdminUserResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"ApiResponseListAuditLogResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/AuditLogResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"AuditLogResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"adminId":{"type":"string","format":"uuid"},"adminEmail":{"type":"string"},"targetUserId":{"type":"string","format":"uuid"},"targetEmail":{"type":"string"},"action":{"type":"string"},"detail":{"type":"object","additionalProperties":{}},"performedAt":{"type":"string","format":"date-time"}}}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"paths":{"/v1/recipes/{id}":{"get":{"tags":["recipe-controller"],"operationId":"getRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}},"put":{"tags":["recipe-controller"],"operationId":"updateRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipeCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}},"delete":{"tags":["recipe-controller"],"operationId":"deleteRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/v1/week-plans":{"get":{"tags":["week-plan-controller"],"operationId":"getWeekPlan","parameters":[{"name":"weekStart","in":"query","required":true,"schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}},"post":{"tags":["week-plan-controller"],"operationId":"createWeekPlan","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWeekPlanRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}}},"/v1/week-plans/{id}/slots":{"post":{"tags":["week-plan-controller"],"operationId":"addSlot","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSlotRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlotResponse"}}}}}}},"/v1/week-plans/{id}/shopping-list":{"post":{"tags":["shopping-list-controller"],"operationId":"generateFromPlan","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/week-plans/{id}/confirm":{"post":{"tags":["week-plan-controller"],"operationId":"confirmPlan","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}}},"/v1/tags":{"get":{"tags":["tag-controller"],"operationId":"listTags","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagResponse"}}}}}}},"post":{"tags":["tag-controller"],"operationId":"createTag","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TagCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TagResponse"}}}}}}},"/v1/shopping-lists/{id}/items":{"post":{"tags":["shopping-list-controller"],"operationId":"addItem","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddItemRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}}}}},"/v1/recipes":{"get":{"tags":["recipe-controller"],"operationId":"listRecipes","parameters":[{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"effort","in":"query","required":false,"schema":{"type":"string"}},{"name":"isChildFriendly","in":"query","required":false,"schema":{"type":"boolean"}},{"name":"cookTimeMin.lte","in":"query","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"sort","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListRecipeSummaryResponse"}}}}}},"post":{"tags":["recipe-controller"],"operationId":"createRecipe","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipeCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}}},"/v1/pantry-items":{"get":{"tags":["pantry-controller"],"operationId":"listItems","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"post":{"tags":["pantry-controller"],"operationId":"createItem","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePantryItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"/v1/invites/{code}/accept":{"post":{"tags":["household-controller"],"operationId":"acceptInvite","parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAcceptInviteResponse"}}}}}}},"/v1/ingredient-categories":{"get":{"tags":["ingredient-category-controller"],"operationId":"listCategories","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IngredientCategoryResponse"}}}}}}},"post":{"tags":["ingredient-category-controller"],"operationId":"createCategory","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngredientCategoryCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IngredientCategoryResponse"}}}}}}},"/v1/households":{"post":{"tags":["household-controller"],"operationId":"createHousehold","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateHouseholdRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseHouseholdResponse"}}}}}}},"/v1/households/mine/invites":{"post":{"tags":["household-controller"],"operationId":"createInvite","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseInviteResponse"}}}}}}},"/v1/cooking-logs":{"get":{"tags":["cooking-log-controller"],"operationId":"listCookingLogs","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":30}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CookingLogResponse"}}}}}}},"post":{"tags":["cooking-log-controller"],"operationId":"createCookingLog","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCookingLogRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CookingLogResponse"}}}}}}},"/v1/auth/signup":{"post":{"tags":["auth-controller"],"operationId":"signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/auth/logout":{"post":{"tags":["auth-controller"],"operationId":"logout","responses":{"200":{"description":"OK"}}}},"/v1/auth/login":{"post":{"tags":["auth-controller"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/admin/users":{"get":{"tags":["admin-controller"],"operationId":"listUsers","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"isActive","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListAdminUserResponse"}}}}}},"post":{"tags":["admin-controller"],"operationId":"createUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminUserResponse"}}}}}}},"/v1/admin/users/{id}/reset-password":{"post":{"tags":["admin-controller"],"operationId":"resetPassword","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResetPasswordRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseResetPasswordResponse"}}}}}}},"/v1/week-plans/{planId}/slots/{slotId}":{"delete":{"tags":["week-plan-controller"],"operationId":"deleteSlot","parameters":[{"name":"planId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["week-plan-controller"],"operationId":"updateSlot","parameters":[{"name":"planId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSlotRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlotResponse"}}}}}}},"/v1/shopping-lists/{listId}/items/{itemId}":{"delete":{"tags":["shopping-list-controller"],"operationId":"deleteItem","parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"itemId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["shopping-list-controller"],"operationId":"checkItem","parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"itemId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}}}}},"/v1/pantry-items/{id}":{"delete":{"tags":["pantry-controller"],"operationId":"deleteItem_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["pantry-controller"],"operationId":"updateItem","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePantryItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"/v1/ingredients/{id}":{"patch":{"tags":["ingredient-controller"],"operationId":"patchIngredient","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngredientPatchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IngredientResponse"}}}}}}},"/v1/auth/me":{"get":{"tags":["auth-controller"],"operationId":"me","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}},"patch":{"tags":["auth-controller"],"operationId":"updateProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/admin/users/{id}":{"patch":{"tags":["admin-controller"],"operationId":"updateUser","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminUserResponse"}}}}}}},"/v1/week-plans/{id}/variety-score":{"get":{"tags":["week-plan-controller"],"operationId":"getVarietyScore","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VarietyScoreResponse"}}}}}}},"/v1/week-plans/{id}/suggestions":{"get":{"tags":["week-plan-controller"],"operationId":"getSuggestions","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotDate","in":"query","required":true,"schema":{"type":"string","format":"date"}},{"name":"tags","in":"query","required":false,"schema":{"type":"array","items":{"type":"string"}}},{"name":"topN","in":"query","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SuggestionResponse"}}}}}}},"/v1/shopping-lists/{id}":{"get":{"tags":["shopping-list-controller"],"operationId":"getShoppingList","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/shopping-list":{"get":{"tags":["shopping-list-controller"],"operationId":"getByWeekStart","parameters":[{"name":"weekStart","in":"query","required":false,"schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/ingredients":{"get":{"tags":["ingredient-controller"],"operationId":"searchIngredients","parameters":[{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"isStaple","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IngredientResponse"}}}}}}}},"/v1/households/mine":{"get":{"tags":["household-controller"],"operationId":"getMyHousehold","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseHouseholdResponse"}}}}}}},"/v1/households/mine/members":{"get":{"tags":["household-controller"],"operationId":"getMembers","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}}}}}}}},"/v1/admin/audit-log":{"get":{"tags":["admin-controller"],"operationId":"listAuditLog","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"targetUserId","in":"query","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListAuditLogResponse"}}}}}}}},"components":{"schemas":{"IngredientEntry":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"newIngredientName":{"type":"string","maxLength":200,"minLength":0},"quantity":{"type":"number","minimum":0.01},"unit":{"type":"string","maxLength":20,"minLength":0},"sortOrder":{"type":"integer","format":"int32"}},"required":["quantity","unit"]},"RecipeCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":200,"minLength":0},"serves":{"type":"integer","format":"int32","maximum":20,"minimum":1},"cookTimeMin":{"type":"integer","format":"int32","minimum":0},"effort":{"type":"string","minLength":1,"pattern":"easy|medium|hard"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string","maxLength":500,"minLength":0},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientEntry"},"minItems":1},"steps":{"type":"array","items":{"$ref":"#/components/schemas/StepEntry"}},"tagIds":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1}},"required":["effort","ingredients","name","tagIds"]},"StepEntry":{"type":"object","properties":{"stepNumber":{"type":"integer","format":"int32","minimum":1},"instruction":{"type":"string","minLength":1}},"required":["instruction"]},"CategoryRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"IngredientItem":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"sortOrder":{"type":"integer","format":"int32"}}},"RecipeDetailResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serves":{"type":"integer","format":"int32"},"cookTimeMin":{"type":"integer","format":"int32"},"effort":{"type":"string"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string"},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientItem"}},"steps":{"type":"array","items":{"$ref":"#/components/schemas/StepItem"}},"tags":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}},"StepItem":{"type":"object","properties":{"stepNumber":{"type":"integer","format":"int32"},"instruction":{"type":"string"}}},"TagItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"tagType":{"type":"string"}}},"CreateWeekPlanRequest":{"type":"object","properties":{"weekStart":{"type":"string","format":"date"}},"required":["weekStart"]},"SlotRecipe":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"effort":{"type":"string"},"cookTimeMin":{"type":"integer","format":"int32"},"heroImageUrl":{"type":"string"}}},"SlotResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slotDate":{"type":"string","format":"date"},"recipe":{"$ref":"#/components/schemas/SlotRecipe"}}},"WeekPlanResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"weekStart":{"type":"string","format":"date"},"status":{"type":"string"},"confirmedAt":{"type":"string","format":"date-time"},"slots":{"type":"array","items":{"$ref":"#/components/schemas/SlotResponse"}}}},"CreateSlotRequest":{"type":"object","properties":{"slotDate":{"type":"string","format":"date"},"recipeId":{"type":"string","format":"uuid"}},"required":["recipeId","slotDate"]},"RecipeRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"ShoppingListItemResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"isChecked":{"type":"boolean"},"checkedBy":{"type":"string","format":"uuid"},"sourceRecipes":{"type":"array","items":{"$ref":"#/components/schemas/RecipeRef"}}}},"ShoppingListResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"weekPlanId":{"type":"string","format":"uuid"},"generatedAt":{"type":"string","format":"date-time"},"filteredStaplesCount":{"type":"integer","format":"int32"},"items":{"type":"array","items":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}},"TagCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":50,"minLength":0},"tagType":{"type":"string","minLength":1,"pattern":"protein|dietary|cuisine|other"}},"required":["name","tagType"]},"TagResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"tagType":{"type":"string"}}},"AddItemRequest":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"customName":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"}}},"CreatePantryItemRequest":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"customName":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"PantryItemResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"AcceptInviteResponse":{"type":"object","properties":{"householdId":{"type":"string","format":"uuid"},"householdName":{"type":"string"},"role":{"type":"string"}}},"ApiResponseAcceptInviteResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/AcceptInviteResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"Meta":{"type":"object","properties":{"pagination":{"$ref":"#/components/schemas/Pagination"}}},"Pagination":{"type":"object","properties":{"total":{"type":"integer","format":"int64"},"limit":{"type":"integer","format":"int32"},"offset":{"type":"integer","format":"int32"},"hasMore":{"type":"boolean"}}},"IngredientCategoryCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":50,"minLength":0}},"required":["name"]},"IngredientCategoryResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"CreateHouseholdRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":100,"minLength":0}},"required":["name"]},"ApiResponseHouseholdResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/HouseholdResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"HouseholdResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"members":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}}}},"MemberResponse":{"type":"object","properties":{"userId":{"type":"string","format":"uuid"},"displayName":{"type":"string"},"role":{"type":"string"},"joinedAt":{"type":"string","format":"date-time"}}},"ApiResponseInviteResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/InviteResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"InviteResponse":{"type":"object","properties":{"inviteCode":{"type":"string"},"shareUrl":{"type":"string"},"expiresAt":{"type":"string","format":"date-time"}}},"CreateCookingLogRequest":{"type":"object","properties":{"recipeId":{"type":"string","format":"uuid"},"cookedOn":{"type":"string","format":"date"}},"required":["recipeId"]},"CookingLogResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"recipeId":{"type":"string","format":"uuid"},"recipeName":{"type":"string"},"cookedOn":{"type":"string","format":"date"},"cookedBy":{"type":"string","format":"uuid"}}},"SignupRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"password":{"type":"string","maxLength":2147483647,"minLength":8},"displayName":{"type":"string","maxLength":100,"minLength":0}},"required":["displayName","email","password"]},"ApiResponseUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/UserResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"UserResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"displayName":{"type":"string"},"householdId":{"type":"string","format":"uuid"},"householdName":{"type":"string"},"householdRole":{"type":"string"},"systemRole":{"type":"string"}}},"LoginRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"password":{"type":"string","minLength":1}},"required":["email","password"]},"CreateUserRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"displayName":{"type":"string","maxLength":100,"minLength":0},"tempPassword":{"type":"string","maxLength":2147483647,"minLength":8},"systemRole":{"type":"string"}},"required":["displayName","email","tempPassword"]},"AdminUserResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"displayName":{"type":"string"},"systemRole":{"type":"string"},"isActive":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}},"ApiResponseAdminUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminUserResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"ResetPasswordRequest":{"type":"object","properties":{"tempPassword":{"type":"string","maxLength":2147483647,"minLength":8},"reason":{"type":"string"}},"required":["tempPassword"]},"ApiResponseResetPasswordResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/ResetPasswordResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"ResetPasswordResponse":{"type":"object","properties":{"message":{"type":"string"},"mustChangePassword":{"type":"boolean"}}},"UpdateSlotRequest":{"type":"object","properties":{"recipeId":{"type":"string","format":"uuid"}},"required":["recipeId"]},"CheckItemRequest":{"type":"object","properties":{"isChecked":{"type":"boolean"}}},"UpdatePantryItemRequest":{"type":"object","properties":{"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"IngredientPatchRequest":{"type":"object","properties":{"name":{"type":"string"},"isStaple":{"type":"boolean"},"categoryId":{"type":"string","format":"uuid"}}},"IngredientResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"isStaple":{"type":"boolean"}}},"UpdateProfileRequest":{"type":"object","properties":{"displayName":{"type":"string","maxLength":100,"minLength":0},"currentPassword":{"type":"string"},"newPassword":{"type":"string","maxLength":2147483647,"minLength":8}}},"UpdateUserRequest":{"type":"object","properties":{"displayName":{"type":"string"},"email":{"type":"string"},"systemRole":{"type":"string"},"isActive":{"type":"boolean"}}},"IngredientOverlap":{"type":"object","properties":{"ingredientName":{"type":"string"},"days":{"type":"array","items":{"type":"string","format":"date"}}}},"TagRepeat":{"type":"object","properties":{"tagName":{"type":"string"},"tagType":{"type":"string"},"days":{"type":"array","items":{"type":"string","format":"date"}}}},"VarietyScoreResponse":{"type":"object","properties":{"score":{"type":"number","format":"double"},"tagRepeats":{"type":"array","items":{"$ref":"#/components/schemas/TagRepeat"}},"ingredientOverlaps":{"type":"array","items":{"$ref":"#/components/schemas/IngredientOverlap"}},"recentRepeats":{"type":"array","items":{"type":"string"}},"duplicatesInPlan":{"type":"array","items":{"type":"string"}}}},"SuggestionItem":{"type":"object","properties":{"recipe":{"$ref":"#/components/schemas/SlotRecipe"},"simulatedScore":{"type":"number","format":"double"}}},"SuggestionResponse":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/SuggestionItem"}}}},"ApiResponseListRecipeSummaryResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RecipeSummaryResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"RecipeSummaryResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serves":{"type":"integer","format":"int32"},"cookTimeMin":{"type":"integer","format":"int32"},"effort":{"type":"string"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string"}}},"ApiResponseListAdminUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/AdminUserResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"ApiResponseListAuditLogResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/AuditLogResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"AuditLogResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"adminId":{"type":"string","format":"uuid"},"adminEmail":{"type":"string"},"targetUserId":{"type":"string","format":"uuid"},"targetEmail":{"type":"string"},"action":{"type":"string"},"detail":{"type":"object","additionalProperties":{}},"performedAt":{"type":"string","format":"date-time"}}}}}} \ No newline at end of file diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index aeea7e6..74d1952 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -452,6 +452,22 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/shopping-list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getByWeekStart"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/ingredients": { parameters: { query?: never; @@ -624,6 +640,11 @@ export interface components { /** Format: uuid */ recipeId: string; }; + RecipeRef: { + /** Format: uuid */ + id?: string; + name?: string; + }; ShoppingListItemResponse: { /** Format: uuid */ id?: string; @@ -636,13 +657,17 @@ export interface components { isChecked?: boolean; /** Format: uuid */ checkedBy?: string; - sourceRecipes?: string[]; + sourceRecipes?: components["schemas"]["RecipeRef"][]; }; ShoppingListResponse: { /** Format: uuid */ id?: string; /** Format: uuid */ weekPlanId?: string; + /** Format: date-time */ + generatedAt?: string; + /** Format: int32 */ + filteredStaplesCount?: number; items?: components["schemas"]["ShoppingListItemResponse"][]; }; TagCreateRequest: { @@ -1902,6 +1927,28 @@ export interface operations { }; }; }; + getByWeekStart: { + parameters: { + query?: { + weekStart?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ShoppingListResponse"]; + }; + }; + }; + }; searchIngredients: { parameters: { query?: { diff --git a/frontend/src/lib/shopping/AddCustomItem.svelte b/frontend/src/lib/shopping/AddCustomItem.svelte new file mode 100644 index 0000000..aad6c59 --- /dev/null +++ b/frontend/src/lib/shopping/AddCustomItem.svelte @@ -0,0 +1,88 @@ + + +{#if !expanded} + +{:else} +
{ + return async ({ result, update }) => { + if (result.type === 'success') { + await update(); + customName = ''; + quantity = '1'; + unit = ''; + expanded = false; + } + }; + }} + class="flex flex-col gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-3" + > + + + + +
+ + +
+ +
+ + +
+
+{/if} diff --git a/frontend/src/lib/shopping/AddCustomItem.test.ts b/frontend/src/lib/shopping/AddCustomItem.test.ts new file mode 100644 index 0000000..f19cce7 --- /dev/null +++ b/frontend/src/lib/shopping/AddCustomItem.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import AddCustomItem from './AddCustomItem.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('AddCustomItem', () => { + it('shows collapsed trigger button initially', () => { + render(AddCustomItem, { props: { listId: 'list-1' } }); + expect(screen.getByText(/Artikel hinzufügen/)).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Artikelname')).not.toBeInTheDocument(); + }); + + it('expands form when trigger button is clicked', async () => { + render(AddCustomItem, { props: { listId: 'list-1' } }); + await userEvent.click(screen.getByText(/Artikel hinzufügen/)); + expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument(); + }); + + it('collapses form when Abbrechen is clicked', async () => { + render(AddCustomItem, { props: { listId: 'list-1' } }); + await userEvent.click(screen.getByText(/Artikel hinzufügen/)); + expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Abbrechen')); + expect(screen.queryByPlaceholderText('Artikelname')).not.toBeInTheDocument(); + }); + + it('submit button is disabled when name is empty', async () => { + render(AddCustomItem, { props: { listId: 'list-1' } }); + await userEvent.click(screen.getByText(/Artikel hinzufügen/)); + expect(screen.getByRole('button', { name: /Hinzufügen/i })).toBeDisabled(); + }); + + it('submit button is enabled when name is entered', async () => { + render(AddCustomItem, { props: { listId: 'list-1' } }); + await userEvent.click(screen.getByText(/Artikel hinzufügen/)); + await userEvent.type(screen.getByPlaceholderText('Artikelname'), 'Papier'); + expect(screen.getByRole('button', { name: /Hinzufügen/i })).not.toBeDisabled(); + }); + + it('form submits to ?/addItem action', async () => { + render(AddCustomItem, { props: { listId: 'list-1' } }); + await userEvent.click(screen.getByText(/Artikel hinzufügen/)); + const form = screen.getByPlaceholderText('Artikelname').closest('form')!; + expect(form).toHaveAttribute('action', '?/addItem'); + }); + + it('form includes listId as hidden input', async () => { + render(AddCustomItem, { props: { listId: 'list-42' } }); + await userEvent.click(screen.getByText(/Artikel hinzufügen/)); + const form = screen.getByPlaceholderText('Artikelname').closest('form')!; + const listIdInput = form.querySelector('input[name="listId"]') as HTMLInputElement; + expect(listIdInput.value).toBe('list-42'); + }); +}); diff --git a/frontend/src/lib/shopping/ChecklistItem.svelte b/frontend/src/lib/shopping/ChecklistItem.svelte new file mode 100644 index 0000000..059073c --- /dev/null +++ b/frontend/src/lib/shopping/ChecklistItem.svelte @@ -0,0 +1,81 @@ + + +
async ({ update }) => update({ reset: false })} class="group flex items-center gap-3 py-2"> + + + + + + +
+

+ {name} +

+ {#if recipeLabel && !isChecked} +

+ Für: {recipeLabel} +

+ {/if} +
+ + {#if quantityLabel} + + {quantityLabel} + + {/if} +
diff --git a/frontend/src/lib/shopping/ChecklistItem.test.ts b/frontend/src/lib/shopping/ChecklistItem.test.ts new file mode 100644 index 0000000..0629c78 --- /dev/null +++ b/frontend/src/lib/shopping/ChecklistItem.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import ChecklistItem from './ChecklistItem.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('ChecklistItem', () => { + const baseProps = { + listId: 'list-1', + itemId: 'item-1', + name: 'Tomaten', + quantity: null, + unit: null, + isChecked: false, + sourceRecipes: [] + }; + + it('renders the item name', () => { + render(ChecklistItem, { props: baseProps }); + expect(screen.getByText('Tomaten')).toBeInTheDocument(); + }); + + it('renders quantity and unit when provided', () => { + render(ChecklistItem, { props: { ...baseProps, quantity: 3, unit: 'Stück' } }); + expect(screen.getByText('3 Stück')).toBeInTheDocument(); + }); + + it('renders quantity without unit when unit is null', () => { + render(ChecklistItem, { props: { ...baseProps, quantity: 2, unit: null } }); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('applies line-through style when checked', () => { + render(ChecklistItem, { props: { ...baseProps, isChecked: true } }); + const nameEl = screen.getByText('Tomaten'); + expect(nameEl.className).toContain('line-through'); + }); + + it('does not apply line-through when unchecked', () => { + render(ChecklistItem, { props: { ...baseProps, isChecked: false } }); + const nameEl = screen.getByText('Tomaten'); + expect(nameEl.className).not.toContain('line-through'); + }); + + it('shows recipe label for source recipes when unchecked', () => { + render(ChecklistItem, { + props: { + ...baseProps, + sourceRecipes: [{ id: 'r-1', name: 'Spaghetti' }] + } + }); + expect(screen.getByText(/Für: Spaghetti/)).toBeInTheDocument(); + }); + + it('hides recipe label when item is checked', () => { + render(ChecklistItem, { + props: { + ...baseProps, + isChecked: true, + sourceRecipes: [{ id: 'r-1', name: 'Spaghetti' }] + } + }); + expect(screen.queryByText(/Für:/)).not.toBeInTheDocument(); + }); + + it('sets aria-checked to false when unchecked', () => { + render(ChecklistItem, { props: { ...baseProps, isChecked: false } }); + const button = screen.getByRole('checkbox'); + expect(button).toHaveAttribute('aria-checked', 'false'); + }); + + it('sets aria-checked to true when checked', () => { + render(ChecklistItem, { props: { ...baseProps, isChecked: true } }); + const button = screen.getByRole('checkbox'); + expect(button).toHaveAttribute('aria-checked', 'true'); + }); + + it('passes listId and itemId as hidden inputs', () => { + render(ChecklistItem, { props: baseProps }); + const form = screen.getByRole('checkbox').closest('form')!; + const listIdInput = form.querySelector('input[name="listId"]') as HTMLInputElement; + const itemIdInput = form.querySelector('input[name="itemId"]') as HTMLInputElement; + expect(listIdInput.value).toBe('list-1'); + expect(itemIdInput.value).toBe('item-1'); + }); +}); diff --git a/frontend/src/lib/shopping/RecipeReferencePanel.svelte b/frontend/src/lib/shopping/RecipeReferencePanel.svelte new file mode 100644 index 0000000..2fc8189 --- /dev/null +++ b/frontend/src/lib/shopping/RecipeReferencePanel.svelte @@ -0,0 +1,62 @@ + + + diff --git a/frontend/src/lib/shopping/ShoppingChecklist.svelte b/frontend/src/lib/shopping/ShoppingChecklist.svelte new file mode 100644 index 0000000..d6a277d --- /dev/null +++ b/frontend/src/lib/shopping/ShoppingChecklist.svelte @@ -0,0 +1,92 @@ + + +{#if uncheckedItems.length > 0} +
+ {#each uncheckedItems as item (item.id)} + + {/each} +
+{:else if totalItems > 0} +

+ Alles erledigt! +

+{/if} + +
+ +
+ +{#if showFilteredStaples && filteredStaplesCount > 0} +
+

+ {filteredStaplesCount} Grundzutaten ausgeblendet · + Vorrat bearbeiten +

+
+{/if} + +{#if checkedItems.length > 0} +
+

+ Abgehakt ({checkedCount}) +

+
+ {#each checkedItems as item (item.id)} + + {/each} +
+
+{/if} diff --git a/frontend/src/lib/shopping/ShoppingHeader.svelte b/frontend/src/lib/shopping/ShoppingHeader.svelte new file mode 100644 index 0000000..c2457cb --- /dev/null +++ b/frontend/src/lib/shopping/ShoppingHeader.svelte @@ -0,0 +1,67 @@ + + +
+
+

+ Einkaufsliste +

+ + {#if isPlanner && weekPlanId} +
{ + generating = true; + return async ({ update }) => { + await update(); + generating = false; + }; + }}> + + +
+ {/if} +
+ + {#if hasShoppingList} +

+ {remainingCount} Artikel übrig · {checkedCount} abgehakt + {#if formattedTime} + · erstellt {formattedTime} + {/if} +

+ {/if} +
diff --git a/frontend/src/lib/shopping/ShoppingHeader.test.ts b/frontend/src/lib/shopping/ShoppingHeader.test.ts new file mode 100644 index 0000000..f7d7fc6 --- /dev/null +++ b/frontend/src/lib/shopping/ShoppingHeader.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import ShoppingHeader from './ShoppingHeader.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('ShoppingHeader', () => { + const baseProps = { + totalItems: 0, + checkedCount: 0, + generatedAt: null, + weekPlanId: null, + isPlanner: false, + hasShoppingList: false + }; + + it('renders the heading', () => { + render(ShoppingHeader, { props: baseProps }); + expect(screen.getByText('Einkaufsliste')).toBeInTheDocument(); + }); + + it('shows counts when hasShoppingList is true', () => { + render(ShoppingHeader, { + props: { ...baseProps, totalItems: 5, checkedCount: 2, hasShoppingList: true } + }); + expect(screen.getByText(/3 Artikel übrig/)).toBeInTheDocument(); + expect(screen.getByText(/2 abgehakt/)).toBeInTheDocument(); + }); + + it('does not show counts when hasShoppingList is false', () => { + render(ShoppingHeader, { props: { ...baseProps, hasShoppingList: false } }); + expect(screen.queryByText(/Artikel übrig/)).not.toBeInTheDocument(); + }); + + it('shows generate button for planner with weekPlanId', () => { + render(ShoppingHeader, { + props: { ...baseProps, isPlanner: true, weekPlanId: 'plan-1' } + }); + expect(screen.getByRole('button', { name: /Liste generieren/i })).toBeInTheDocument(); + }); + + it('shows regenerate button when planner already has a list', () => { + render(ShoppingHeader, { + props: { ...baseProps, isPlanner: true, weekPlanId: 'plan-1', hasShoppingList: true, totalItems: 3, checkedCount: 0 } + }); + expect(screen.getByRole('button', { name: /Neu generieren/i })).toBeInTheDocument(); + }); + + it('hides generate button for non-planner', () => { + render(ShoppingHeader, { + props: { ...baseProps, isPlanner: false, weekPlanId: 'plan-1' } + }); + expect(screen.queryByRole('button', { name: /generieren/i })).not.toBeInTheDocument(); + }); + + it('hides generate button when weekPlanId is null', () => { + render(ShoppingHeader, { + props: { ...baseProps, isPlanner: true, weekPlanId: null } + }); + expect(screen.queryByRole('button', { name: /generieren/i })).not.toBeInTheDocument(); + }); + + it('shows formatted timestamp when generatedAt is provided', () => { + render(ShoppingHeader, { + props: { ...baseProps, hasShoppingList: true, generatedAt: '2026-04-06T10:30:00Z', totalItems: 1, checkedCount: 0 } + }); + expect(screen.getByText(/erstellt/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/(app)/shopping/+page.server.ts b/frontend/src/routes/(app)/shopping/+page.server.ts new file mode 100644 index 0000000..99c1efc --- /dev/null +++ b/frontend/src/routes/(app)/shopping/+page.server.ts @@ -0,0 +1,86 @@ +import type { PageServerLoad, Actions } from './$types'; +import { apiClient } from '$lib/server/api'; +import { getWeekStart } from '$lib/planner/week'; + +export const load: PageServerLoad = async ({ fetch, url }) => { + const weekParam = url.searchParams.get('week'); + const weekStart = weekParam ?? getWeekStart(new Date()); + + const api = apiClient(fetch); + + const [shoppingResult, weekPlanResult] = await Promise.all([ + api.GET('/v1/shopping-list', { + params: { query: { weekStart } } + }), + api.GET('/v1/week-plans', { + params: { query: { weekStart } } + }) + ]); + + return { + shoppingList: shoppingResult.data ?? null, + weekPlan: weekPlanResult.data ?? null, + weekStart + }; +}; + +export const actions: Actions = { + check: async ({ fetch, request }) => { + const formData = await request.formData(); + const listId = formData.get('listId') as string; + const itemId = formData.get('itemId') as string; + const isChecked = formData.get('isChecked') === 'true'; + + const api = apiClient(fetch); + const { data, error } = await api.PATCH('/v1/shopping-lists/{listId}/items/{itemId}', { + params: { path: { listId, itemId } }, + body: { isChecked } + }); + + if (error || !data) { + return { success: false, error: 'Status konnte nicht geändert werden.' }; + } + + return { success: true, item: data }; + }, + + addItem: async ({ fetch, request }) => { + const formData = await request.formData(); + const listId = formData.get('listId') as string; + const customName = formData.get('customName') as string; + const quantity = parseFloat(formData.get('quantity') as string) || 1; + const unit = (formData.get('unit') as string) ?? ''; + + const api = apiClient(fetch); + const { data, error } = await api.POST('/v1/shopping-lists/{id}/items', { + params: { path: { id: listId } }, + body: { customName, quantity, unit } + }); + + if (error || !data) { + return { success: false, error: 'Artikel konnte nicht hinzugefügt werden.' }; + } + + return { success: true, item: data }; + }, + + generate: async ({ fetch, request, locals }) => { + if (locals.benutzer?.rolle !== 'planer') { + return { success: false, error: 'Keine Berechtigung.' }; + } + + const formData = await request.formData(); + const weekPlanId = formData.get('weekPlanId') as string; + + const api = apiClient(fetch); + const { data, error } = await api.POST('/v1/week-plans/{id}/shopping-list', { + params: { path: { id: weekPlanId } } + }); + + if (error || !data) { + return { success: false, error: 'Einkaufsliste konnte nicht erstellt werden.' }; + } + + return { success: true }; + } +}; diff --git a/frontend/src/routes/(app)/shopping/+page.svelte b/frontend/src/routes/(app)/shopping/+page.svelte index 158e40e..f71c895 100644 --- a/frontend/src/routes/(app)/shopping/+page.svelte +++ b/frontend/src/routes/(app)/shopping/+page.svelte @@ -1 +1,139 @@ -

Einkaufsliste

+ + + +
+
+ +
+ +
+ {#if !weekPlan} + +
+

+ Noch kein Wochenplan für diese Woche. +

+ + Zum Wochenplaner + +
+ {:else if !shoppingList} + +
+

+ Einkaufsliste noch nicht erstellt. +

+ {#if isPlanner} +

+ Generiere die Liste aus dem Wochenplan. +

+ {:else} +

+ Der Planer muss die Liste zuerst erstellen. +

+ {/if} +
+ {:else} + + {/if} +
+
+ + + diff --git a/specs/frontend/j5-shopping-list.html b/specs/frontend/j5-shopping-list.html index fb6213f..3f8beea 100644 --- a/specs/frontend/j5-shopping-list.html +++ b/specs/frontend/j5-shopping-list.html @@ -132,7 +132,7 @@

J5 — Shopping list

-

Journey spec — Generate shopping list, real-time shared checklist

+

Journey spec — Generate shopping list, shared checklist

v1.0
@@ -143,12 +143,12 @@
J5
-

Generate shopping list

Merge ingredients, filter staples. Always live and shared with household.

C1 → D1 (always live) · Planner generates · All members add/remove/check off
+

Generate shopping list

Merge ingredients, filter staples. Shared with household.

C1 → D1 · Planner generates · All members add/remove/check off
-

Shopping list (live)

D1
+

Shopping list

D1
V1 Checklist with sources. Desktop: sidebar + topbar + two-column content — checklist on the left, a "This week's recipes" reference panel on the right that shows which recipes contributed which items. The panel is a page section (surface bg), not a card.
V1 · Checklist with sources — desktop: list left, recipe reference right
@@ -162,7 +162,7 @@
-
Shared with household · 2 members online
+
Shared with household
5 items remaining
@@ -188,7 +188,7 @@
Plan
📅Planner
📖Recipes
🛒Shopping
-
Shopping list
2 members online
+
Shopping list
Shared with household
@@ -237,20 +237,20 @@

D1 · Shopping list

-
/* Desktop: 224px sidebar + topbar (title + shared status badge) + 2-col content:
+      
/* Desktop: 224px sidebar + topbar (title + shared badge) + 2-col content:
  *   Left (flex:1, page bg): remaining count + checklist rows + "checked off" section + add custom
  *   Right (280px, surface bg): recipe reference cards + filtered staples list + edit staples link
  * Recipe reference panel: page-section with surface bg, not a floating card.
  * Mobile: full-width checklist + shared banner + bottom tabs.
- * Real-time sync: is_checked updates broadcast to all connected clients.
+ * Sync model: server-authoritative — check/uncheck persists via form action, other users see changes on page refresh.
  * Both roles: planner + member can view and check off. Only planner can regenerate. */
- - + +
ElementValueNotes
Desktop
Checklist areaflex:1, page bg, 20px 24px paddingRemaining items + divider + checked items
Recipe reference280px, surface bg, border-leftRecipe name + day + ingredient count. Filtered staples below.
Shared state
Banner (mobile)blue-tint, blue dot, radius-lg"Shared · N online"
Badge (desktop)blue-tint pill in topbarCompact: dot + "N members online"
Banner (mobile)blue-tint, blue dot, radius-lg"Shared with household"
Badge (desktop)blue-tint pill in topbarCompact: dot + "Shared with household"
@@ -269,22 +269,23 @@

1. Journey flow

/* J5 flow
- * C1 (week confirmed) → D1 (shopping list, always live).
+ * C1 (week confirmed) → D1 (shopping list).
  * Actor: Planner generates the list. All household members shop (view, check off, add items).
  * Preconditions: J1 (recipes exist) + J2 (week is planned) for generating a list.
  *                J6 (household setup) for shared access.
- * There is NO draft/publish workflow — the list is always live. */
+ * Sync model: server-authoritative. Changes persist via form actions. + * Other users see updates on page refresh (no WebSocket/SSE). */ -

2. Screen D1 — Shopping list (live shared)

+

2. Screen D1 — Shopping list

/* Mobile layout:
  *   topbar (title + settings icon)
- *   + blue-tint shared banner ("Shared with household · N members online", blue dot)
+ *   + blue-tint shared banner ("Shared with household", blue dot)
  *   + checklist (unchecked items, then checked items below divider)
  *   + "+ Add custom item" link (blue-dark, centred)
  *   + bottom tabs (Planner | Recipes | Shopping [active] | Settings)
  *
  * Desktop layout:
- *   sidebar (224px, dsb) + topbar (dtb: title + blue-tint "N members online" badge)
+ *   sidebar (224px, dsb) + topbar (dtb: title + blue-tint "Shared with household" badge)
  *   + split content area:
  *     Left: checklist (flex:1, page bg, 20px 24px padding)
  *       - eyebrow "N items remaining · N checked off"
@@ -319,15 +320,14 @@
       
  • Filtered staples are listed in the recipe reference panel (desktop) for transparency
  • -

    4. Real-time sync

    -
    /* Real-time rules:
    - * - is_checked updates broadcast to ALL connected clients instantly
    - * - "N members online" indicator shows who is currently viewing the shopping list
    - * - Prevents double-buying when multiple family members shop simultaneously or at different times
    - * - Blue accent colour for all shared-state UI:
    - *     Mobile: blue-tint banner with blue dot
    - *     Desktop: blue-tint badge in topbar with blue dot
    - * - WebSocket or SSE for real-time — implementation choice, but must be instant */
    +

    4. Sync model

    +
    /* Sync rules:
    + * - Server-authoritative: all mutations (check, uncheck, add) go through form actions / API calls
    + * - No WebSocket or SSE — other users see changes on page refresh
    + * - Each check/uncheck is a single server round-trip (form action with use:enhance)
    + * - Blue accent colour for shared-state UI:
    + *     Mobile: blue-tint banner with blue dot ("Shared with household")
    + *     Desktop: blue-tint badge in topbar with blue dot ("Shared with household") */

    5. Custom items

      @@ -352,22 +352,20 @@ * UPDATE shopping_list_item * SET is_checked = true/false * WHERE id = :item_id - * → broadcast change to all connected clients via real-time channel * * Add custom: * INSERT INTO shopping_list_item (name, quantity, is_custom, is_checked, shopping_list_id) - * VALUES (:name, :quantity, true, false, :list_id) - * → broadcast new item to all connected clients */
    + * VALUES (:name, :quantity, true, false, :list_id) */

    7. Design constraints

      -
    • List is ALWAYS live — no draft/publish workflow, no approval step
    • +
    • List is shared — all household members see the same list on refresh
    • Both planner and member can: view, check off, add custom items, remove items
    • Only planner can: generate list, regenerate list
    • "Edit staples" link navigates to D3 (same component as A3 — build once, reference from two entry points)
    • CalDAV export is future scope (E3) — do not build in v1
    • Recipe reference panel is a page section with surface bg — NOT a floating card
    • -
    • Blue accent colour is reserved for shared/collaborative state indicators
    • +
    • Blue accent colour for shared-state banner/badge ("Shared with household")
    • Checked items must visually separate from unchecked via a divider and "Checked off" label
    @@ -375,7 +373,7 @@
    /* Precondition chain:
      * J1 (recipes exist) — cannot generate a shopping list without recipes
      * J2 (week is planned) — cannot generate a shopping list without planned meals
    - * J6 (household setup) — required for shared access (multiple members online)
    + * J6 (household setup) — required for shared access
      *
      * If no meals are planned: show empty state on D1 with prompt to plan the week first
      * If no household members: list works for solo planner, shared banner is hidden */