saveinmed/backend/internal/http/handler/shipping_handler.go

366 lines
11 KiB
Go

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)
}