Implement Recipe, Planning, Shopping, Pantry, and Admin domains
Outside-in TDD for all 5 remaining domains (128 tests total): - Recipe: CRUD, ingredients autocomplete/patch, tags, categories (27 tests) - Planning: week plans, slots, confirm, suggestions, variety score, cooking logs (24 tests) - Shopping: generate from plan, publish, check/add/remove items (15 tests) - Pantry: CRUD with expiry sorting (11 tests) - Admin: user management, password reset, audit logging (13 tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package com.recipeapp.shopping;
|
||||
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
public class ShoppingListController {
|
||||
|
||||
private final ShoppingService shoppingService;
|
||||
private final HouseholdResolver householdResolver;
|
||||
|
||||
public ShoppingListController(ShoppingService shoppingService, HouseholdResolver householdResolver) {
|
||||
this.shoppingService = shoppingService;
|
||||
this.householdResolver = householdResolver;
|
||||
}
|
||||
|
||||
@PostMapping("/v1/week-plans/{id}/shopping-list")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return shoppingService.generateFromPlan(householdId, id);
|
||||
}
|
||||
|
||||
@GetMapping("/v1/shopping-lists/{id}")
|
||||
public ShoppingListResponse getShoppingList(@PathVariable UUID id, Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return shoppingService.getShoppingList(householdId, id);
|
||||
}
|
||||
|
||||
@PostMapping("/v1/shopping-lists/{id}/publish")
|
||||
public PublishResponse publish(@PathVariable UUID id, Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return shoppingService.publish(householdId, id);
|
||||
}
|
||||
|
||||
@PatchMapping("/v1/shopping-lists/{listId}/items/{itemId}")
|
||||
public ShoppingListItemResponse checkItem(@PathVariable UUID listId,
|
||||
@PathVariable UUID itemId,
|
||||
@RequestBody CheckItemRequest request,
|
||||
Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
UUID userId = householdResolver.resolveUserId(principal.getName());
|
||||
return shoppingService.checkItem(householdId, listId, itemId, request, userId);
|
||||
}
|
||||
|
||||
@PostMapping("/v1/shopping-lists/{id}/items")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public ShoppingListItemResponse addItem(@PathVariable UUID id,
|
||||
@RequestBody AddItemRequest request,
|
||||
Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return shoppingService.addItem(householdId, id, request);
|
||||
}
|
||||
|
||||
@DeleteMapping("/v1/shopping-lists/{listId}/items/{itemId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteItem(@PathVariable UUID listId,
|
||||
@PathVariable UUID itemId,
|
||||
Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
shoppingService.deleteItem(householdId, listId, itemId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.recipeapp.shopping;
|
||||
|
||||
import com.recipeapp.shopping.entity.ShoppingListItem;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ShoppingListItemRepository extends JpaRepository<ShoppingListItem, UUID> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.recipeapp.shopping;
|
||||
|
||||
import com.recipeapp.shopping.entity.ShoppingList;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ShoppingListRepository extends JpaRepository<ShoppingList, UUID> {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.recipeapp.shopping;
|
||||
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ShoppingService {
|
||||
|
||||
ShoppingListResponse generateFromPlan(UUID householdId, UUID weekPlanId);
|
||||
|
||||
ShoppingListResponse getShoppingList(UUID householdId, UUID shoppingListId);
|
||||
|
||||
PublishResponse publish(UUID householdId, UUID shoppingListId);
|
||||
|
||||
ShoppingListItemResponse checkItem(UUID householdId, UUID listId, UUID itemId,
|
||||
CheckItemRequest request, UUID userId);
|
||||
|
||||
ShoppingListItemResponse addItem(UUID householdId, UUID shoppingListId, AddItemRequest request);
|
||||
|
||||
void deleteItem(UUID householdId, UUID listId, UUID itemId);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.recipeapp.shopping;
|
||||
|
||||
import com.recipeapp.auth.UserAccountRepository;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.planning.WeekPlanRepository;
|
||||
import com.recipeapp.planning.entity.WeekPlan;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import com.recipeapp.recipe.entity.RecipeIngredient;
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
import com.recipeapp.shopping.entity.ShoppingList;
|
||||
import com.recipeapp.shopping.entity.ShoppingListItem;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class ShoppingServiceImpl implements ShoppingService {
|
||||
|
||||
private final ShoppingListRepository shoppingListRepository;
|
||||
private final ShoppingListItemRepository shoppingListItemRepository;
|
||||
private final WeekPlanRepository weekPlanRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
|
||||
public ShoppingServiceImpl(ShoppingListRepository shoppingListRepository,
|
||||
ShoppingListItemRepository shoppingListItemRepository,
|
||||
WeekPlanRepository weekPlanRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
IngredientRepository ingredientRepository,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
this.shoppingListRepository = shoppingListRepository;
|
||||
this.shoppingListItemRepository = shoppingListItemRepository;
|
||||
this.weekPlanRepository = weekPlanRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShoppingListResponse generateFromPlan(UUID householdId, UUID weekPlanId) {
|
||||
WeekPlan weekPlan = weekPlanRepository.findById(weekPlanId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
|
||||
|
||||
if (!weekPlan.getHousehold().getId().equals(householdId)) {
|
||||
throw new ResourceNotFoundException("Week plan not found");
|
||||
}
|
||||
|
||||
var household = weekPlan.getHousehold();
|
||||
|
||||
ShoppingList shoppingList = new ShoppingList(household, weekPlan);
|
||||
shoppingList = shoppingListRepository.save(shoppingList);
|
||||
|
||||
// Aggregate ingredients across all slots/recipes
|
||||
// Key: ingredientId + unit -> merged data
|
||||
Map<String, MergedIngredient> merged = new LinkedHashMap<>();
|
||||
|
||||
for (var slot : weekPlan.getSlots()) {
|
||||
var recipe = slot.getRecipe();
|
||||
for (RecipeIngredient ri : recipe.getIngredients()) {
|
||||
Ingredient ingredient = ri.getIngredient();
|
||||
|
||||
// Filter out staples
|
||||
if (ingredient.isStaple()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = ingredient.getId().toString() + "|" + ri.getUnit();
|
||||
merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit()))
|
||||
.addQuantity(ri.getQuantity())
|
||||
.addRecipeId(recipe.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// Create shopping list items
|
||||
for (MergedIngredient mi : merged.values()) {
|
||||
ShoppingListItem item = new ShoppingListItem(
|
||||
shoppingList,
|
||||
mi.ingredient,
|
||||
null,
|
||||
mi.totalQuantity,
|
||||
mi.unit,
|
||||
mi.recipeIds.stream().distinct().toArray(UUID[]::new)
|
||||
);
|
||||
shoppingList.getItems().add(item);
|
||||
}
|
||||
|
||||
shoppingListRepository.save(shoppingList);
|
||||
|
||||
return toResponse(shoppingList);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public ShoppingListResponse getShoppingList(UUID householdId, UUID shoppingListId) {
|
||||
ShoppingList list = findList(householdId, shoppingListId);
|
||||
return toResponse(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublishResponse publish(UUID householdId, UUID shoppingListId) {
|
||||
ShoppingList list = findList(householdId, shoppingListId);
|
||||
|
||||
if (!"draft".equals(list.getStatus())) {
|
||||
throw new ValidationException("Shopping list is already published");
|
||||
}
|
||||
|
||||
list.setStatus("published");
|
||||
list.setPublishedAt(Instant.now());
|
||||
shoppingListRepository.save(list);
|
||||
|
||||
return new PublishResponse(list.getId(), list.getStatus(), list.getPublishedAt());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShoppingListItemResponse checkItem(UUID householdId, UUID listId, UUID itemId,
|
||||
CheckItemRequest request, UUID userId) {
|
||||
ShoppingList list = findList(householdId, listId);
|
||||
ShoppingListItem item = findItem(list, itemId);
|
||||
|
||||
item.setChecked(request.isChecked());
|
||||
|
||||
if (request.isChecked()) {
|
||||
UserAccount user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
item.setCheckedBy(user);
|
||||
} else {
|
||||
item.setCheckedBy(null);
|
||||
}
|
||||
|
||||
shoppingListItemRepository.save(item);
|
||||
return toItemResponse(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShoppingListItemResponse addItem(UUID householdId, UUID shoppingListId, AddItemRequest request) {
|
||||
ShoppingList list = findList(householdId, shoppingListId);
|
||||
|
||||
Ingredient ingredient = null;
|
||||
if (request.ingredientId() != null) {
|
||||
ingredient = ingredientRepository.findById(request.ingredientId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
|
||||
}
|
||||
|
||||
ShoppingListItem item = new ShoppingListItem(
|
||||
list,
|
||||
ingredient,
|
||||
request.customName(),
|
||||
request.quantity(),
|
||||
request.unit(),
|
||||
new UUID[0]
|
||||
);
|
||||
|
||||
item = shoppingListItemRepository.save(item);
|
||||
list.getItems().add(item);
|
||||
|
||||
return toItemResponse(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteItem(UUID householdId, UUID listId, UUID itemId) {
|
||||
ShoppingList list = findList(householdId, listId);
|
||||
|
||||
if ("published".equals(list.getStatus())) {
|
||||
throw new ValidationException("Cannot delete items from a published shopping list");
|
||||
}
|
||||
|
||||
ShoppingListItem item = findItem(list, itemId);
|
||||
list.getItems().remove(item);
|
||||
shoppingListItemRepository.delete(item);
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private ShoppingList findList(UUID householdId, UUID shoppingListId) {
|
||||
ShoppingList list = shoppingListRepository.findById(shoppingListId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Shopping list not found"));
|
||||
|
||||
if (!list.getHousehold().getId().equals(householdId)) {
|
||||
throw new ResourceNotFoundException("Shopping list not found");
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private ShoppingListItem findItem(ShoppingList list, UUID itemId) {
|
||||
return list.getItems().stream()
|
||||
.filter(i -> i.getId().equals(itemId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Shopping list item not found"));
|
||||
}
|
||||
|
||||
private ShoppingListResponse toResponse(ShoppingList list) {
|
||||
List<ShoppingListItemResponse> items = list.getItems().stream()
|
||||
.map(this::toItemResponse)
|
||||
.toList();
|
||||
|
||||
return new ShoppingListResponse(
|
||||
list.getId(),
|
||||
list.getWeekPlan().getId(),
|
||||
list.getStatus(),
|
||||
list.getPublishedAt(),
|
||||
items
|
||||
);
|
||||
}
|
||||
|
||||
private ShoppingListItemResponse toItemResponse(ShoppingListItem item) {
|
||||
String name;
|
||||
ShoppingListItemResponse.CategoryRef categoryRef = null;
|
||||
UUID ingredientId = null;
|
||||
|
||||
if (item.getIngredient() != null) {
|
||||
ingredientId = item.getIngredient().getId();
|
||||
name = item.getIngredient().getName();
|
||||
if (item.getIngredient().getCategory() != null) {
|
||||
categoryRef = new ShoppingListItemResponse.CategoryRef(
|
||||
item.getIngredient().getCategory().getId(),
|
||||
item.getIngredient().getCategory().getName()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
name = item.getCustomName();
|
||||
}
|
||||
|
||||
return new ShoppingListItemResponse(
|
||||
item.getId(),
|
||||
ingredientId,
|
||||
name,
|
||||
categoryRef,
|
||||
item.getQuantity(),
|
||||
item.getUnit(),
|
||||
item.isChecked(),
|
||||
item.getCheckedBy() != null ? item.getCheckedBy().getId() : null,
|
||||
item.getSourceRecipes() != null ? Arrays.asList(item.getSourceRecipes()) : List.of()
|
||||
);
|
||||
}
|
||||
|
||||
private static class MergedIngredient {
|
||||
final Ingredient ingredient;
|
||||
final String unit;
|
||||
BigDecimal totalQuantity = BigDecimal.ZERO;
|
||||
final List<UUID> recipeIds = new ArrayList<>();
|
||||
|
||||
MergedIngredient(Ingredient ingredient, String unit) {
|
||||
this.ingredient = ingredient;
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
MergedIngredient addQuantity(BigDecimal qty) {
|
||||
if (qty != null) {
|
||||
this.totalQuantity = this.totalQuantity.add(qty);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
MergedIngredient addRecipeId(UUID recipeId) {
|
||||
this.recipeIds.add(recipeId);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.recipeapp.shopping.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
public record AddItemRequest(
|
||||
UUID ingredientId,
|
||||
String customName,
|
||||
BigDecimal quantity,
|
||||
String unit
|
||||
) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.recipeapp.shopping.dto;
|
||||
|
||||
public record CheckItemRequest(boolean isChecked) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.recipeapp.shopping.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record PublishResponse(UUID id, String status, Instant publishedAt) {}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.recipeapp.shopping.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ShoppingListItemResponse(
|
||||
UUID id,
|
||||
UUID ingredientId,
|
||||
String name,
|
||||
CategoryRef category,
|
||||
BigDecimal quantity,
|
||||
String unit,
|
||||
boolean isChecked,
|
||||
UUID checkedBy,
|
||||
List<UUID> sourceRecipes
|
||||
) {
|
||||
public record CategoryRef(UUID id, String name) {}
|
||||
}
|
||||
@@ -0,0 +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,
|
||||
String status,
|
||||
Instant publishedAt,
|
||||
List<ShoppingListItemResponse> items
|
||||
) {}
|
||||
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
|
||||
@Entity
|
||||
@Table(name = "shopping_list")
|
||||
public class ShoppingList {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "household_id", nullable = false)
|
||||
private Household household;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "week_plan_id", nullable = false)
|
||||
private WeekPlan weekPlan;
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
private String status = "draft";
|
||||
|
||||
@Column(name = "published_at")
|
||||
private Instant publishedAt;
|
||||
|
||||
@OneToMany(mappedBy = "shoppingList", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private List<ShoppingListItem> items = new ArrayList<>();
|
||||
|
||||
protected ShoppingList() {}
|
||||
|
||||
public ShoppingList(Household household, WeekPlan weekPlan) {
|
||||
this.household = household;
|
||||
this.weekPlan = weekPlan;
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public WeekPlan getWeekPlan() { return weekPlan; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public Instant getPublishedAt() { return publishedAt; }
|
||||
public void setPublishedAt(Instant publishedAt) { this.publishedAt = publishedAt; }
|
||||
public List<ShoppingListItem> getItems() { return items; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.recipeapp.shopping.entity;
|
||||
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import jakarta.persistence.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "shopping_list_item")
|
||||
public class ShoppingListItem {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "shopping_list_id", nullable = false)
|
||||
private ShoppingList shoppingList;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "ingredient_id")
|
||||
private Ingredient ingredient;
|
||||
|
||||
@Column(name = "custom_name", length = 200)
|
||||
private String customName;
|
||||
|
||||
@Column(precision = 8, scale = 2)
|
||||
private BigDecimal quantity;
|
||||
|
||||
@Column(length = 20)
|
||||
private String unit;
|
||||
|
||||
@Column(name = "is_checked", nullable = false)
|
||||
private boolean isChecked = false;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "checked_by")
|
||||
private UserAccount checkedBy;
|
||||
|
||||
@Column(name = "source_recipes", columnDefinition = "uuid[]")
|
||||
private UUID[] sourceRecipes;
|
||||
|
||||
protected ShoppingListItem() {}
|
||||
|
||||
public ShoppingListItem(ShoppingList shoppingList, Ingredient ingredient, String customName,
|
||||
BigDecimal quantity, String unit, UUID[] sourceRecipes) {
|
||||
this.shoppingList = shoppingList;
|
||||
this.ingredient = ingredient;
|
||||
this.customName = customName;
|
||||
this.quantity = quantity;
|
||||
this.unit = unit;
|
||||
this.sourceRecipes = sourceRecipes;
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public ShoppingList getShoppingList() { return shoppingList; }
|
||||
public Ingredient getIngredient() { return ingredient; }
|
||||
public String getCustomName() { return customName; }
|
||||
public void setCustomName(String customName) { this.customName = customName; }
|
||||
public BigDecimal getQuantity() { return quantity; }
|
||||
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
|
||||
public String getUnit() { return unit; }
|
||||
public void setUnit(String unit) { this.unit = unit; }
|
||||
public boolean isChecked() { return isChecked; }
|
||||
public void setChecked(boolean checked) { isChecked = checked; }
|
||||
public UserAccount getCheckedBy() { return checkedBy; }
|
||||
public void setCheckedBy(UserAccount checkedBy) { this.checkedBy = checkedBy; }
|
||||
public UUID[] getSourceRecipes() { return sourceRecipes; }
|
||||
public void setSourceRecipes(UUID[] sourceRecipes) { this.sourceRecipes = sourceRecipes; }
|
||||
}
|
||||
Reference in New Issue
Block a user