diff --git a/veza-backend-api/internal/core/marketplace/service.go b/veza-backend-api/internal/core/marketplace/service.go index e7c6cb0c3..513d432ff 100644 --- a/veza-backend-api/internal/core/marketplace/service.go +++ b/veza-backend-api/internal/core/marketplace/service.go @@ -1114,37 +1114,47 @@ func (s *Service) AddProductPreview(ctx context.Context, productID uuid.UUID, se // UpdateProductImages replaces all images for a product (v0.401 M1) func (s *Service) UpdateProductImages(ctx context.Context, productID uuid.UUID, sellerID uuid.UUID, images []ProductImageInput) ([]ProductImage, error) { - var product Product - if err := s.db.First(&product, "id = ?", productID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrProductNotFound + // Wrap DELETE + loop-CREATE in a transaction so a failure mid-loop + // doesn't leave the product with zero images (the delete would + // otherwise already be committed). + var result []ProductImage + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var product Product + if err := tx.First(&product, "id = ?", productID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrProductNotFound + } + return err } + if product.SellerID != sellerID { + return ErrInvalidSeller + } + if err := tx.Where("product_id = ?", productID).Delete(&ProductImage{}).Error; err != nil { + return err + } + result = make([]ProductImage, 0, len(images)) + for i, img := range images { + if img.URL == "" { + continue + } + pi := &ProductImage{ + ProductID: productID, + URL: img.URL, + SortOrder: img.SortOrder, + } + if pi.SortOrder == 0 && i > 0 { + pi.SortOrder = i + } + if err := tx.Create(pi).Error; err != nil { + return err + } + result = append(result, *pi) + } + return nil + }) + if err != nil { return nil, err } - if product.SellerID != sellerID { - return nil, ErrInvalidSeller - } - if err := s.db.Where("product_id = ?", productID).Delete(&ProductImage{}).Error; err != nil { - return nil, err - } - result := make([]ProductImage, 0, len(images)) - for i, img := range images { - if img.URL == "" { - continue - } - pi := &ProductImage{ - ProductID: productID, - URL: img.URL, - SortOrder: img.SortOrder, - } - if pi.SortOrder == 0 && i > 0 { - pi.SortOrder = i - } - if err := s.db.Create(pi).Error; err != nil { - return nil, err - } - result = append(result, *pi) - } return result, nil } @@ -1159,35 +1169,45 @@ func (s *Service) GetProductLicenses(ctx context.Context, productID uuid.UUID) ( // SetProductLicenses replaces all licenses for a product (v0.401 M2) func (s *Service) SetProductLicenses(ctx context.Context, productID uuid.UUID, sellerID uuid.UUID, licenses []ProductLicenseInput) ([]ProductLicense, error) { - var product Product - if err := s.db.First(&product, "id = ?", productID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrProductNotFound + // Same DELETE+LOOP-CREATE atomicity concern as UpdateProductImages: + // a failure mid-loop would leave the product with zero licenses, + // making it unsellable until a retry. + var result []ProductLicense + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var product Product + if err := tx.First(&product, "id = ?", productID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrProductNotFound + } + return err } + if product.SellerID != sellerID { + return ErrInvalidSeller + } + if err := tx.Where("product_id = ?", productID).Delete(&ProductLicense{}).Error; err != nil { + return err + } + result = make([]ProductLicense, 0, len(licenses)) + for _, in := range licenses { + if in.LicenseType == "" || in.PriceCents < 0 { + continue + } + pl := &ProductLicense{ + ProductID: productID, + LicenseType: in.LicenseType, + PriceCents: in.PriceCents, + TermsText: in.TermsText, + } + if err := tx.Create(pl).Error; err != nil { + return err + } + result = append(result, *pl) + } + return nil + }) + if err != nil { return nil, err } - if product.SellerID != sellerID { - return nil, ErrInvalidSeller - } - if err := s.db.Where("product_id = ?", productID).Delete(&ProductLicense{}).Error; err != nil { - return nil, err - } - result := make([]ProductLicense, 0, len(licenses)) - for _, in := range licenses { - if in.LicenseType == "" || in.PriceCents < 0 { - continue - } - pl := &ProductLicense{ - ProductID: productID, - LicenseType: in.LicenseType, - PriceCents: in.PriceCents, - TermsText: in.TermsText, - } - if err := s.db.Create(pl).Error; err != nil { - return nil, err - } - result = append(result, *pl) - } return result, nil }