package product import ( "context" "encoding/json" "log/slog" "net/http" "strconv" "github.com/go-chi/chi/v5" "github.com/food-ai/backend/internal/infra/middleware" ) // ProductSearcher is the data layer interface used by Handler for search. type ProductSearcher interface { Search(ctx context.Context, query string, limit int) ([]*Product, error) GetByBarcode(ctx context.Context, barcode string) (*Product, error) UpsertByBarcode(ctx context.Context, catalogProduct *Product) (*Product, error) } // OpenFoodFactsClient fetches product data from Open Food Facts. type OpenFoodFactsClient interface { Fetch(requestContext context.Context, barcode string) (*Product, error) } // Handler handles catalog product HTTP requests. type Handler struct { repo ProductSearcher openFoodFacts OpenFoodFactsClient } // NewHandler creates a new Handler. func NewHandler(repo ProductSearcher, openFoodFacts OpenFoodFactsClient) *Handler { return &Handler{repo: repo, openFoodFacts: openFoodFacts} } // Search handles GET /products/search?q=&limit=10. func (handler *Handler) Search(responseWriter http.ResponseWriter, request *http.Request) { query := request.URL.Query().Get("q") if query == "" { responseWriter.Header().Set("Content-Type", "application/json") _, _ = responseWriter.Write([]byte("[]")) return } limit := 10 if limitStr := request.URL.Query().Get("limit"); limitStr != "" { if parsedLimit, parseError := strconv.Atoi(limitStr); parseError == nil && parsedLimit > 0 && parsedLimit <= 50 { limit = parsedLimit } } products, searchError := handler.repo.Search(request.Context(), query, limit) if searchError != nil { slog.ErrorContext(request.Context(), "search catalog products", "q", query, "err", searchError) responseWriter.Header().Set("Content-Type", "application/json") responseWriter.WriteHeader(http.StatusInternalServerError) _, _ = responseWriter.Write([]byte(`{"error":"search failed"}`)) return } if products == nil { products = []*Product{} } responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(products) } // GetByBarcode handles GET /products/barcode/{barcode}. // Checks the database first; on miss, fetches from Open Food Facts and caches the result. func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request *http.Request) { barcode := chi.URLParam(request, "barcode") if barcode == "" { writeErrorJSON(responseWriter, request, http.StatusBadRequest, "barcode is required") return } // Check the local catalog first. catalogProduct, lookupError := handler.repo.GetByBarcode(request.Context(), barcode) if lookupError != nil { slog.ErrorContext(request.Context(), "lookup product by barcode", "barcode", barcode, "err", lookupError) writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "lookup failed") return } if catalogProduct != nil { writeJSON(responseWriter, http.StatusOK, catalogProduct) return } // Not in catalog — fetch from Open Food Facts. fetchedProduct, fetchError := handler.openFoodFacts.Fetch(request.Context(), barcode) if fetchError != nil { slog.WarnContext(request.Context(), "open food facts fetch failed", "barcode", barcode, "err", fetchError) writeErrorJSON(responseWriter, request, http.StatusNotFound, "product not found") return } // Persist the fetched product so subsequent lookups are served from the DB. savedProduct, upsertError := handler.repo.UpsertByBarcode(request.Context(), fetchedProduct) if upsertError != nil { slog.WarnContext(request.Context(), "upsert product from open food facts", "barcode", barcode, "err", upsertError) // Return the fetched data even if we could not cache it. writeJSON(responseWriter, http.StatusOK, fetchedProduct) return } writeJSON(responseWriter, http.StatusOK, savedProduct) } type errorResponse struct { Error string `json:"error"` RequestID string `json:"request_id,omitempty"` } func writeErrorJSON(responseWriter http.ResponseWriter, request *http.Request, status int, msg string) { responseWriter.Header().Set("Content-Type", "application/json") responseWriter.WriteHeader(status) _ = json.NewEncoder(responseWriter).Encode(errorResponse{ Error: msg, RequestID: middleware.RequestIDFromCtx(request.Context()), }) } func writeJSON(responseWriter http.ResponseWriter, status int, value any) { responseWriter.Header().Set("Content-Type", "application/json") responseWriter.WriteHeader(status) _ = json.NewEncoder(responseWriter).Encode(value) }