Frontend: - Refatoração completa do [GestaoUsuarioModal](cci:1://file:///c:/Projetos/saveinmed/saveinmed-frontend/src/components/GestaoUsuarioModal.tsx:17:0-711:2) para melhor visibilidade e UX. - Correção de erro (crash) ao carregar endereços vazios. - Nova interface de Configuração de Frete com abas para Entrega e Retirada. - Correção na busca de dados completos da empresa (CEP, etc). - Ajuste na chave de autenticação (`access_token`) no serviço de endereços. Backend: - Correção do erro 500 em [GetShippingSettings](cci:1://file:///c:/Projetos/saveinmed/backend-old/internal/repository/postgres/postgres.go:1398:0-1405:1) (tratamento de `no rows`). - Ajustes nos handlers de endereço para suportar Admin/EntityID corretamente. - Migrações de banco de dados para configurações de envio e coordenadas. - Atualização da documentação Swagger e testes.
1282 lines
41 KiB
Go
1282 lines
41 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/gofrs/uuid/v5"
|
|
|
|
"github.com/saveinmed/backend-go/internal/domain"
|
|
"github.com/saveinmed/backend-go/internal/infrastructure/mapbox"
|
|
"github.com/saveinmed/backend-go/internal/notifications"
|
|
)
|
|
|
|
// Repository defines DB contract for the core entities.
|
|
type Repository interface {
|
|
CreateCompany(ctx context.Context, company *domain.Company) error
|
|
ListCompanies(ctx context.Context, filter domain.CompanyFilter) ([]domain.Company, int64, error)
|
|
GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error)
|
|
UpdateCompany(ctx context.Context, company *domain.Company) error
|
|
DeleteCompany(ctx context.Context, id uuid.UUID) error
|
|
|
|
CreateProduct(ctx context.Context, product *domain.Product) error
|
|
BatchCreateProducts(ctx context.Context, products []domain.Product) error
|
|
ListProducts(ctx context.Context, filter domain.ProductFilter) ([]domain.Product, int64, error)
|
|
SearchProducts(ctx context.Context, filter domain.ProductSearchFilter) ([]domain.ProductWithDistance, int64, error)
|
|
ListRecords(ctx context.Context, filter domain.RecordSearchFilter) ([]domain.Product, int64, error)
|
|
GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error)
|
|
UpdateProduct(ctx context.Context, product *domain.Product) error
|
|
DeleteProduct(ctx context.Context, id uuid.UUID) error
|
|
AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error)
|
|
ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, int64, error)
|
|
CreateInventoryItem(ctx context.Context, item *domain.InventoryItem) error
|
|
GetInventoryItem(ctx context.Context, id uuid.UUID) (*domain.InventoryItem, error)
|
|
UpdateInventoryItem(ctx context.Context, item *domain.InventoryItem) error
|
|
|
|
CreateOrder(ctx context.Context, order *domain.Order) error
|
|
ListOrders(ctx context.Context, filter domain.OrderFilter) ([]domain.Order, int64, error)
|
|
|
|
GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error)
|
|
UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) error
|
|
DeleteOrder(ctx context.Context, id uuid.UUID) error
|
|
CreateShipment(ctx context.Context, shipment *domain.Shipment) error
|
|
GetShipmentByOrderID(ctx context.Context, orderID uuid.UUID) (*domain.Shipment, error)
|
|
|
|
CreateUser(ctx context.Context, user *domain.User) error
|
|
ListUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error)
|
|
GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error)
|
|
GetUserByUsername(ctx context.Context, username string) (*domain.User, error)
|
|
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
|
|
UpdateUser(ctx context.Context, user *domain.User) error
|
|
DeleteUser(ctx context.Context, id uuid.UUID) error
|
|
|
|
AddCartItem(ctx context.Context, item *domain.CartItem) (*domain.CartItem, error)
|
|
ListCartItems(ctx context.Context, buyerID uuid.UUID) ([]domain.CartItem, error)
|
|
DeleteCartItem(ctx context.Context, id uuid.UUID, buyerID uuid.UUID) error
|
|
DeleteCartItemByProduct(ctx context.Context, buyerID, productID uuid.UUID) error
|
|
ClearCart(ctx context.Context, buyerID uuid.UUID) error
|
|
|
|
CreateReview(ctx context.Context, review *domain.Review) error
|
|
GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error)
|
|
SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error)
|
|
AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error)
|
|
GetShippingSettings(ctx context.Context, vendorID uuid.UUID) (*domain.ShippingSettings, error)
|
|
UpsertShippingSettings(ctx context.Context, settings *domain.ShippingSettings) error
|
|
ListReviews(ctx context.Context, filter domain.ReviewFilter) ([]domain.Review, int64, error)
|
|
ListShipments(ctx context.Context, filter domain.ShipmentFilter) ([]domain.Shipment, int64, error)
|
|
|
|
// Financials
|
|
CreateDocument(ctx context.Context, doc *domain.CompanyDocument) error
|
|
ListDocuments(ctx context.Context, companyID uuid.UUID) ([]domain.CompanyDocument, error)
|
|
RecordLedgerEntry(ctx context.Context, entry *domain.LedgerEntry) error
|
|
GetLedger(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]domain.LedgerEntry, int64, error)
|
|
GetBalance(ctx context.Context, companyID uuid.UUID) (int64, error)
|
|
CreateWithdrawal(ctx context.Context, withdrawal *domain.Withdrawal) error
|
|
ListWithdrawals(ctx context.Context, companyID uuid.UUID) ([]domain.Withdrawal, error)
|
|
|
|
// Payment Configuration
|
|
GetPaymentGatewayConfig(ctx context.Context, provider string) (*domain.PaymentGatewayConfig, error)
|
|
UpsertPaymentGatewayConfig(ctx context.Context, config *domain.PaymentGatewayConfig) error
|
|
GetSellerPaymentAccount(ctx context.Context, sellerID uuid.UUID) (*domain.SellerPaymentAccount, error)
|
|
UpsertSellerPaymentAccount(ctx context.Context, account *domain.SellerPaymentAccount) error
|
|
|
|
CreateAddress(ctx context.Context, address *domain.Address) error
|
|
ListAddresses(ctx context.Context, entityID uuid.UUID) ([]domain.Address, error)
|
|
GetAddress(ctx context.Context, id uuid.UUID) (*domain.Address, error)
|
|
UpdateAddress(ctx context.Context, address *domain.Address) error
|
|
DeleteAddress(ctx context.Context, id uuid.UUID) error
|
|
ListManufacturers(ctx context.Context) ([]string, error)
|
|
ListCategories(ctx context.Context) ([]string, error)
|
|
GetProductByEAN(ctx context.Context, ean string) (*domain.Product, error)
|
|
ReplaceCart(ctx context.Context, buyerID uuid.UUID, items []domain.CartItem) error
|
|
UpdateOrderItems(ctx context.Context, orderID uuid.UUID, items []domain.OrderItem, totalCents int64) error
|
|
}
|
|
|
|
// PaymentGateway abstracts Mercado Pago integration.
|
|
type PaymentGateway interface {
|
|
CreatePreference(ctx context.Context, order *domain.Order) (*domain.PaymentPreference, error)
|
|
}
|
|
|
|
type Service struct {
|
|
repo Repository
|
|
pay PaymentGateway
|
|
mapbox *mapbox.Client
|
|
notify notifications.NotificationService
|
|
marketplaceCommission float64
|
|
buyerFeeRate float64
|
|
jwtSecret []byte
|
|
tokenTTL time.Duration
|
|
passwordPepper string
|
|
}
|
|
|
|
const (
|
|
passwordResetTTL = 30 * time.Minute
|
|
)
|
|
|
|
// NewService wires use cases together.
|
|
func NewService(repo Repository, pg PaymentGateway, mapbox *mapbox.Client, notify notifications.NotificationService, commission, buyerFeeRate float64, jwtSecret string, tokenTTL time.Duration, pepper string) *Service {
|
|
return &Service{
|
|
repo: repo,
|
|
pay: pg,
|
|
mapbox: mapbox,
|
|
notify: notify,
|
|
marketplaceCommission: commission,
|
|
buyerFeeRate: buyerFeeRate,
|
|
jwtSecret: []byte(jwtSecret),
|
|
tokenTTL: tokenTTL,
|
|
passwordPepper: pepper,
|
|
}
|
|
}
|
|
|
|
// GetNotificationService returns the notification service for push handlers
|
|
func (s *Service) GetNotificationService() notifications.NotificationService {
|
|
return s.notify
|
|
}
|
|
|
|
func (s *Service) RegisterCompany(ctx context.Context, company *domain.Company) error {
|
|
company.ID = uuid.Must(uuid.NewV7())
|
|
return s.repo.CreateCompany(ctx, company)
|
|
}
|
|
|
|
func (s *Service) ListCompanies(ctx context.Context, filter domain.CompanyFilter, page, pageSize int) (*domain.CompanyPage, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
companies, total, err := s.repo.ListCompanies(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.CompanyPage{Companies: companies, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (s *Service) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) {
|
|
return s.repo.GetCompany(ctx, id)
|
|
}
|
|
|
|
func (s *Service) CalculateShippingOptions(ctx context.Context, vendorID uuid.UUID, buyerLat, buyerLng float64, cartTotalCents int64) ([]domain.ShippingOption, error) {
|
|
company, err := s.repo.GetCompany(ctx, vendorID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if company == nil {
|
|
return nil, errors.New("vendor not found")
|
|
}
|
|
|
|
settings, err := s.repo.GetShippingSettings(ctx, vendorID)
|
|
if err != nil {
|
|
// Just return empty options if settings not found
|
|
return []domain.ShippingOption{}, nil
|
|
}
|
|
if settings == nil {
|
|
return []domain.ShippingOption{}, nil
|
|
}
|
|
|
|
distance := domain.HaversineDistance(company.Latitude, company.Longitude, buyerLat, buyerLng)
|
|
var options []domain.ShippingOption
|
|
|
|
// 1. Delivery
|
|
if settings.Active {
|
|
if settings.MaxRadiusKm > 0 && distance <= settings.MaxRadiusKm {
|
|
variableCost := int64(math.Round(distance * float64(settings.PricePerKmCents)))
|
|
price := settings.MinFeeCents
|
|
if variableCost > price {
|
|
price = variableCost
|
|
}
|
|
if settings.FreeShippingThresholdCents != nil && *settings.FreeShippingThresholdCents > 0 && cartTotalCents >= *settings.FreeShippingThresholdCents {
|
|
price = 0
|
|
}
|
|
|
|
// Estimate: 30 mins base + 5 mins/km default
|
|
estMins := 30 + int(math.Round(distance*5))
|
|
|
|
options = append(options, domain.ShippingOption{
|
|
Type: domain.ShippingOptionTypeDelivery,
|
|
ValueCents: price,
|
|
EstimatedMinutes: estMins,
|
|
Description: "Entrega Própria",
|
|
DistanceKm: distance,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 2. Pickup
|
|
if settings.PickupActive {
|
|
desc := "Retirada na loja"
|
|
if settings.PickupAddress != "" {
|
|
desc = "Retirada em: " + settings.PickupAddress
|
|
}
|
|
if settings.PickupHours != "" {
|
|
desc += " (" + settings.PickupHours + ")"
|
|
}
|
|
|
|
options = append(options, domain.ShippingOption{
|
|
Type: domain.ShippingOptionTypePickup,
|
|
ValueCents: 0,
|
|
EstimatedMinutes: 60, // Default 1 hour readily available
|
|
Description: desc,
|
|
DistanceKm: distance,
|
|
})
|
|
}
|
|
|
|
return options, nil
|
|
}
|
|
|
|
func (s *Service) UpdateCompany(ctx context.Context, company *domain.Company) error {
|
|
return s.repo.UpdateCompany(ctx, company)
|
|
}
|
|
|
|
func (s *Service) DeleteCompany(ctx context.Context, id uuid.UUID) error {
|
|
return s.repo.DeleteCompany(ctx, id)
|
|
}
|
|
|
|
func (s *Service) RegisterProduct(ctx context.Context, product *domain.Product) error {
|
|
product.ID = uuid.Must(uuid.NewV7())
|
|
return s.repo.CreateProduct(ctx, product)
|
|
}
|
|
|
|
func (s *Service) ListProducts(ctx context.Context, filter domain.ProductFilter, page, pageSize int) (*domain.ProductPage, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
products, total, err := s.repo.ListProducts(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.ProductPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
// SearchProducts returns products with distance, ordered by expiration date.
|
|
// Seller info is anonymized until checkout.
|
|
func (s *Service) SearchProducts(ctx context.Context, filter domain.ProductSearchFilter, page, pageSize int) (*domain.ProductSearchPage, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
products, total, err := s.repo.SearchProducts(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.ProductSearchPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
// ListRecords provides an advanced search for marketplace listings.
|
|
func (s *Service) ListRecords(ctx context.Context, req domain.SearchRequest) (*domain.PaginationResponse[domain.Product], error) {
|
|
page := req.Page
|
|
pageSize := req.PageSize
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
|
|
sortBy := strings.TrimSpace(req.SortBy)
|
|
if sortBy == "" {
|
|
sortBy = "updated_at"
|
|
}
|
|
sortOrder := strings.ToLower(strings.TrimSpace(req.SortOrder))
|
|
if sortOrder == "" {
|
|
sortOrder = "desc"
|
|
}
|
|
|
|
filter := domain.RecordSearchFilter{
|
|
Query: strings.TrimSpace(req.Query),
|
|
SortBy: sortBy,
|
|
SortOrder: sortOrder,
|
|
CreatedAfter: req.CreatedAfter,
|
|
CreatedBefore: req.CreatedBefore,
|
|
Limit: pageSize,
|
|
Offset: (page - 1) * pageSize,
|
|
}
|
|
|
|
items, total, err := s.repo.ListRecords(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
|
|
return &domain.PaginationResponse[domain.Product]{
|
|
Items: items,
|
|
TotalCount: total,
|
|
CurrentPage: page,
|
|
TotalPages: totalPages,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
|
|
return s.repo.GetProduct(ctx, id)
|
|
}
|
|
|
|
func (s *Service) UpdateProduct(ctx context.Context, product *domain.Product) error {
|
|
return s.repo.UpdateProduct(ctx, product)
|
|
}
|
|
|
|
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
|
return s.repo.DeleteProduct(ctx, id)
|
|
}
|
|
|
|
func (s *Service) ListInventory(ctx context.Context, filter domain.InventoryFilter, page, pageSize int) (*domain.InventoryPage, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
items, total, err := s.repo.ListInventory(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.InventoryPage{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (s *Service) AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) {
|
|
return s.repo.AdjustInventory(ctx, productID, delta, reason)
|
|
}
|
|
|
|
func (s *Service) CreateOrder(ctx context.Context, order *domain.Order) error {
|
|
// 1. Auto-clean: Check if buyer has ANY pending order and delete it to release stock.
|
|
// This prevents "zombie" orders from holding stock if frontend state is lost.
|
|
// We only do this for "Pending" orders.
|
|
// If ListOrders doesn't filter by status, I have to filter manually.
|
|
// Or I can rely on a broader clean-up.
|
|
|
|
orders, _, err := s.repo.ListOrders(ctx, domain.OrderFilter{BuyerID: &order.BuyerID, Limit: 100})
|
|
if err == nil {
|
|
for _, o := range orders {
|
|
if o.Status == domain.OrderStatusPending {
|
|
// Delete pending order (Restores stock)
|
|
_ = s.DeleteOrder(ctx, o.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
order.ID = uuid.Must(uuid.NewV7())
|
|
order.Status = domain.OrderStatusPending
|
|
|
|
// Calculate and Apply Shipping
|
|
buyerAddr := &domain.Address{
|
|
Latitude: order.Shipping.Latitude,
|
|
Longitude: order.Shipping.Longitude,
|
|
}
|
|
fee, dist, err := s.CalculateShipping(ctx, buyerAddr, order.SellerID, order.TotalCents)
|
|
if err == nil {
|
|
order.ShippingFeeCents = fee
|
|
order.DistanceKm = dist
|
|
order.TotalCents += fee
|
|
} else {
|
|
// Log warning but proceed? Or fail?
|
|
// Ensure fee is 0 if error or out of range.
|
|
// If error is "out of range", we might want to block order.
|
|
if err.Error() == "address out of delivery range" {
|
|
return err
|
|
}
|
|
// logic for other errors:
|
|
// fmt.Printf("Shipping calculation warning: %v\n", err)
|
|
}
|
|
|
|
if err := s.repo.CreateOrder(ctx, order); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Async notification (ignore errors to not block request)
|
|
go func() {
|
|
// Fetch buyer details
|
|
buyer, err := s.repo.GetUser(context.Background(), order.BuyerID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Fetch seller details (hack: get list and pick first for MVP)
|
|
users, _, _ := s.repo.ListUsers(context.Background(), domain.UserFilter{CompanyID: &order.SellerID, Limit: 1})
|
|
var seller *domain.User
|
|
|
|
if len(users) > 0 {
|
|
seller = &users[0]
|
|
} else {
|
|
// Fallback mock
|
|
seller = &domain.User{Name: "Seller", Email: "seller@platform.com"}
|
|
}
|
|
|
|
s.notify.NotifyOrderCreated(context.Background(), order, buyer, seller)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) CalculateShipping(ctx context.Context, buyerAddress *domain.Address, sellerID uuid.UUID, cartTotalCents int64) (int64, float64, error) {
|
|
// 1. Get Shipping Settings for Seller
|
|
settings, err := s.repo.GetShippingSettings(ctx, sellerID)
|
|
if err != nil {
|
|
// If settings not found, assume free or default? Or error?
|
|
// For MVP, if no settings, Free Shipping (0).
|
|
return 0, 0, nil
|
|
}
|
|
|
|
if !settings.Active {
|
|
return 0, 0, nil
|
|
}
|
|
|
|
// 2. Validate coordinates
|
|
if buyerAddress.Latitude == 0 || buyerAddress.Longitude == 0 {
|
|
return settings.MinFeeCents, 0, nil
|
|
}
|
|
|
|
// 3. Calculate Distance (Mapbox)
|
|
distKm, err := s.mapbox.GetDrivingDistance(settings.Latitude, settings.Longitude, buyerAddress.Latitude, buyerAddress.Longitude)
|
|
if err != nil {
|
|
// Fallback to Haversine if mapbox fails?
|
|
distKm = haversine(buyerAddress.Latitude, buyerAddress.Longitude, settings.Latitude, settings.Longitude)
|
|
}
|
|
|
|
// 4. Check Radius
|
|
if distKm > settings.MaxRadiusKm {
|
|
// Logic from python: error out
|
|
return 0, distKm, errors.New("address out of delivery range")
|
|
}
|
|
|
|
// 5. Calculate Fee
|
|
// Logic:
|
|
// If FreeShippingThreshold set and Total >= Threshold -> Free (0)
|
|
// Else If PricePerKm > 0 -> dist * PricePerKm
|
|
// Else If MinFee > 0 -> MinFee (Fixed)
|
|
// Else -> Free (0)
|
|
|
|
if settings.FreeShippingThresholdCents != nil && cartTotalCents >= *settings.FreeShippingThresholdCents {
|
|
return 0, distKm, nil
|
|
}
|
|
|
|
var fee int64
|
|
if settings.PricePerKmCents > 0 {
|
|
fee = int64(distKm * float64(settings.PricePerKmCents))
|
|
// Optional: Ensure min fee? Python logic says if per_km use per_km.
|
|
// Usually there's a base fee + per km, but here model is separate.
|
|
// Let's stick to simple logic.
|
|
} else if settings.MinFeeCents > 0 {
|
|
fee = settings.MinFeeCents
|
|
} else {
|
|
fee = 0
|
|
}
|
|
|
|
return fee, distKm, nil
|
|
}
|
|
|
|
// haversine calculates distance between two points in km
|
|
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
|
|
const R = 6371 // Earth radius in km
|
|
dLat := (lat2 - lat1) * (math.Pi / 180.0)
|
|
dLon := (lon2 - lon1) * (math.Pi / 180.0)
|
|
|
|
lat1Rad := lat1 * (math.Pi / 180.0)
|
|
lat2Rad := lat2 * (math.Pi / 180.0)
|
|
|
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
|
math.Sin(dLon/2)*math.Sin(dLon/2)*math.Cos(lat1Rad)*math.Cos(lat2Rad)
|
|
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
|
|
|
return R * c
|
|
}
|
|
|
|
func (s *Service) ListOrders(ctx context.Context, filter domain.OrderFilter, page, pageSize int) (*domain.OrderPage, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
orders, total, err := s.repo.ListOrders(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.OrderPage{Orders: orders, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (s *Service) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) {
|
|
return s.repo.GetOrder(ctx, id)
|
|
}
|
|
|
|
func (s *Service) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) error {
|
|
order, err := s.repo.GetOrder(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// State Machine Logic
|
|
switch order.Status {
|
|
case domain.OrderStatusPending:
|
|
if status != domain.OrderStatusPaid && status != domain.OrderStatusCancelled {
|
|
return errors.New("invalid transition from Pending")
|
|
}
|
|
case domain.OrderStatusPaid:
|
|
if status != domain.OrderStatusInvoiced && status != domain.OrderStatusShipped && status != domain.OrderStatusCancelled {
|
|
return errors.New("invalid transition from Paid")
|
|
}
|
|
case domain.OrderStatusInvoiced: // Can go to Shipped
|
|
if status != domain.OrderStatusShipped && status != domain.OrderStatusCancelled {
|
|
return errors.New("invalid transition from Invoiced")
|
|
}
|
|
case domain.OrderStatusShipped:
|
|
if status != domain.OrderStatusDelivered {
|
|
return errors.New("invalid transition from Shipped")
|
|
}
|
|
case domain.OrderStatusDelivered:
|
|
if status != domain.OrderStatusCompleted {
|
|
return errors.New("invalid transition from Delivered")
|
|
}
|
|
case domain.OrderStatusCompleted, domain.OrderStatusCancelled:
|
|
return errors.New("order is in terminal state")
|
|
}
|
|
|
|
if err := s.repo.UpdateOrderStatus(ctx, id, status); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Restore stock if order is cancelled
|
|
if status == domain.OrderStatusCancelled && (order.Status == domain.OrderStatusPending || order.Status == domain.OrderStatusPaid || order.Status == domain.OrderStatusInvoiced) {
|
|
for _, item := range order.Items {
|
|
// Restore stock
|
|
if _, err := s.repo.AdjustInventory(ctx, item.ProductID, int64(item.Quantity), "Order Cancelled"); err != nil {
|
|
// Log error but don't fail the request (or maybe we should?)
|
|
// ideally this whole operation should be atomic.
|
|
// For now, logging.
|
|
// In a real system, we'd want a saga or transaction.
|
|
// fmt.Printf("Failed to restore stock for item %s: %v\n", item.ProductID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Async notification
|
|
go func() {
|
|
// Re-fetch order to get all details if necessary, but we have fields.
|
|
// Need buyer to send email
|
|
updatedOrder, _ := s.repo.GetOrder(context.Background(), id)
|
|
if updatedOrder == nil {
|
|
return
|
|
}
|
|
|
|
buyer, err := s.repo.GetUser(context.Background(), updatedOrder.BuyerID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
s.notify.NotifyOrderStatusChanged(context.Background(), updatedOrder, buyer)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) DeleteOrder(ctx context.Context, id uuid.UUID) error {
|
|
order, err := s.repo.GetOrder(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only restore stock if order reserved it (Pending, Paid, Invoiced)
|
|
if order.Status == domain.OrderStatusPending || order.Status == domain.OrderStatusPaid || order.Status == domain.OrderStatusInvoiced {
|
|
for _, item := range order.Items {
|
|
// Restore stock
|
|
if _, err := s.repo.AdjustInventory(ctx, item.ProductID, int64(item.Quantity), "Order Deleted"); err != nil {
|
|
// Log error but proceed? Or fail?
|
|
// For now proceed to ensure deletion, but log would be good.
|
|
}
|
|
}
|
|
}
|
|
|
|
return s.repo.DeleteOrder(ctx, id)
|
|
}
|
|
|
|
// CreateShipment persists a freight label for an order if not already present.
|
|
func (s *Service) CreateShipment(ctx context.Context, shipment *domain.Shipment) error {
|
|
if _, err := s.repo.GetOrder(ctx, shipment.OrderID); err != nil {
|
|
return err
|
|
}
|
|
|
|
shipment.ID = uuid.Must(uuid.NewV7())
|
|
shipment.Status = "Label gerada"
|
|
return s.repo.CreateShipment(ctx, shipment)
|
|
}
|
|
|
|
// GetShipmentByOrderID returns freight details for an order.
|
|
func (s *Service) GetShipmentByOrderID(ctx context.Context, orderID uuid.UUID) (*domain.Shipment, error) {
|
|
return s.repo.GetShipmentByOrderID(ctx, orderID)
|
|
}
|
|
|
|
func (s *Service) CreatePaymentPreference(ctx context.Context, id uuid.UUID) (*domain.PaymentPreference, error) {
|
|
order, err := s.repo.GetOrder(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.pay.CreatePreference(ctx, order)
|
|
}
|
|
|
|
// HandlePaymentWebhook processes Mercado Pago notifications ensuring split consistency.
|
|
func (s *Service) HandlePaymentWebhook(ctx context.Context, event domain.PaymentWebhookEvent) (*domain.PaymentSplitResult, error) {
|
|
order, err := s.repo.GetOrder(ctx, event.OrderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
expectedMarketplaceFee := int64(float64(order.TotalCents) * (s.marketplaceCommission / 100))
|
|
marketplaceFee := event.MarketplaceFee
|
|
if marketplaceFee == 0 {
|
|
marketplaceFee = expectedMarketplaceFee
|
|
}
|
|
|
|
sellerReceivable := order.TotalCents - marketplaceFee
|
|
if event.SellerAmount > 0 {
|
|
sellerReceivable = event.SellerAmount
|
|
}
|
|
|
|
if strings.EqualFold(event.Status, "approved") || strings.EqualFold(event.Status, "paid") {
|
|
if err := s.repo.UpdateOrderStatus(ctx, order.ID, domain.OrderStatusPaid); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Financial Ledger: Credit Sale
|
|
_ = s.repo.RecordLedgerEntry(ctx, &domain.LedgerEntry{
|
|
ID: uuid.Must(uuid.NewV7()),
|
|
CompanyID: order.SellerID,
|
|
AmountCents: order.TotalCents, // Credit full amount
|
|
Type: "SALE",
|
|
Description: "Order #" + order.ID.String(),
|
|
ReferenceID: &order.ID,
|
|
})
|
|
|
|
// Financial Ledger: Debit Fee
|
|
_ = s.repo.RecordLedgerEntry(ctx, &domain.LedgerEntry{
|
|
ID: uuid.Must(uuid.NewV7()),
|
|
CompanyID: order.SellerID,
|
|
AmountCents: -marketplaceFee, // Debit fee
|
|
Type: "FEE",
|
|
Description: "Marketplace Fee #" + order.ID.String(),
|
|
ReferenceID: &order.ID,
|
|
})
|
|
}
|
|
|
|
return &domain.PaymentSplitResult{
|
|
OrderID: order.ID,
|
|
PaymentID: event.PaymentID,
|
|
Status: event.Status,
|
|
MarketplaceFee: marketplaceFee,
|
|
SellerReceivable: sellerReceivable,
|
|
TotalPaidAmount: event.TotalPaidAmount,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) CreateUser(ctx context.Context, user *domain.User, password string) error {
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(s.pepperPassword(password)), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
user.ID = uuid.Must(uuid.NewV7())
|
|
user.PasswordHash = string(hashed)
|
|
|
|
return s.repo.CreateUser(ctx, user)
|
|
}
|
|
|
|
func (s *Service) ListUsers(ctx context.Context, filter domain.UserFilter, page, pageSize int) (*domain.UserPage, error) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
|
|
users, total, err := s.repo.ListUsers(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &domain.UserPage{Users: users, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (s *Service) GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error) {
|
|
return s.repo.GetUser(ctx, id)
|
|
}
|
|
|
|
func (s *Service) UpdateUser(ctx context.Context, user *domain.User, newPassword string) error {
|
|
if newPassword != "" {
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(s.pepperPassword(newPassword)), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user.PasswordHash = string(hashed)
|
|
}
|
|
|
|
return s.repo.UpdateUser(ctx, user)
|
|
}
|
|
|
|
func (s *Service) DeleteUser(ctx context.Context, id uuid.UUID) error {
|
|
return s.repo.DeleteUser(ctx, id)
|
|
}
|
|
|
|
func (s *Service) ReplaceCart(ctx context.Context, buyerID uuid.UUID, reqItems []domain.CartItem) (*domain.CartSummary, error) {
|
|
var validItems []domain.CartItem
|
|
|
|
for _, item := range reqItems {
|
|
// Fetch product to get price
|
|
product, err := s.repo.GetProduct(ctx, item.ProductID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
unitPrice := product.PriceCents
|
|
if s.buyerFeeRate > 0 {
|
|
unitPrice = int64(float64(unitPrice) * (1 + s.buyerFeeRate))
|
|
}
|
|
|
|
validItems = append(validItems, domain.CartItem{
|
|
ID: uuid.Must(uuid.NewV7()),
|
|
BuyerID: buyerID,
|
|
ProductID: item.ProductID,
|
|
Quantity: item.Quantity,
|
|
UnitCents: unitPrice,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
})
|
|
}
|
|
|
|
if err := s.repo.ReplaceCart(ctx, buyerID, validItems); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.cartSummary(ctx, buyerID)
|
|
}
|
|
|
|
func (s *Service) UpdateOrderItems(ctx context.Context, orderID uuid.UUID, items []domain.OrderItem, totalCents int64) error {
|
|
// Ensure order exists
|
|
if _, err := s.repo.GetOrder(ctx, orderID); err != nil {
|
|
return err
|
|
}
|
|
|
|
return s.repo.UpdateOrderItems(ctx, orderID, items, totalCents)
|
|
}
|
|
|
|
// AddItemToCart validates stock, persists the item and returns the refreshed summary.
|
|
func (s *Service) AddItemToCart(ctx context.Context, buyerID, productID uuid.UUID, quantity int64) (*domain.CartSummary, error) {
|
|
if quantity <= 0 {
|
|
return nil, errors.New("quantity must be greater than zero")
|
|
}
|
|
|
|
product, err := s.repo.GetProduct(ctx, productID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cartItems, err := s.repo.ListCartItems(ctx, buyerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var currentQty int64
|
|
for _, it := range cartItems {
|
|
if it.ProductID == productID {
|
|
currentQty += it.Quantity
|
|
}
|
|
}
|
|
|
|
// Stock check disabled for Dictionary mode.
|
|
// In the future, check inventory_items availability via AdjustInventory logic or similar.
|
|
|
|
// Apply Buyer Fee (12% or configured)
|
|
unitPrice := product.PriceCents
|
|
if s.buyerFeeRate > 0 {
|
|
unitPrice = int64(float64(unitPrice) * (1 + s.buyerFeeRate))
|
|
}
|
|
|
|
_, err = s.repo.AddCartItem(ctx, &domain.CartItem{
|
|
ID: uuid.Must(uuid.NewV7()),
|
|
BuyerID: buyerID,
|
|
ProductID: productID,
|
|
Quantity: quantity,
|
|
UnitCents: unitPrice,
|
|
// Batch and ExpiresAt handled at fulfillment or selection time
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.cartSummary(ctx, buyerID)
|
|
}
|
|
|
|
// Reorder duplicates items from a previous order into the cart.
|
|
// It skips items that are out of stock or discontinued.
|
|
func (s *Service) Reorder(ctx context.Context, buyerID, orderID uuid.UUID) (*domain.CartSummary, []string, error) {
|
|
order, err := s.repo.GetOrder(ctx, orderID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if order.BuyerID != buyerID {
|
|
return nil, nil, errors.New("order does not belong to buyer")
|
|
}
|
|
|
|
var warnings []string
|
|
for _, item := range order.Items {
|
|
// Try to add to cart
|
|
// We use the original quantity.
|
|
// Ideally we should check if product still exists. AddItemToCart does that.
|
|
if _, err := s.AddItemToCart(ctx, buyerID, item.ProductID, int64(item.Quantity)); err != nil {
|
|
warnings = append(warnings, fmt.Sprintf("Item %s not added: %v", item.ProductID, err))
|
|
}
|
|
}
|
|
|
|
// Return final cart state
|
|
summary, err := s.cartSummary(ctx, buyerID)
|
|
return summary, warnings, err
|
|
}
|
|
|
|
// ListCart returns the current cart with B2B discounts applied.
|
|
func (s *Service) ListCart(ctx context.Context, buyerID uuid.UUID) (*domain.CartSummary, error) {
|
|
return s.cartSummary(ctx, buyerID)
|
|
}
|
|
|
|
// RemoveCartItem deletes a cart row and returns the refreshed cart summary.
|
|
func (s *Service) RemoveCartItem(ctx context.Context, buyerID, cartItemID uuid.UUID) (*domain.CartSummary, error) {
|
|
if err := s.repo.DeleteCartItem(ctx, cartItemID, buyerID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.cartSummary(ctx, buyerID)
|
|
}
|
|
|
|
func (s *Service) RemoveCartItemByProduct(ctx context.Context, buyerID, productID uuid.UUID) (*domain.CartSummary, error) {
|
|
// We ignore "not found" error to be idempotent, or handle it?
|
|
// Logic says if it returns error, we return it.
|
|
// But if we want to "ensure removed", we might ignore not found.
|
|
// For now, standard behavior.
|
|
if err := s.repo.DeleteCartItemByProduct(ctx, buyerID, productID); err != nil {
|
|
// return nil, err
|
|
// Actually, if item is not found, we still want to return the cart summary,
|
|
// but maybe we should return error to let frontend know?
|
|
// Let's return error for now to be consistent with DeleteCartItem.
|
|
return nil, err
|
|
}
|
|
return s.cartSummary(ctx, buyerID)
|
|
}
|
|
|
|
func (s *Service) ClearCart(ctx context.Context, buyerID uuid.UUID) (*domain.CartSummary, error) {
|
|
|
|
if err := s.repo.ClearCart(ctx, buyerID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.cartSummary(ctx, buyerID)
|
|
}
|
|
|
|
func (s *Service) cartSummary(ctx context.Context, buyerID uuid.UUID) (*domain.CartSummary, error) {
|
|
items, err := s.repo.ListCartItems(ctx, buyerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var subtotal int64
|
|
for i := range items {
|
|
subtotal += items[i].UnitCents * items[i].Quantity
|
|
}
|
|
|
|
summary := &domain.CartSummary{
|
|
ID: buyerID,
|
|
Items: items,
|
|
SubtotalCents: subtotal,
|
|
}
|
|
|
|
if subtotal >= 100000 { // apply 5% B2B discount for large baskets
|
|
summary.DiscountCents = int64(float64(subtotal) * 0.05)
|
|
summary.DiscountReason = "5% B2B volume discount"
|
|
}
|
|
summary.TotalCents = subtotal - summary.DiscountCents
|
|
return summary, nil
|
|
}
|
|
|
|
// CreateReview stores a buyer rating ensuring the order is delivered and owned by the requester.
|
|
func (s *Service) CreateReview(ctx context.Context, buyerID, orderID uuid.UUID, rating int, comment string) (*domain.Review, error) {
|
|
if rating < 1 || rating > 5 {
|
|
return nil, errors.New("rating must be between 1 and 5")
|
|
}
|
|
|
|
order, err := s.repo.GetOrder(ctx, orderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if order.Status != domain.OrderStatusDelivered {
|
|
return nil, errors.New("only delivered orders can be reviewed")
|
|
}
|
|
|
|
if order.BuyerID != buyerID {
|
|
return nil, errors.New("order does not belong to buyer")
|
|
}
|
|
|
|
review := &domain.Review{
|
|
ID: uuid.Must(uuid.NewV7()),
|
|
OrderID: orderID,
|
|
BuyerID: buyerID,
|
|
SellerID: order.SellerID,
|
|
Rating: rating,
|
|
Comment: comment,
|
|
}
|
|
|
|
if err := s.repo.CreateReview(ctx, review); err != nil {
|
|
return nil, err
|
|
}
|
|
return review, nil
|
|
}
|
|
|
|
// GetCompanyRating returns the average rating for a seller or pharmacy.
|
|
func (s *Service) GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error) {
|
|
return s.repo.GetCompanyRating(ctx, companyID)
|
|
}
|
|
|
|
// GetSellerDashboard aggregates commercial KPIs for the seller.
|
|
func (s *Service) GetSellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error) {
|
|
return s.repo.SellerDashboard(ctx, sellerID)
|
|
}
|
|
|
|
// GetAdminDashboard exposes marketplace-wide metrics for the last 30 days.
|
|
func (s *Service) GetAdminDashboard(ctx context.Context) (*domain.AdminDashboard, error) {
|
|
since := time.Now().AddDate(0, 0, -30)
|
|
return s.repo.AdminDashboard(ctx, since)
|
|
}
|
|
|
|
// RegisterAccount creates a company when needed and persists a user bound to it.
|
|
func (s *Service) RegisterAccount(ctx context.Context, company *domain.Company, user *domain.User, password string) error {
|
|
if company != nil {
|
|
if company.ID == uuid.Nil {
|
|
company.ID = uuid.Must(uuid.NewV7())
|
|
company.IsVerified = false
|
|
if err := s.repo.CreateCompany(ctx, company); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if _, err := s.repo.GetCompany(ctx, company.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
user.CompanyID = company.ID
|
|
}
|
|
|
|
return s.CreateUser(ctx, user, password)
|
|
}
|
|
|
|
// Authenticate validates credentials and emits a signed JWT.
|
|
func (s *Service) Authenticate(ctx context.Context, identifier, password string) (string, time.Time, error) {
|
|
// Try fetching by username first
|
|
user, err := s.repo.GetUserByUsername(ctx, identifier)
|
|
if err != nil {
|
|
// Try fetching by email
|
|
user, err = s.repo.GetUserByEmail(ctx, identifier)
|
|
if err != nil {
|
|
// Return generic error to avoid leaking DB details or user existence
|
|
return "", time.Time{}, errors.New("invalid credentials")
|
|
}
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(s.pepperPassword(password))); err != nil {
|
|
return "", time.Time{}, errors.New("invalid credentials")
|
|
}
|
|
|
|
// Check if company is verified
|
|
if user.CompanyID != uuid.Nil {
|
|
_, err := s.repo.GetCompany(ctx, user.CompanyID)
|
|
if err != nil {
|
|
// If company not found, something is wrong with data integrity, prevent login
|
|
return "", time.Time{}, errors.New("associated company not found")
|
|
}
|
|
// if !company.IsVerified {
|
|
// return "", time.Time{}, errors.New("account pending approval")
|
|
// }
|
|
}
|
|
|
|
return s.issueAccessToken(user)
|
|
}
|
|
|
|
func (s *Service) pepperPassword(password string) string {
|
|
if s.passwordPepper == "" {
|
|
return password
|
|
}
|
|
return password + s.passwordPepper
|
|
}
|
|
|
|
// RefreshToken validates the provided JWT and issues a new access token.
|
|
func (s *Service) RefreshToken(ctx context.Context, tokenStr string) (string, time.Time, error) {
|
|
claims, err := s.parseToken(tokenStr)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
|
|
if scope, ok := claims["scope"].(string); ok && scope != "" {
|
|
return "", time.Time{}, errors.New("invalid token scope")
|
|
}
|
|
|
|
sub, ok := claims["sub"].(string)
|
|
if !ok || sub == "" {
|
|
return "", time.Time{}, errors.New("invalid token subject")
|
|
}
|
|
|
|
userID, err := uuid.FromString(sub)
|
|
if err != nil {
|
|
return "", time.Time{}, errors.New("invalid token subject")
|
|
}
|
|
|
|
user, err := s.repo.GetUser(ctx, userID)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
|
|
return s.issueAccessToken(user)
|
|
}
|
|
|
|
// CreatePasswordResetToken generates a short-lived token for password reset.
|
|
func (s *Service) CreatePasswordResetToken(ctx context.Context, email string) (string, time.Time, error) {
|
|
user, err := s.repo.GetUserByEmail(ctx, email)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
|
|
expiresAt := time.Now().Add(passwordResetTTL)
|
|
claims := jwt.MapClaims{
|
|
"sub": user.ID.String(),
|
|
"scope": "password_reset",
|
|
}
|
|
signed, err := s.signToken(claims, expiresAt)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
return signed, expiresAt, nil
|
|
}
|
|
|
|
// ResetPassword validates the reset token and updates the user password.
|
|
func (s *Service) ResetPassword(ctx context.Context, tokenStr, newPassword string) error {
|
|
claims, err := s.parseToken(tokenStr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
scope, _ := claims["scope"].(string)
|
|
if scope != "password_reset" {
|
|
return errors.New("invalid token scope")
|
|
}
|
|
|
|
sub, ok := claims["sub"].(string)
|
|
if !ok || sub == "" {
|
|
return errors.New("invalid token subject")
|
|
}
|
|
|
|
userID, err := uuid.FromString(sub)
|
|
if err != nil {
|
|
return errors.New("invalid token subject")
|
|
}
|
|
|
|
user, err := s.repo.GetUser(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return s.UpdateUser(ctx, user, newPassword)
|
|
}
|
|
|
|
// VerifyEmail marks the user email as verified based on a JWT token.
|
|
func (s *Service) VerifyEmail(ctx context.Context, tokenStr string) (*domain.User, error) {
|
|
claims, err := s.parseToken(tokenStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if scope, ok := claims["scope"].(string); ok && scope != "" && scope != "email_verify" {
|
|
return nil, errors.New("invalid token scope")
|
|
}
|
|
|
|
sub, ok := claims["sub"].(string)
|
|
if !ok || sub == "" {
|
|
return nil, errors.New("invalid token subject")
|
|
}
|
|
|
|
userID, err := uuid.FromString(sub)
|
|
if err != nil {
|
|
return nil, errors.New("invalid token subject")
|
|
}
|
|
|
|
user, err := s.repo.GetUser(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !user.EmailVerified {
|
|
user.EmailVerified = true
|
|
if err := s.repo.UpdateUser(ctx, user); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Service) issueAccessToken(user *domain.User) (string, time.Time, error) {
|
|
expiresAt := time.Now().Add(s.tokenTTL)
|
|
claims := jwt.MapClaims{
|
|
"sub": user.ID.String(),
|
|
"role": user.Role,
|
|
"company_id": user.CompanyID.String(),
|
|
}
|
|
signed, err := s.signToken(claims, expiresAt)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
return signed, expiresAt, nil
|
|
}
|
|
|
|
func (s *Service) signToken(claims jwt.MapClaims, expiresAt time.Time) (string, error) {
|
|
claims["exp"] = expiresAt.Unix()
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString(s.jwtSecret)
|
|
}
|
|
|
|
func (s *Service) parseToken(tokenStr string) (jwt.MapClaims, error) {
|
|
if strings.TrimSpace(tokenStr) == "" {
|
|
return nil, errors.New("token is required")
|
|
}
|
|
|
|
claims := jwt.MapClaims{}
|
|
parser := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
|
token, err := parser.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (any, error) {
|
|
return s.jwtSecret, nil
|
|
})
|
|
if err != nil || !token.Valid {
|
|
return nil, errors.New("invalid token")
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
// VerifyCompany marks a company as verified.
|
|
func (s *Service) VerifyCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) {
|
|
company, err := s.repo.GetCompany(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
company.IsVerified = true
|
|
|
|
if err := s.repo.UpdateCompany(ctx, company); err != nil {
|
|
return nil, err
|
|
}
|
|
return company, nil
|
|
}
|
|
|
|
func (s *Service) ListReviews(ctx context.Context, filter domain.ReviewFilter, page, pageSize int) (*domain.ReviewPage, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
reviews, total, err := s.repo.ListReviews(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.ReviewPage{Reviews: reviews, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (s *Service) ListShipments(ctx context.Context, filter domain.ShipmentFilter, page, pageSize int) (*domain.ShipmentPage, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
shipments, total, err := s.repo.ListShipments(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.ShipmentPage{Shipments: shipments, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (s *Service) GetShippingSettings(ctx context.Context, vendorID uuid.UUID) (*domain.ShippingSettings, error) {
|
|
return s.repo.GetShippingSettings(ctx, vendorID)
|
|
}
|
|
|
|
func (s *Service) UpsertShippingSettings(ctx context.Context, settings *domain.ShippingSettings) error {
|
|
return s.repo.UpsertShippingSettings(ctx, settings)
|
|
}
|
|
|
|
func (s *Service) CreateAddress(ctx context.Context, address *domain.Address) error {
|
|
address.ID = uuid.Must(uuid.NewV7())
|
|
return s.repo.CreateAddress(ctx, address)
|
|
}
|
|
|
|
func (s *Service) ListAddresses(ctx context.Context, entityID uuid.UUID) ([]domain.Address, error) {
|
|
return s.repo.ListAddresses(ctx, entityID)
|
|
}
|
|
|
|
// UpdateAddress updates an existing address.
|
|
func (s *Service) UpdateAddress(ctx context.Context, addr *domain.Address, requester *domain.User) error {
|
|
// Verify ownership or Admin
|
|
existing, err := s.repo.GetAddress(ctx, addr.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if requester.Role != "Admin" && existing.EntityID != requester.ID && (requester.CompanyID == uuid.Nil || existing.EntityID != requester.CompanyID) {
|
|
return errors.New("unauthorized to update this address")
|
|
}
|
|
|
|
// Update fields
|
|
existing.Title = addr.Title
|
|
existing.ZipCode = addr.ZipCode
|
|
existing.Street = addr.Street
|
|
existing.Number = addr.Number
|
|
existing.Complement = addr.Complement
|
|
existing.District = addr.District
|
|
existing.City = addr.City
|
|
existing.State = addr.State
|
|
|
|
return s.repo.UpdateAddress(ctx, existing)
|
|
}
|
|
|
|
// DeleteAddress deletes an address.
|
|
func (s *Service) DeleteAddress(ctx context.Context, id uuid.UUID, requester *domain.User) error {
|
|
// Verify ownership or Admin
|
|
existing, err := s.repo.GetAddress(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if requester.Role != "Admin" && existing.EntityID != requester.ID && (requester.CompanyID == uuid.Nil || existing.EntityID != requester.CompanyID) {
|
|
return errors.New("unauthorized to delete this address")
|
|
}
|
|
|
|
return s.repo.DeleteAddress(ctx, id)
|
|
}
|
|
|
|
func (s *Service) ListManufacturers(ctx context.Context) ([]string, error) {
|
|
return s.repo.ListManufacturers(ctx)
|
|
}
|