package handler import ( "errors" "net/http" "strconv" "time" "github.com/saveinmed/backend-go/internal/domain" "github.com/saveinmed/backend-go/internal/http/middleware" ) // CreateProduct godoc // @Summary Cadastro de produto com rastreabilidade de lote // @Tags Produtos // @Accept json // @Produce json // @Param product body registerProductRequest true "Produto" // @Success 201 {object} domain.Product // @Router /api/v1/products [post] func (h *Handler) CreateProduct(w http.ResponseWriter, r *http.Request) { var req registerProductRequest if err := decodeJSON(r.Context(), r, &req); err != nil { writeError(w, http.StatusBadRequest, err) return } product := &domain.Product{ SellerID: req.SellerID, Name: req.Name, Description: req.Description, Batch: req.Batch, ExpiresAt: req.ExpiresAt, PriceCents: req.PriceCents, Stock: req.Stock, } if err := h.svc.RegisterProduct(r.Context(), product); err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusCreated, product) } // ListProducts godoc // @Summary Lista catálogo com lote e validade // @Tags Produtos // @Produce json // @Success 200 {array} domain.Product // @Router /api/v1/products [get] func (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) { page, pageSize := parsePagination(r) filter := domain.ProductFilter{ Search: r.URL.Query().Get("search"), } result, err := h.svc.ListProducts(r.Context(), filter, page, pageSize) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, result) } // SearchProducts godoc // @Summary Busca avançada de produtos com filtros e distância // @Description Retorna produtos ordenados por validade, com distância aproximada. Vendedor anônimo até checkout. // @Tags Produtos // @Produce json // @Param search query string false "Termo de busca" // @Param min_price query integer false "Preço mínimo em centavos" // @Param max_price query integer false "Preço máximo em centavos" // @Param max_distance query number false "Distância máxima em km" // @Param lat query number true "Latitude do comprador" // @Param lng query number true "Longitude do comprador" // @Param page query integer false "Página" // @Param page_size query integer false "Itens por página" // @Success 200 {object} domain.ProductSearchPage // @Router /api/v1/products/search [get] func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) { page, pageSize := parsePagination(r) filter := domain.ProductSearchFilter{ Search: r.URL.Query().Get("search"), } // Parse buyer location (required) latStr := r.URL.Query().Get("lat") lngStr := r.URL.Query().Get("lng") if latStr == "" || lngStr == "" { writeError(w, http.StatusBadRequest, errors.New("lat and lng query params are required")) return } lat, err := strconv.ParseFloat(latStr, 64) if err != nil { writeError(w, http.StatusBadRequest, errors.New("invalid lat value")) return } lng, err := strconv.ParseFloat(lngStr, 64) if err != nil { writeError(w, http.StatusBadRequest, errors.New("invalid lng value")) return } filter.BuyerLat = lat filter.BuyerLng = lng // Parse optional price filters if v := r.URL.Query().Get("min_price"); v != "" { if price, err := strconv.ParseInt(v, 10, 64); err == nil { filter.MinPriceCents = &price } } if v := r.URL.Query().Get("max_price"); v != "" { if price, err := strconv.ParseInt(v, 10, 64); err == nil { filter.MaxPriceCents = &price } } // Parse optional max distance if v := r.URL.Query().Get("max_distance"); v != "" { if dist, err := strconv.ParseFloat(v, 64); err == nil { filter.MaxDistanceKm = &dist } } // Parse optional expiration filter if v := r.URL.Query().Get("expires_before"); v != "" { if days, err := strconv.Atoi(v); err == nil && days > 0 { expires := time.Now().AddDate(0, 0, days) filter.ExpiresBefore = &expires } } // Exclude products from the buyer's own company if claims, ok := middleware.GetClaims(r.Context()); ok && claims.CompanyID != nil { filter.ExcludeSellerID = claims.CompanyID } result, err := h.svc.SearchProducts(r.Context(), filter, page, pageSize) if err != nil { writeError(w, http.StatusInternalServerError, err) return } // Apply invisible buyer fee: inflate prices by buyerFeeRate (e.g., 12%) // The buyer sees inflated prices, but the DB stores the original seller price if h.buyerFeeRate > 0 { for i := range result.Products { originalPrice := result.Products[i].PriceCents inflatedPrice := int64(float64(originalPrice) * (1 + h.buyerFeeRate)) result.Products[i].PriceCents = inflatedPrice } } writeJSON(w, http.StatusOK, result) } // GetProduct godoc // @Summary Obter produto // @Tags Produtos // @Produce json // @Param id path string true "Product ID" // @Success 200 {object} domain.Product // @Failure 404 {object} map[string]string // @Router /api/v1/products/{id} [get] func (h *Handler) GetProduct(w http.ResponseWriter, r *http.Request) { id, err := parseUUIDFromPath(r.URL.Path) if err != nil { writeError(w, http.StatusBadRequest, err) return } product, err := h.svc.GetProduct(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, err) return } writeJSON(w, http.StatusOK, product) } // UpdateProduct godoc // @Summary Atualizar produto // @Tags Produtos // @Accept json // @Produce json // @Param id path string true "Product ID" // @Param payload body updateProductRequest true "Campos para atualização" // @Success 200 {object} domain.Product // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/v1/products/{id} [patch] func (h *Handler) UpdateProduct(w http.ResponseWriter, r *http.Request) { id, err := parseUUIDFromPath(r.URL.Path) if err != nil { writeError(w, http.StatusBadRequest, err) return } var req updateProductRequest if err := decodeJSON(r.Context(), r, &req); err != nil { writeError(w, http.StatusBadRequest, err) return } product, err := h.svc.GetProduct(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, err) return } if req.SellerID != nil { product.SellerID = *req.SellerID } if req.Name != nil { product.Name = *req.Name } if req.Description != nil { product.Description = *req.Description } if req.Batch != nil { product.Batch = *req.Batch } if req.ExpiresAt != nil { product.ExpiresAt = *req.ExpiresAt } if req.PriceCents != nil { product.PriceCents = *req.PriceCents } if req.Stock != nil { product.Stock = *req.Stock } if err := h.svc.UpdateProduct(r.Context(), product); err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, product) } // DeleteProduct godoc // @Summary Remover produto // @Tags Produtos // @Param id path string true "Product ID" // @Success 204 "" // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/v1/products/{id} [delete] func (h *Handler) DeleteProduct(w http.ResponseWriter, r *http.Request) { id, err := parseUUIDFromPath(r.URL.Path) if err != nil { writeError(w, http.StatusBadRequest, err) return } if err := h.svc.DeleteProduct(r.Context(), id); err != nil { writeError(w, http.StatusBadRequest, err) return } w.WriteHeader(http.StatusNoContent) } // ListInventory godoc // @Summary Listar estoque // @Tags Estoque // @Security BearerAuth // @Produce json // @Param expires_in_days query int false "Dias para expiração" // @Success 200 {array} domain.InventoryItem // @Router /api/v1/inventory [get] // ListInventory exposes stock with expiring batch filters. func (h *Handler) ListInventory(w http.ResponseWriter, r *http.Request) { page, pageSize := parsePagination(r) var filter domain.InventoryFilter if days := r.URL.Query().Get("expires_in_days"); days != "" { n, err := strconv.Atoi(days) if err != nil || n < 0 { writeError(w, http.StatusBadRequest, errors.New("invalid expires_in_days")) return } expires := time.Now().Add(time.Duration(n) * 24 * time.Hour) filter.ExpiringBefore = &expires } result, err := h.svc.ListInventory(r.Context(), filter, page, pageSize) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, result) } // AdjustInventory godoc // @Summary Ajustar estoque // @Tags Estoque // @Security BearerAuth // @Accept json // @Produce json // @Param payload body inventoryAdjustRequest true "Ajuste de estoque" // @Success 200 {object} domain.InventoryItem // @Failure 400 {object} map[string]string // @Router /api/v1/inventory/adjust [post] // AdjustInventory handles manual stock corrections. func (h *Handler) AdjustInventory(w http.ResponseWriter, r *http.Request) { var req inventoryAdjustRequest if err := decodeJSON(r.Context(), r, &req); err != nil { writeError(w, http.StatusBadRequest, err) return } if req.Delta == 0 { writeError(w, http.StatusBadRequest, errors.New("delta must be non-zero")) return } item, err := h.svc.AdjustInventory(r.Context(), req.ProductID, req.Delta, req.Reason) if err != nil { writeError(w, http.StatusBadRequest, err) return } writeJSON(w, http.StatusOK, item) }