diff --git a/api/meals.py b/api/meals.py index 59c7ac9..01519fb 100644 --- a/api/meals.py +++ b/api/meals.py @@ -233,6 +233,15 @@ async def create_meal_scoped( msg = meals.validate_meal(domain_meal) if msg: return error_response(request, 400, msg) + # Disallow empty extra ingredients (no product and no textual content) + for ing in domain_meal.extra_ingredients: + name = (ing.name or "").strip() + line = (ing.line or "").strip() + has_product = getattr(ing, "product", None) is not None or ( + getattr(ing, "product_id", None) is not None and getattr(ing, "product_id") >= 0 + ) + if not has_product and name == "" and line == "": + return error_response(request, 400, "Ingredient must include a name or line or a product") # Proactive validation via repositories hid = household["id"] # Validate members exist as users (do not require household membership here to preserve existing behavior/tests) @@ -368,6 +377,16 @@ async def update_meal_scoped( 400, f"Invalid recipe id(s): {', '.join(map(str, sorted(set(invalid_recipes))))}", ) + # Disallow empty extra ingredients on update as well + for ing in domain_meal.extra_ingredients: + name = (ing.name or "").strip() + line = (ing.line or "").strip() + has_product = getattr(ing, "product", None) is not None or ( + getattr(ing, "product_id", None) is not None and getattr(ing, "product_id") >= 0 + ) + if not has_product and name == "" and line == "": + return error_response(request, 400, "Ingredient must include a name or line or a product") + await meals.update_meal(conn, domain_meal) # Return updated state updated = await meals.find_meal_by_id_scoped(conn, meal_id, hid) diff --git a/api/recipes.py b/api/recipes.py index 017c95b..9c2d6ec 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -189,6 +189,15 @@ async def create_recipe( ): if not recipe.ingredients: return error_response(None, 400, "Recipe must have at least one ingredient") + # Disallow empty ingredients with neither product nor textual content + for ing in recipe.ingredients: + name = (ing.name or "").strip() + line = (ing.line or "").strip() + has_product = getattr(ing, "product", None) is not None or ( + getattr(ing, "product_id", None) is not None and getattr(ing, "product_id") >= 0 + ) + if not has_product and name == "" and line == "": + return error_response(None, 400, "Ingredient must include a name or line or a product") hid = household["id"] # Build domain model and insert r = recipes.Recipe( @@ -205,7 +214,10 @@ async def create_recipe( ingredient.recipe_id = r.id if ingredient.product: ingredient.product_id = ingredient.product.id - await ingredients_mod.insert_ingredient(conn, ingredient) + try: + await ingredients_mod.insert_ingredient(conn, ingredient) + except ValueError as e: + return error_response(None, 400, str(e)) response.headers["Location"] = f"/api/v1/households/{household['slug']}/recipes/{r.id}" created = MemberRef(id=user.id, display_name=user.display_name) out = RecipeOut( diff --git a/ingredients/repository.py b/ingredients/repository.py index 6a88fec..bb120d0 100644 --- a/ingredients/repository.py +++ b/ingredients/repository.py @@ -37,6 +37,13 @@ async def insert_ingredient(conn, ingredient: Ingredient): if ingredient.product_id is None or ingredient.product_id < 0: ingredient.product_id = None + # Disallow empty ingredients (no product and no textual content) + name = (ingredient.name or "").strip() + line = (ingredient.line or "").strip() + has_product = ingredient.product_id is not None and ingredient.product_id >= 0 + if not has_product and name == "" and line == "": + raise ValueError("Ingredient must include a name or line or a product") + async with conn.execute( """ INSERT INTO Ingredient (name, line, preparation, unit, quantity, product_id, recipe_id, meal_id)