feat: product search screen with catalog add and success feedback
- Add product search screen (/products/search) as primary add flow; "Add" button on products list opens search, manual entry remains as fallback - Add to shelf bottom sheet with AnimatedSwitcher success view (green checkmark) and SnackBar confirmation on the search screen via onAdded callback - Manual add (AddProductScreen) shows SnackBar on success before popping back - Extend AddProductScreen with optional nutrition fields (calories, protein, fat, carbs, fiber); auto-fills from catalog selection and auto-expands section - Auto-upsert catalog product on backend when nutrition data is provided without a primary_product_id, linking the user product to the catalog - Add fiber_per_100g field to CatalogProduct model and CreateRequest - Add 16 new L10n keys across all 12 locales (addProduct, addManually, searchProducts, quantity, storageDays, addToShelf, nutritionOptional, calories, protein, fat, carbs, fiber, productAddedToShelf, etc.) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,7 +45,7 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) {
|
||||
productRepository := product.NewRepository(pool)
|
||||
openFoodFactsClient := product.NewOpenFoodFacts()
|
||||
productHandler := product.NewHandler(productRepository, openFoodFactsClient)
|
||||
userProductHandler := userproduct.NewHandler(userProductRepository)
|
||||
userProductHandler := userproduct.NewHandler(userProductRepository, productRepository)
|
||||
|
||||
// Kafka producer
|
||||
kafkaProducer, kafkaProducerError := newKafkaProducer(appConfig)
|
||||
|
||||
@@ -28,6 +28,14 @@ type CreateRequest struct {
|
||||
Unit string `json:"unit"`
|
||||
Category *string `json:"category"`
|
||||
StorageDays int `json:"storage_days"`
|
||||
// Optional nutrition fields per 100g.
|
||||
// When provided and primary_product_id is absent, the backend upserts a catalog
|
||||
// product and links it automatically.
|
||||
CaloriesPer100g *float64 `json:"calories_per_100g"`
|
||||
ProteinPer100g *float64 `json:"protein_per_100g"`
|
||||
FatPer100g *float64 `json:"fat_per_100g"`
|
||||
CarbsPer100g *float64 `json:"carbs_per_100g"`
|
||||
FiberPer100g *float64 `json:"fiber_per_100g"`
|
||||
}
|
||||
|
||||
// UpdateRequest is the body for PUT /user-products/{id}.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/domain/product"
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -18,16 +19,24 @@ type UserProductRepository interface {
|
||||
BatchCreate(ctx context.Context, userID string, items []CreateRequest) ([]*UserProduct, error)
|
||||
Update(ctx context.Context, id, userID string, req UpdateRequest) (*UserProduct, error)
|
||||
Delete(ctx context.Context, id, userID string) error
|
||||
DeleteAll(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
// ProductUpserter creates or updates a catalog product entry.
|
||||
// Implemented by product.Repository — defined here to avoid import cycles.
|
||||
type ProductUpserter interface {
|
||||
Upsert(ctx context.Context, catalogProduct *product.Product) (*product.Product, error)
|
||||
}
|
||||
|
||||
// Handler handles /user-products HTTP requests.
|
||||
type Handler struct {
|
||||
repo UserProductRepository
|
||||
repo UserProductRepository
|
||||
productUpserter ProductUpserter
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(repo UserProductRepository) *Handler {
|
||||
return &Handler{repo: repo}
|
||||
func NewHandler(repo UserProductRepository, productUpserter ProductUpserter) *Handler {
|
||||
return &Handler{repo: repo, productUpserter: productUpserter}
|
||||
}
|
||||
|
||||
// List handles GET /user-products.
|
||||
@@ -47,7 +56,8 @@ func (handler *Handler) List(responseWriter http.ResponseWriter, request *http.R
|
||||
|
||||
// Create handles POST /user-products.
|
||||
func (handler *Handler) Create(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(request.Context())
|
||||
requestContext := request.Context()
|
||||
userID := middleware.UserIDFromCtx(requestContext)
|
||||
var req CreateRequest
|
||||
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil {
|
||||
writeErrorJSON(responseWriter, request, http.StatusBadRequest, "invalid request body")
|
||||
@@ -58,9 +68,40 @@ func (handler *Handler) Create(responseWriter http.ResponseWriter, request *http
|
||||
return
|
||||
}
|
||||
|
||||
userProduct, createError := handler.repo.Create(request.Context(), userID, req)
|
||||
// Resolve effective primary_product_id (accept legacy mapping_id alias).
|
||||
primaryProductID := req.PrimaryProductID
|
||||
if primaryProductID == nil {
|
||||
primaryProductID = req.MappingID
|
||||
}
|
||||
|
||||
// When nutrition data is provided without an existing catalog link, upsert a catalog
|
||||
// product so the nutrition info is not silently discarded.
|
||||
hasNutrition := req.CaloriesPer100g != nil || req.ProteinPer100g != nil ||
|
||||
req.FatPer100g != nil || req.CarbsPer100g != nil || req.FiberPer100g != nil
|
||||
if hasNutrition && primaryProductID == nil {
|
||||
catalogProduct := &product.Product{
|
||||
CanonicalName: req.Name,
|
||||
Category: req.Category,
|
||||
DefaultUnit: &req.Unit,
|
||||
CaloriesPer100g: req.CaloriesPer100g,
|
||||
ProteinPer100g: req.ProteinPer100g,
|
||||
FatPer100g: req.FatPer100g,
|
||||
CarbsPer100g: req.CarbsPer100g,
|
||||
FiberPer100g: req.FiberPer100g,
|
||||
StorageDays: &req.StorageDays,
|
||||
}
|
||||
savedProduct, upsertError := handler.productUpserter.Upsert(requestContext, catalogProduct)
|
||||
if upsertError != nil {
|
||||
slog.WarnContext(requestContext, "upsert catalog product on manual add", "name", req.Name, "err", upsertError)
|
||||
// Graceful degradation: continue without catalog link.
|
||||
} else {
|
||||
req.PrimaryProductID = &savedProduct.ID
|
||||
}
|
||||
}
|
||||
|
||||
userProduct, createError := handler.repo.Create(requestContext, userID, req)
|
||||
if createError != nil {
|
||||
slog.ErrorContext(request.Context(), "create user product", "user_id", userID, "err", createError)
|
||||
slog.ErrorContext(requestContext, "create user product", "user_id", userID, "err", createError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user product")
|
||||
return
|
||||
}
|
||||
@@ -113,6 +154,17 @@ func (handler *Handler) Update(responseWriter http.ResponseWriter, request *http
|
||||
writeJSON(responseWriter, http.StatusOK, userProduct)
|
||||
}
|
||||
|
||||
// DeleteAll handles DELETE /user-products.
|
||||
func (handler *Handler) DeleteAll(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(request.Context())
|
||||
if deleteError := handler.repo.DeleteAll(request.Context(), userID); deleteError != nil {
|
||||
slog.ErrorContext(request.Context(), "delete all user products", "user_id", userID, "err", deleteError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to delete all user products")
|
||||
return
|
||||
}
|
||||
responseWriter.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Delete handles DELETE /user-products/{id}.
|
||||
func (handler *Handler) Delete(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(request.Context())
|
||||
|
||||
@@ -106,6 +106,16 @@ func (r *Repository) Update(requestContext context.Context, id, userID string, r
|
||||
return userProduct, scanError
|
||||
}
|
||||
|
||||
// DeleteAll removes all user products for the given user.
|
||||
func (r *Repository) DeleteAll(requestContext context.Context, userID string) error {
|
||||
_, execError := r.pool.Exec(requestContext,
|
||||
`DELETE FROM user_products WHERE user_id = $1`, userID)
|
||||
if execError != nil {
|
||||
return fmt.Errorf("delete all user products: %w", execError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a user product. Returns ErrNotFound if it does not exist or belongs to a different user.
|
||||
func (r *Repository) Delete(requestContext context.Context, id, userID string) error {
|
||||
tag, execError := r.pool.Exec(requestContext,
|
||||
|
||||
@@ -87,6 +87,7 @@ func NewRouter(
|
||||
r.Post("/", userProductHandler.Create)
|
||||
r.Post("/batch", userProductHandler.BatchCreate)
|
||||
r.Put("/{id}", userProductHandler.Update)
|
||||
r.Delete("/", userProductHandler.DeleteAll)
|
||||
r.Delete("/{id}", userProductHandler.Delete)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user