feat: Phase 8 Feedback UI + API endpoints

- New backend/app/api/feedback.py: GET/POST for meal_plan_item feedback
- MealDetail.tsx: star rating, never-suggest checkbox, reason dropdown,
  free-text comments, displays saved feedback
- frontend/src/api/index.ts + types: feedback API + TypeScript interface
- backend/app/schemas/__init__.py: model_validator maps qty→quantity for
  RecipeIngredient (fixes Pydantic validation on recipe JSONB)
- docs/HANDOFF.md: mark Phase 8 complete, update file map and date
This commit is contained in:
2026-05-14 09:54:25 -07:00
parent e618b2bd5a
commit f7ed10651b
8 changed files with 353 additions and 26 deletions
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import FamilyProfile, Feedback, MealPlanItem
from app.schemas import FeedbackCreate, FeedbackResponse
from app.security import require_session
router = APIRouter()
@router.get("/{meal_plan_item_id}", response_model=FeedbackResponse | None)
def get_feedback(
meal_plan_item_id: UUID,
db: Session = Depends(get_db),
family_id: str = Depends(require_session),
):
"""Get existing feedback for a meal plan item."""
item = db.query(MealPlanItem).filter(MealPlanItem.id == meal_plan_item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
feedback = (
db.query(Feedback)
.filter(
Feedback.meal_plan_item_id == meal_plan_item_id,
Feedback.family_profile_id == UUID(family_id),
)
.first()
)
return feedback
@router.post("", response_model=FeedbackResponse)
def create_feedback(
data: FeedbackCreate,
db: Session = Depends(get_db),
family_id: str = Depends(require_session),
):
"""Create or update feedback for a meal plan item."""
item = db.query(MealPlanItem).filter(
MealPlanItem.id == data.meal_plan_item_id
).first()
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
profile = db.query(FamilyProfile).filter(FamilyProfile.id == UUID(family_id)).first()
if not profile:
raise HTTPException(status_code=404, detail="Family profile not found")
existing = (
db.query(Feedback)
.filter(
Feedback.meal_plan_item_id == data.meal_plan_item_id,
Feedback.family_profile_id == UUID(family_id),
)
.first()
)
if existing:
existing.rating = data.rating
existing.never_suggest = data.never_suggest
existing.denial_reason = data.denial_reason.value if data.denial_reason else None
existing.feedback_text = data.feedback_text
db.commit()
db.refresh(existing)
return existing
feedback = Feedback(
family_profile_id=UUID(family_id),
meal_plan_item_id=data.meal_plan_item_id,
rating=data.rating,
never_suggest=data.never_suggest,
denial_reason=data.denial_reason.value if data.denial_reason else None,
feedback_text=data.feedback_text,
)
db.add(feedback)
db.commit()
db.refresh(feedback)
return feedback
+2
View File
@@ -34,6 +34,7 @@ from app.api import ingredients as ingredients_api
from app.api import recipes as recipes_api
from app.api import never_suggest as never_suggest_api
from app.api import meal_plans as meal_plans_api
from app.api import feedback as feedback_api
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
@@ -50,3 +51,4 @@ app.include_router(never_suggest_api.public_router)
app.include_router(never_suggest_api.admin_router)
app.include_router(meal_plans_api.admin_router)
app.include_router(meal_plans_api.public_router)
app.include_router(feedback_api.router, prefix="/api/feedback", tags=["feedback"])
+13 -2
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from typing import Optional, List, Any
from uuid import UUID
from datetime import date, datetime
@@ -122,10 +122,21 @@ class FamilyProfileUpdate(BaseModel):
class RecipeIngredient(BaseModel):
ingredient_id: Optional[UUID] = None
name: str
name: Optional[str] = None
quantity: Optional[float] = None
unit: Optional[str] = None
is_optional: bool = False
notes: Optional[str] = None
@model_validator(mode="before")
@classmethod
def _normalize(cls, data):
if not isinstance(data, dict):
return data
# JSONB stores qty; schema uses quantity
if "qty" in data and "quantity" not in data:
data["quantity"] = data.pop("qty")
return data
class RecipeBase(BaseModel):