package handler import ( "errors" "net/http" "strings" "github.com/gofrs/uuid/v5" "github.com/saveinmed/backend-go/internal/domain" ) // GetShippingSettings godoc // @Summary Get vendor shipping settings // @Description Returns pickup and delivery settings for a vendor. // @Tags Shipping // @Produce json // @Param vendor_id path string true "Vendor ID" // @Success 200 {object} domain.ShippingSettings // @Failure 400 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/shipping/settings/{vendor_id} [get] func (h *Handler) GetShippingSettings(w http.ResponseWriter, r *http.Request) { vendorID, err := parseUUIDFromPath(r.URL.Path) if err != nil { writeError(w, http.StatusBadRequest, err) return } // Any authenticated user can view shipping settings (needed for checkout) // No role-based restriction here - shipping settings are public info for buyers settings, err := h.svc.GetShippingSettings(r.Context(), vendorID) if err != nil { // Log error if needed, but for 404/not found we might return empty object writeError(w, http.StatusInternalServerError, err) return } if settings == nil { // Return defaults settings = &domain.ShippingSettings{VendorID: vendorID, Active: false} } writeJSON(w, http.StatusOK, settings) } // UpsertShippingSettings godoc // @Summary Update vendor shipping settings // @Description Stores pickup and delivery settings for a vendor. // @Tags Shipping // @Accept json // @Produce json // @Param vendor_id path string true "Vendor ID" // @Param payload body shippingSettingsRequest true "Shipping settings" // @Success 200 {object} domain.ShippingSettings // @Failure 400 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/shipping/settings/{vendor_id} [put] func (h *Handler) UpsertShippingSettings(w http.ResponseWriter, r *http.Request) { vendorID, err := parseUUIDFromPath(r.URL.Path) if err != nil { writeError(w, http.StatusBadRequest, err) return } requester, err := getRequester(r) if err != nil { writeError(w, http.StatusBadRequest, err) return } if !strings.EqualFold(requester.Role, "Admin") { if requester.CompanyID == nil || *requester.CompanyID != vendorID { writeError(w, http.StatusForbidden, errors.New("not allowed to update shipping settings")) return } } var req shippingSettingsRequest if err := decodeJSON(r.Context(), r, &req); err != nil { writeError(w, http.StatusBadRequest, err) return } if req.Active { if req.MaxRadiusKm < 0 { writeError(w, http.StatusBadRequest, errors.New("max_radius_km must be >= 0")) return } if req.PricePerKmCents < 0 || req.MinFeeCents < 0 { writeError(w, http.StatusBadRequest, errors.New("pricing fields must be >= 0")) return } } if req.PickupActive { if strings.TrimSpace(req.PickupAddress) == "" || strings.TrimSpace(req.PickupHours) == "" { writeError(w, http.StatusBadRequest, errors.New("pickup_address and pickup_hours are required for active pickup")) return } } settings := &domain.ShippingSettings{ VendorID: vendorID, Active: req.Active, MaxRadiusKm: req.MaxRadiusKm, PricePerKmCents: req.PricePerKmCents, MinFeeCents: req.MinFeeCents, FreeShippingThresholdCents: req.FreeShippingThresholdCents, PickupActive: req.PickupActive, PickupAddress: req.PickupAddress, PickupHours: req.PickupHours, Latitude: req.Latitude, Longitude: req.Longitude, } if err := h.svc.UpsertShippingSettings(r.Context(), settings); err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, settings) } // shippingCalculateV2Item is a single cart item in the new frontend request format. type shippingCalculateV2Item struct { SellerID uuid.UUID `json:"seller_id"` ProductID uuid.UUID `json:"product_id"` Quantity int64 `json:"quantity"` PriceCents int64 `json:"price_cents"` } // shippingCalculateV2Request is the frontend-native calculate request. type shippingCalculateV2Request struct { BuyerID uuid.UUID `json:"buyer_id"` OrderTotalCents int64 `json:"order_total_cents"` Items []shippingCalculateV2Item `json:"items"` } // shippingOptionV2Response is the frontend-native shipping option per seller. type shippingOptionV2Response struct { SellerID string `json:"seller_id"` DeliveryFeeCents int64 `json:"delivery_fee_cents"` DistanceKm float64 `json:"distance_km"` EstimatedDays int `json:"estimated_days"` PickupAvailable bool `json:"pickup_available"` PickupAddress string `json:"pickup_address,omitempty"` PickupHours string `json:"pickup_hours,omitempty"` } // CalculateShipping godoc // @Summary Calculate shipping options // @Description Accepts two formats: // (1) Legacy: { vendor_id, cart_total_cents, buyer_latitude, buyer_longitude } // (2) Frontend: { buyer_id, order_total_cents, items: [{seller_id, ...}] } // Returns options per seller in format { options: [...] }. // @Tags Shipping // @Accept json // @Produce json // @Success 200 {object} map[string]interface{} // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/shipping/calculate [post] func (h *Handler) CalculateShipping(w http.ResponseWriter, r *http.Request) { // Decode into a generic map to detect request format var raw map[string]interface{} if err := decodeJSON(r.Context(), r, &raw); err != nil { writeError(w, http.StatusBadRequest, err) return } // Detect frontend format: has "items" array with seller_id _, hasItems := raw["items"] _, hasBuyerID := raw["buyer_id"] if hasItems && hasBuyerID { // New frontend format h.calculateShippingV2(w, r, raw) return } // Legacy format: vendor_id + buyer coordinates h.calculateShippingLegacy(w, r, raw) } // calculateShippingV2 handles the frontend's multi-seller shipping calculation. func (h *Handler) calculateShippingV2(w http.ResponseWriter, r *http.Request, raw map[string]interface{}) { // Re-parse items from raw map buyerIDStr, _ := raw["buyer_id"].(string) buyerID, err := uuid.FromString(buyerIDStr) if err != nil { writeError(w, http.StatusBadRequest, errors.New("invalid buyer_id")) return } orderTotalCents := int64(0) if v, ok := raw["order_total_cents"].(float64); ok { orderTotalCents = int64(v) } itemsRaw, _ := raw["items"].([]interface{}) // Group items by seller_id to get totals per seller sellerTotals := map[uuid.UUID]int64{} for _, itemRaw := range itemsRaw { item, ok := itemRaw.(map[string]interface{}) if !ok { continue } sellerIDStr, _ := item["seller_id"].(string) sellerID, err := uuid.FromString(sellerIDStr) if err != nil { continue } qty := int64(1) if q, ok := item["quantity"].(float64); ok { qty = int64(q) } price := int64(0) if p, ok := item["price_cents"].(float64); ok { price = int64(p) } sellerTotals[sellerID] += qty * price } if len(sellerTotals) == 0 { writeError(w, http.StatusBadRequest, errors.New("no valid items provided")) return } // Try to get buyer's primary address for coordinates buyerAddr := &domain.Address{} addresses, _ := h.svc.ListAddresses(r.Context(), buyerID) if len(addresses) > 0 { buyerAddr = &addresses[0] } // Calculate options per seller results := make([]shippingOptionV2Response, 0, len(sellerTotals)) for sellerID, sellerTotal := range sellerTotals { opt := shippingOptionV2Response{ SellerID: sellerID.String(), } // Use per-seller total if available, otherwise distribute order total equally cartTotal := sellerTotal if cartTotal == 0 && orderTotalCents > 0 { cartTotal = orderTotalCents / int64(len(sellerTotals)) } fee, distKm, calcErr := h.svc.CalculateShipping(r.Context(), buyerAddr, sellerID, cartTotal) if calcErr == nil { opt.DeliveryFeeCents = fee opt.DistanceKm = distKm opt.EstimatedDays = 1 if distKm > 50 { opt.EstimatedDays = 3 } else if distKm > 20 { opt.EstimatedDays = 2 } } // Check pickup availability settings, _ := h.svc.GetShippingSettings(r.Context(), sellerID) if settings != nil && settings.PickupActive { opt.PickupAvailable = true opt.PickupAddress = settings.PickupAddress opt.PickupHours = settings.PickupHours } results = append(results, opt) } writeJSON(w, http.StatusOK, map[string]any{"options": results}) } // calculateShippingLegacy handles the original vendor_id + coordinates format. func (h *Handler) calculateShippingLegacy(w http.ResponseWriter, r *http.Request, raw map[string]interface{}) { vendorIDStr, _ := raw["vendor_id"].(string) vendorID, err := uuid.FromString(vendorIDStr) if err != nil || vendorID == uuid.Nil { writeError(w, http.StatusBadRequest, errors.New("vendor_id is required")) return } cartTotal := int64(0) if v, ok := raw["cart_total_cents"].(float64); ok { cartTotal = int64(v) } buyerLat, hasLat := raw["buyer_latitude"].(float64) buyerLng, hasLng := raw["buyer_longitude"].(float64) if !hasLat || !hasLng { writeError(w, http.StatusBadRequest, errors.New("buyer_latitude and buyer_longitude are required")) return } buyerAddr := &domain.Address{Latitude: buyerLat, Longitude: buyerLng} options := make([]domain.ShippingOption, 0) fee, distKm, calcErr := h.svc.CalculateShipping(r.Context(), buyerAddr, vendorID, cartTotal) if calcErr == nil { desc := "Entrega padrão" if fee == 0 { desc = "Frete Grátis" } options = append(options, domain.ShippingOption{ Type: "delivery", Description: desc, ValueCents: fee, DistanceKm: distKm, EstimatedMinutes: 120, }) } settings, _ := h.svc.GetShippingSettings(r.Context(), vendorID) if settings != nil && settings.PickupActive { options = append(options, domain.ShippingOption{ Type: "pickup", Description: settings.PickupAddress, ValueCents: 0, EstimatedMinutes: 0, }) } writeJSON(w, http.StatusOK, options) } // ListShipments godoc // @Summary List shipments // @Description Returns shipments. Admins see all, Tenants see only their own. // @Tags Shipments // @Security BearerAuth // @Produce json // @Param page query int false "Página" // @Param page_size query int false "Tamanho da página" // @Success 200 {object} domain.ShipmentPage // @Failure 401 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/shipments [get] func (h *Handler) ListShipments(w http.ResponseWriter, r *http.Request) { page, pageSize := parsePagination(r) requester, err := getRequester(r) if err != nil { writeError(w, http.StatusUnauthorized, err) return } filter := domain.ShipmentFilter{} if !strings.EqualFold(requester.Role, "Admin") { if requester.CompanyID == nil { writeError(w, http.StatusForbidden, errors.New("user has no company associated")) return } // Shipments logic: // Shipments are linked to orders, and orders belong to sellers. filter.SellerID = requester.CompanyID } result, err := h.svc.ListShipments(r.Context(), filter, page, pageSize) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, result) }