Public Access
fix(pantry): make public ingredient endpoint idempotent
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:
@@ -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)
|
@public_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED)
|
||||||
def create_ingredient_public(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient:
|
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(
|
row = Ingredient(
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
name_lower=payload.name.lower(),
|
name_lower=name_lower,
|
||||||
aliases=payload.aliases,
|
aliases=payload.aliases,
|
||||||
aisle=payload.aisle,
|
aisle=payload.aisle,
|
||||||
unit=payload.unit,
|
unit=payload.unit,
|
||||||
@@ -70,6 +85,19 @@ def create_ingredient_public(payload: IngredientCreate, db: Session = Depends(ge
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
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")
|
raise HTTPException(status_code=409, detail="ingredient name already exists")
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return row
|
return row
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ Generate plan → Send proposal email (per-member tokens)
|
|||||||
| POST | `/api/pantry` | Add item to pantry |
|
| POST | `/api/pantry` | Add item to pantry |
|
||||||
| DELETE | `/api/pantry/{id}` | Remove item from pantry |
|
| DELETE | `/api/pantry/{id}` | Remove item from pantry |
|
||||||
| POST | `/api/feedback` | Submit meal feedback |
|
| POST | `/api/feedback` | Submit meal feedback |
|
||||||
|
| POST | `/api/ingredients` | Create or resolve ingredient (public, idempotent) |
|
||||||
| GET | `/api/recipes` | Search recipes |
|
| GET | `/api/recipes` | Search recipes |
|
||||||
| POST | `/api/admin/scrape` | Trigger grocery scrape (admin) |
|
| POST | `/api/admin/scrape` | Trigger grocery scrape (admin) |
|
||||||
| GET | `/api/admin/logs` | Get scraper logs |
|
| GET | `/api/admin/logs` | Get scraper logs |
|
||||||
|
|||||||
@@ -31,10 +31,15 @@ export default function Pantry() {
|
|||||||
queryFn: () => mealPlannerApi.recipes.listIngredients().then(r => r.data),
|
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 */
|
/* fuzzy match existing ingredient */
|
||||||
const matchedIngredient = ingredientName.trim().length > 0
|
const matchedIngredient = ingredientName.trim().length > 0
|
||||||
? ingredients?.find(
|
? ingredients?.find(
|
||||||
ing => ing.name.toLowerCase() === ingredientName.trim().toLowerCase()
|
ing => _ingredientMatchesName(ing, ingredientName.trim())
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
@@ -86,12 +91,8 @@ export default function Pantry() {
|
|||||||
ingredientId = created?.id
|
ingredientId = created?.id
|
||||||
// refresh ingredient list so next add sees it
|
// refresh ingredient list so next add sees it
|
||||||
await queryClient.invalidateQueries({ queryKey: ['ingredients'] })
|
await queryClient.invalidateQueries({ queryKey: ['ingredients'] })
|
||||||
} catch (err: any) {
|
} catch {
|
||||||
if (err?.response?.status === 409) {
|
showToast.error('Failed to create ingredient')
|
||||||
showToast.error('Ingredient name already exists')
|
|
||||||
} else {
|
|
||||||
showToast.error('Failed to create ingredient')
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export interface Ingredient {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
name_lower: string
|
name_lower: string
|
||||||
|
aliases: string[]
|
||||||
plural_name?: string
|
plural_name?: string
|
||||||
aisle?: string
|
aisle?: string
|
||||||
typical_price?: number
|
typical_price?: number
|
||||||
|
|||||||
Reference in New Issue
Block a user