diff --git a/backend/app/api/ingredients.py b/backend/app/api/ingredients.py index 55d6432..d6174ca 100644 --- a/backend/app/api/ingredients.py +++ b/backend/app/api/ingredients.py @@ -57,9 +57,24 @@ def get_ingredient(ingredient_id: UUID, db: Session = Depends(get_db)) -> Ingred @public_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED) def create_ingredient_public(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient: + name_lower = payload.name.lower() + # idempotent: return existing if name or alias already known + existing = ( + db.query(Ingredient) + .filter( + or_( + Ingredient.name_lower == name_lower, + Ingredient.aliases.any(payload.name.lower()), + ) + ) + .first() + ) + if existing: + return existing + row = Ingredient( name=payload.name, - name_lower=payload.name.lower(), + name_lower=name_lower, aliases=payload.aliases, aisle=payload.aisle, unit=payload.unit, @@ -70,6 +85,19 @@ def create_ingredient_public(payload: IngredientCreate, db: Session = Depends(ge db.commit() except IntegrityError: db.rollback() + # race-window safety: re-check after rollback + existing = ( + db.query(Ingredient) + .filter( + or_( + Ingredient.name_lower == name_lower, + Ingredient.aliases.any(payload.name.lower()), + ) + ) + .first() + ) + if existing: + return existing raise HTTPException(status_code=409, detail="ingredient name already exists") db.refresh(row) return row diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36dfaf9..0d649a5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -299,6 +299,7 @@ Generate plan → Send proposal email (per-member tokens) | POST | `/api/pantry` | Add item to pantry | | DELETE | `/api/pantry/{id}` | Remove item from pantry | | POST | `/api/feedback` | Submit meal feedback | +| POST | `/api/ingredients` | Create or resolve ingredient (public, idempotent) | | GET | `/api/recipes` | Search recipes | | POST | `/api/admin/scrape` | Trigger grocery scrape (admin) | | GET | `/api/admin/logs` | Get scraper logs | diff --git a/frontend/src/pages/Pantry.tsx b/frontend/src/pages/Pantry.tsx index be9f88d..fb1c3a0 100644 --- a/frontend/src/pages/Pantry.tsx +++ b/frontend/src/pages/Pantry.tsx @@ -31,10 +31,15 @@ export default function Pantry() { queryFn: () => mealPlannerApi.recipes.listIngredients().then(r => r.data), }) + function _ingredientMatchesName(ing: Ingredient, search: string): boolean { + const s = search.toLowerCase() + return ing.name.toLowerCase() === s || (ing.aliases ?? []).some(a => a.toLowerCase() === s) + } + /* fuzzy match existing ingredient */ const matchedIngredient = ingredientName.trim().length > 0 ? ingredients?.find( - ing => ing.name.toLowerCase() === ingredientName.trim().toLowerCase() + ing => _ingredientMatchesName(ing, ingredientName.trim()) ) : undefined @@ -86,12 +91,8 @@ export default function Pantry() { ingredientId = created?.id // refresh ingredient list so next add sees it await queryClient.invalidateQueries({ queryKey: ['ingredients'] }) - } catch (err: any) { - if (err?.response?.status === 409) { - showToast.error('Ingredient name already exists') - } else { - showToast.error('Failed to create ingredient') - } + } catch { + showToast.error('Failed to create ingredient') return } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index fd54f0c..3a44e69 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -100,6 +100,7 @@ export interface Ingredient { id: string name: string name_lower: string + aliases: string[] plural_name?: string aisle?: string typical_price?: number