package userproduct import ( "context" "encoding/json" "errors" "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" ) // UserProductRepository is the data layer interface used by Handler. type UserProductRepository interface { List(ctx context.Context, userID string) ([]*UserProduct, error) Create(ctx context.Context, userID string, req CreateRequest) (*UserProduct, error) 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 productUpserter ProductUpserter } // NewHandler creates a new Handler. func NewHandler(repo UserProductRepository, productUpserter ProductUpserter) *Handler { return &Handler{repo: repo, productUpserter: productUpserter} } // List handles GET /user-products. func (handler *Handler) List(responseWriter http.ResponseWriter, request *http.Request) { userID := middleware.UserIDFromCtx(request.Context()) userProducts, listError := handler.repo.List(request.Context(), userID) if listError != nil { slog.ErrorContext(request.Context(), "list user products", "user_id", userID, "err", listError) writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list user products") return } if userProducts == nil { userProducts = []*UserProduct{} } writeJSON(responseWriter, http.StatusOK, userProducts) } // Create handles POST /user-products. func (handler *Handler) Create(responseWriter http.ResponseWriter, request *http.Request) { 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") return } if req.Name == "" { writeErrorJSON(responseWriter, request, http.StatusBadRequest, "name is required") return } // 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(requestContext, "create user product", "user_id", userID, "err", createError) writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user product") return } writeJSON(responseWriter, http.StatusCreated, userProduct) } // BatchCreate handles POST /user-products/batch. func (handler *Handler) BatchCreate(responseWriter http.ResponseWriter, request *http.Request) { userID := middleware.UserIDFromCtx(request.Context()) var items []CreateRequest if decodeError := json.NewDecoder(request.Body).Decode(&items); decodeError != nil { writeErrorJSON(responseWriter, request, http.StatusBadRequest, "invalid request body") return } if len(items) == 0 { writeJSON(responseWriter, http.StatusCreated, []*UserProduct{}) return } userProducts, batchError := handler.repo.BatchCreate(request.Context(), userID, items) if batchError != nil { slog.ErrorContext(request.Context(), "batch create user products", "user_id", userID, "err", batchError) writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user products") return } writeJSON(responseWriter, http.StatusCreated, userProducts) } // Update handles PUT /user-products/{id}. func (handler *Handler) Update(responseWriter http.ResponseWriter, request *http.Request) { userID := middleware.UserIDFromCtx(request.Context()) id := chi.URLParam(request, "id") var req UpdateRequest if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil { writeErrorJSON(responseWriter, request, http.StatusBadRequest, "invalid request body") return } userProduct, updateError := handler.repo.Update(request.Context(), id, userID, req) if errors.Is(updateError, ErrNotFound) { writeErrorJSON(responseWriter, request, http.StatusNotFound, "user product not found") return } if updateError != nil { slog.ErrorContext(request.Context(), "update user product", "id", id, "err", updateError) writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to update user product") return } 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()) id := chi.URLParam(request, "id") if deleteError := handler.repo.Delete(request.Context(), id, userID); deleteError != nil { if errors.Is(deleteError, ErrNotFound) { writeErrorJSON(responseWriter, request, http.StatusNotFound, "user product not found") return } slog.ErrorContext(request.Context(), "delete user product", "id", id, "err", deleteError) writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to delete user product") return } responseWriter.WriteHeader(http.StatusNoContent) } 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) }