flamingo.me/flamingo-commerce/v3@v3.11.0/w3cdatalayer/application/factory.go (about) 1 package application 2 3 import ( 4 "context" 5 "crypto/sha512" 6 "encoding/base64" 7 "encoding/hex" 8 "net/url" 9 "regexp" 10 "strconv" 11 "strings" 12 13 customerDomain "flamingo.me/flamingo-commerce/v3/customer/domain" 14 "flamingo.me/flamingo/v3/core/auth" 15 "flamingo.me/flamingo/v3/framework/flamingo" 16 "flamingo.me/flamingo/v3/framework/web" 17 "github.com/pkg/errors" 18 "go.opencensus.io/tag" 19 20 "flamingo.me/flamingo-commerce/v3/cart/domain/decorator" 21 productDomain "flamingo.me/flamingo-commerce/v3/product/domain" 22 "flamingo.me/flamingo-commerce/v3/w3cdatalayer/domain" 23 ) 24 25 type ( 26 // Factory is used to build new datalayers 27 Factory struct { 28 router *web.Router 29 logger flamingo.Logger 30 datalayerProvider domain.DatalayerProvider 31 webIdentityService *auth.WebIdentityService 32 customerIdentityService customerDomain.CustomerIdentityService 33 hashEncoder encoder 34 35 pageInstanceIDPrefix string 36 pageInstanceIDStage string 37 productMediaBaseURL string 38 productMediaURLPrefix string 39 productMediaThumbnailURLPrefix string 40 pageNamePrefix string 41 siteName string 42 locale string 43 defaultCurrency string 44 hashUserValues bool 45 regex *regexp.Regexp 46 } 47 48 // hexEncoder is a wrapper for hex.EncodeToString 49 hexEncoder struct{} 50 51 encoder interface { 52 EncodeToString(src []byte) string 53 } 54 ) 55 56 func (h *hexEncoder) EncodeToString(src []byte) string { 57 return hex.EncodeToString(src) 58 } 59 60 // Inject factory dependencies 61 func (s *Factory) Inject( 62 router2 *web.Router, 63 logger flamingo.Logger, 64 provider domain.DatalayerProvider, 65 webIdentityService *auth.WebIdentityService, 66 customerIdentityService customerDomain.CustomerIdentityService, 67 config *struct { 68 PageInstanceIDPrefix string `inject:"config:commerce.w3cDatalayer.pageInstanceIDPrefix,optional"` 69 PageInstanceIDStage string `inject:"config:commerce.w3cDatalayer.pageInstanceIDStage,optional"` 70 ProductMediaBaseURL string `inject:"config:commerce.w3cDatalayer.productMediaBaseUrl,optional"` 71 ProductMediaURLPrefix string `inject:"config:commerce.w3cDatalayer.productMediaUrlPrefix,optional"` 72 ProductMediaThumbnailURLPrefix string `inject:"config:commerce.w3cDatalayer.productMediaThumbnailUrlPrefix,optional"` 73 PageNamePrefix string `inject:"config:commerce.w3cDatalayer.pageNamePrefix,optional"` 74 SiteName string `inject:"config:commerce.w3cDatalayer.siteName,optional"` 75 Locale string `inject:"config:locale.locale,optional"` 76 DefaultCurrency string `inject:"config:commerce.w3cDatalayer.defaultCurrency,optional"` 77 HashUserValues bool `inject:"config:commerce.w3cDatalayer.hashUserValues,optional"` 78 HashEncoding string `inject:"config:commerce.w3cDatalayer.hashEncoding,optional"` 79 }, 80 ) { 81 s.router = router2 82 s.logger = logger.WithField(flamingo.LogKeyModule, "w3cdatalayer") 83 s.datalayerProvider = provider 84 s.webIdentityService = webIdentityService 85 s.customerIdentityService = customerIdentityService 86 87 s.pageInstanceIDPrefix = config.PageInstanceIDPrefix 88 s.pageInstanceIDStage = config.PageInstanceIDStage 89 s.productMediaBaseURL = config.ProductMediaBaseURL 90 s.productMediaURLPrefix = config.ProductMediaURLPrefix 91 s.productMediaThumbnailURLPrefix = config.ProductMediaThumbnailURLPrefix 92 s.pageNamePrefix = config.PageNamePrefix 93 s.siteName = config.SiteName 94 s.locale = config.Locale 95 s.defaultCurrency = config.DefaultCurrency 96 s.hashUserValues = config.HashUserValues 97 98 if config.HashUserValues { 99 switch config.HashEncoding { 100 case "base64url": 101 s.hashEncoder = base64.URLEncoding 102 case "hex": 103 s.hashEncoder = &hexEncoder{} 104 default: 105 s.logger.Warn("invalid configuration for commerce.w3cDatalayer.hashEncoding, using base64url encoding as fallback") 106 s.hashEncoder = base64.URLEncoding 107 } 108 } 109 110 regexString := "[,|;|\\|]" 111 r, err := regexp.Compile(regexString) 112 if err != nil { 113 panic(err) 114 } 115 s.regex = r 116 } 117 118 // BuildForCurrentRequest builds the datalayer for the current request 119 func (s Factory) BuildForCurrentRequest(ctx context.Context, request *web.Request) domain.Datalayer { 120 layer := s.datalayerProvider() 121 122 // get language from locale code configuration 123 language := "" 124 localeParts := strings.Split(s.locale, "-") 125 if len(localeParts) > 0 { 126 language = localeParts[0] 127 } 128 129 baseURL, err := s.router.Absolute(request, request.Request().URL.Path, nil) 130 if err != nil { 131 s.logger.Warn(errors.Wrap(err, "cannot build absolute url")) 132 baseURL = new(url.URL) 133 } 134 layer.Page = &domain.Page{ 135 PageInfo: domain.PageInfo{ 136 PageID: request.Request().URL.Path, 137 PageName: s.pageNamePrefix + request.Request().URL.Path, 138 DestinationURL: baseURL.String(), 139 Language: language, 140 }, 141 Attributes: make(map[string]interface{}), 142 } 143 144 layer.Page.Attributes["currency"] = s.defaultCurrency 145 146 // Use the handler name as PageId if available 147 if controllerHandler, ok := tag.FromContext(ctx).Value(web.ControllerKey); ok { 148 layer.Page.PageInfo.PageID = controllerHandler 149 } 150 151 layer.SiteInfo = &domain.SiteInfo{ 152 SiteName: s.siteName, 153 } 154 155 layer.PageInstanceID = s.pageInstanceIDPrefix + s.pageInstanceIDStage 156 157 // Handle User 158 layer.Page.Attributes["loggedIn"] = false 159 layer.Page.Attributes["logintype"] = "guest" 160 161 identity := s.webIdentityService.Identify(ctx, request) 162 if identity != nil { 163 // logged in 164 layer.Page.Attributes["loggedIn"] = true 165 layer.Page.Attributes["logintype"] = "external" 166 userData := s.getUserFromIdentity(ctx, identity) 167 if userData != nil { 168 layer.User = append(layer.User, *userData) 169 } 170 } 171 172 return *layer 173 } 174 175 func (s Factory) getUserFromIdentity(ctx context.Context, identity auth.Identity) *domain.User { 176 if identity == nil { 177 return nil 178 } 179 180 customer, err := s.customerIdentityService.GetByIdentity(ctx, identity) 181 if err != nil { 182 return nil 183 } 184 185 dataLayerProfile := s.getUserProfile(customer.GetPersonalData().MainEmail, identity.Subject()) 186 if dataLayerProfile == nil { 187 return nil 188 } 189 190 dataLayerUser := domain.User{} 191 dataLayerUser.Profile = append(dataLayerUser.Profile, *dataLayerProfile) 192 return &dataLayerUser 193 } 194 195 func (s Factory) getUserProfile(email string, sub string) *domain.UserProfile { 196 dataLayerProfile := domain.UserProfile{ 197 ProfileInfo: domain.UserProfileInfo{ 198 EmailID: s.HashValueIfConfigured(email), 199 ProfileID: s.HashValueIfConfigured(sub), 200 }, 201 } 202 return &dataLayerProfile 203 } 204 205 // HashValueIfConfigured returns the hashed `value` if hashing is configured 206 func (s Factory) HashValueIfConfigured(value string) string { 207 if s.hashUserValues && value != "" { 208 return s.hashWithSHA512(value) 209 } 210 return value 211 } 212 213 // BuildCartData builds the domain cart data 214 func (s Factory) BuildCartData(cart decorator.DecoratedCart) *domain.Cart { 215 cartData := domain.Cart{ 216 CartID: cart.Cart.ID, 217 Price: &domain.CartPrice{ 218 Currency: cart.Cart.GrandTotal.Currency(), 219 BasePrice: cart.Cart.SubTotalNet.FloatAmount(), 220 CartTotal: cart.Cart.GrandTotal.FloatAmount(), 221 Shipping: cart.Cart.ShippingNet.FloatAmount(), 222 ShippingMethod: strings.Join(cart.Cart.AllShippingTitles(), "/"), 223 PriceWithTax: cart.Cart.GrandTotal.FloatAmount(), 224 }, 225 Attributes: make(map[string]interface{}), 226 } 227 for _, item := range cart.GetAllDecoratedItems() { 228 itemData := s.buildCartItem(item, cart.Cart.GrandTotal.Currency()) 229 cartData.Item = append(cartData.Item, itemData) 230 } 231 return &cartData 232 } 233 234 // BuildTransactionData builds the domain transaction data 235 func (s Factory) BuildTransactionData(ctx context.Context, cart decorator.DecoratedCart, decoratedItems []decorator.DecoratedCartItem, orderID string, email string) *domain.Transaction { 236 identity := s.webIdentityService.Identify(ctx, web.RequestFromContext(ctx)) 237 profile := s.getUserProfile(email, "") 238 239 if identity != nil { 240 user := s.getUserFromIdentity(ctx, identity) 241 if user != nil { 242 profile = &user.Profile[0] 243 } 244 } 245 246 transactionData := domain.Transaction{ 247 TransactionID: orderID, 248 Price: &domain.TransactionPrice{ 249 Currency: cart.Cart.GrandTotal.Currency(), 250 BasePrice: cart.Cart.GrandTotal.FloatAmount(), 251 TransactionTotal: cart.Cart.GrandTotal.FloatAmount(), 252 Shipping: cart.Cart.ShippingNet.FloatAmount(), 253 ShippingMethod: strings.Join(cart.Cart.AllShippingTitles(), "/"), 254 }, 255 Profile: profile, 256 Attributes: make(map[string]interface{}), 257 } 258 for _, item := range decoratedItems { 259 itemData := s.buildCartItem(item, cart.Cart.GrandTotal.Currency()) 260 transactionData.Item = append(transactionData.Item, itemData) 261 } 262 return &transactionData 263 } 264 265 func (s Factory) buildCartItem(item decorator.DecoratedCartItem, currencyCode string) domain.CartItem { 266 cartItem := domain.CartItem{ 267 Category: s.getProductCategory(item.Product), 268 Quantity: item.Item.Qty, 269 ProductInfo: s.getProductInfo(item.Product), 270 Price: domain.CartItemPrice{ 271 BasePrice: item.Item.SinglePriceNet.FloatAmount(), 272 PriceWithTax: item.Item.SinglePriceGross.FloatAmount(), 273 TaxRate: item.Item.TotalTaxAmount().FloatAmount(), 274 Currency: currencyCode, 275 }, 276 Attributes: make(map[string]interface{}), 277 } 278 cartItem.Attributes["sourceId"] = item.Item.SourceID 279 cartItem.Attributes["terminal"] = "" 280 cartItem.Attributes["leadtime"] = "" 281 return cartItem 282 } 283 284 // BuildProductData builds the domain product data 285 func (s Factory) BuildProductData(product productDomain.BasicProduct) domain.Product { 286 productData := domain.Product{ 287 ProductInfo: s.getProductInfo(product), 288 Category: s.getProductCategory(product), 289 Attributes: make(map[string]interface{}), 290 } 291 292 // check for defaultVariant 293 baseData := product.BaseData() 294 saleableData := product.SaleableData() 295 if product.Type() == productDomain.TypeConfigurable { 296 if configurable, ok := product.(productDomain.ConfigurableProduct); ok { 297 defaultVariant, _ := configurable.GetDefaultVariant() 298 baseData = defaultVariant.BaseData() 299 saleableData = defaultVariant.SaleableData() 300 } 301 } 302 303 // set prices 304 productData.Attributes["productPrice"] = strconv.FormatFloat(saleableData.ActivePrice.GetFinalPrice().FloatAmount(), 'f', 2, 64) 305 306 // check for highstreet price 307 if baseData.HasAttribute("rrp") { 308 productData.Attributes["highstreetPrice"] = baseData.Attributes["rrp"].Value() 309 } 310 311 // if FinalPrice is discounted, add it to specialPrice 312 if saleableData.ActivePrice.IsDiscounted { 313 productData.Attributes["specialPrice"] = strconv.FormatFloat(saleableData.ActivePrice.Discounted.FloatAmount(), 'f', 2, 64) 314 productData.Attributes["productPrice"] = strconv.FormatFloat(saleableData.ActivePrice.Default.FloatAmount(), 'f', 2, 64) 315 } 316 317 // set badge 318 productData.Attributes["badge"] = s.EvaluateBadgeHierarchy(product) 319 320 if product.BaseData().HasAttribute("ispuLimitedToAreas") { 321 replacer := strings.NewReplacer("[", "", "]", "") 322 productData.Attributes["ispuLimitedToAreas"] = strings.Split(replacer.Replace(product.BaseData().Attributes["ispuLimitedToAreas"].Value()), " ") 323 } 324 return productData 325 } 326 327 func (s Factory) getProductCategory(product productDomain.BasicProduct) *domain.ProductCategory { 328 level0 := "" 329 level1 := "" 330 level2 := "" 331 332 categoryPath := product.BaseData().MainCategory.Path 333 baseData := product.BaseData() 334 335 firstPathLevels := strings.Split(categoryPath, "/") 336 if len(firstPathLevels) > 0 { 337 level0 = firstPathLevels[0] 338 } 339 if len(firstPathLevels) > 1 { 340 level1 = firstPathLevels[1] 341 } 342 if len(firstPathLevels) > 2 { 343 level2 = firstPathLevels[2] 344 } 345 346 productFamily := "" 347 if baseData.HasAttribute("gs1Family") { 348 productFamily = baseData.Attributes["gs1Family"].Value() 349 } 350 return &domain.ProductCategory{ 351 PrimaryCategory: level0, 352 SubCategory1: level1, 353 SubCategory2: level2, 354 SubCategory: level1, 355 ProductType: productFamily, 356 } 357 } 358 359 func (s Factory) getProductInfo(product productDomain.BasicProduct) domain.ProductInfo { 360 baseData := product.BaseData() 361 retailerCode := baseData.RetailerCode 362 363 productName := s.regex.ReplaceAllString(baseData.Title, "-") 364 365 // Handle Variants if it is a Configurable 366 var parentIDRef *string 367 var variantSelectedAttributeRef *string 368 if product.Type() == productDomain.TypeConfigurableWithActiveVariant { 369 if configurableWithActiveVariant, ok := product.(productDomain.ConfigurableProductWithActiveVariant); ok { 370 parentID := configurableWithActiveVariant.ConfigurableBaseData().MarketPlaceCode 371 parentIDRef = &parentID 372 variantSelectedAttribute := configurableWithActiveVariant.ActiveVariant.BaseData().Attributes[configurableWithActiveVariant.VariantVariationAttributes[0]].Value() 373 variantSelectedAttributeRef = &variantSelectedAttribute 374 } 375 } 376 if product.Type() == productDomain.TypeConfigurable { 377 if configurable, ok := product.(productDomain.ConfigurableProduct); ok { 378 parentID := configurable.BaseData().MarketPlaceCode 379 parentIDRef = &parentID 380 381 defaultVariant, err := configurable.GetDefaultVariant() 382 if err == nil { 383 retailerCode = defaultVariant.RetailerCode 384 } 385 } 386 } 387 // Search for some common product attributes to fill the productInfos (This maybe better to be configurable later) 388 color := "" 389 if baseData.HasAttribute("manufacturerColor") { 390 color = baseData.Attributes["manufacturerColor"].Value() 391 } 392 if baseData.HasAttribute("baseColor") { 393 color = baseData.Attributes["baseColor"].Value() 394 } 395 size := "" 396 if baseData.HasAttribute("shoeSize") { 397 size = baseData.Attributes["shoeSize"].Value() 398 } 399 if baseData.HasAttribute("clothingSize") { 400 size = baseData.Attributes["clothingSize"].Value() 401 } 402 brand := "" 403 if baseData.HasAttribute("brandCode") { 404 brand = baseData.Attributes["brandCode"].Value() 405 } 406 gtin := "" 407 if baseData.HasAttribute("gtin") { 408 if baseData.Attributes["gtin"].HasMultipleValues() { 409 gtins := baseData.Attributes["gtin"].Values() 410 gtin = strings.Join(gtins, ",") 411 } else { 412 gtin = baseData.Attributes["gtin"].Value() 413 } 414 } 415 416 return domain.ProductInfo{ 417 ProductID: baseData.MarketPlaceCode, 418 ProductName: productName, 419 ProductThumbnail: s.getProductThumbnailURL(baseData), 420 ProductImage: s.getProductImageURL(baseData), 421 ProductType: product.Type(), 422 ParentID: parentIDRef, 423 VariantSelectedAttribute: variantSelectedAttributeRef, 424 Retailer: retailerCode, 425 Brand: brand, 426 SKU: gtin, 427 Manufacturer: brand, 428 Color: color, 429 Size: size, 430 InStock: strconv.FormatBool(baseData.IsInStock()), 431 } 432 } 433 434 func (s Factory) getProductThumbnailURL(baseData productDomain.BasicProductData) string { 435 media := baseData.GetMedia("thumbnail") 436 if media.Reference != "" { 437 return s.productMediaBaseURL + s.productMediaThumbnailURLPrefix + media.Reference 438 } 439 media = baseData.GetMedia("list") 440 if media.Reference != "" { 441 return s.productMediaBaseURL + s.productMediaThumbnailURLPrefix + media.Reference 442 } 443 return "" 444 } 445 446 func (s Factory) getProductImageURL(baseData productDomain.BasicProductData) string { 447 media := baseData.GetMedia("detail") 448 if media.Reference != "" { 449 return s.productMediaBaseURL + s.productMediaURLPrefix + media.Reference 450 } 451 return "" 452 } 453 454 func (s Factory) hashWithSHA512(value string) string { 455 newHash := sha512.New() 456 newHash.Write([]byte(value)) 457 // the hash is a byte array 458 result := newHash.Sum(nil) 459 // since we want to use it in a variable we base64 encode it (other alternative would be Hexadecimal representation "% x", h.Sum(nil) 460 return s.hashEncoder.EncodeToString(result) 461 } 462 463 // BuildChangeQtyEvent builds the change quantity domain event 464 func (s Factory) BuildChangeQtyEvent(productIdentifier string, productName string, qty int, qtyBefore int, cartID string) domain.Event { 465 event := domain.Event{EventInfo: make(map[string]interface{})} 466 event.EventInfo["productId"] = productIdentifier 467 event.EventInfo["productName"] = productName 468 event.EventInfo["cartId"] = cartID 469 470 if qty == 0 { 471 event.EventInfo["eventName"] = "Remove Product" 472 } else { 473 event.EventInfo["eventName"] = "Update Quantity" 474 event.EventInfo["quantity"] = qty 475 } 476 return event 477 } 478 479 // BuildAddToBagEvent builds the add to bag domain event 480 func (s Factory) BuildAddToBagEvent(productIdentifier string, productName string, qty int) domain.Event { 481 event := domain.Event{EventInfo: make(map[string]interface{})} 482 event.EventInfo["eventName"] = "Add To Bag" 483 event.EventInfo["productId"] = productIdentifier 484 event.EventInfo["productName"] = productName 485 event.EventInfo["quantity"] = qty 486 487 return event 488 } 489 490 // EvaluateBadgeHierarchy get the active badge by product 491 func (s *Factory) EvaluateBadgeHierarchy(product productDomain.BasicProduct) string { 492 badge := "" 493 494 if product.BaseData().HasAttribute("airportBadge") { 495 badge = "airportBadge" 496 } else if product.BaseData().HasAttribute("retailerBadge") { 497 badge = "retailerBadge" 498 } else if product.BaseData().HasAttribute("exclusiveProduct") && product.BaseData().Attributes["exclusiveProduct"].Value() == "true" { 499 badge = "travellerExclusive" 500 } else if product.BaseData().IsNew { 501 badge = "new" 502 } 503 504 return badge 505 }