fix(pantry): make public ingredient endpoint idempotent
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled

Backend:
- POST /api/ingredients now checks name_lower and aliases before inserting
- Returns existing ingredient on 409 instead of throwing error

Frontend:
- Removed fragile 409-recovery logic from Pantry.tsx handleAdd
- Added aliases field to Ingredient type for case-insensitive matching

Fixes pantry add for ingredients like 'Carrots' whose canonical name is 'Carrot'
This commit is contained in:
2026-05-18 17:16:57 -07:00
parent 54b3e785b3
commit 69acd70188
4 changed files with 39 additions and 8 deletions
+29 -1
View File
@@ -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
+1
View File
@@ -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 |
+8 -7
View File
@@ -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
}
}
+1
View File
@@ -100,6 +100,7 @@ export interface Ingredient {
id: string
name: string
name_lower: string
aliases: string[]
plural_name?: string
aisle?: string
typical_price?: number