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:
dbastrikin
2026-03-26 14:04:58 +02:00
parent 16d944c80e
commit 8d33a4eb30
40 changed files with 2167 additions and 74 deletions

View File

@@ -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}.

View File

@@ -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())

View File

@@ -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,