Umbraco Notifications: How to Handle Removing Commerce Product Variants

In Umbraco CMS, Notifications provide a powerful mechanism to hook into the backoffice workflow, allowing developers to execute custom code at various stages of content management processes, such as before or after a page is published. This system, which resembles the Observer pattern, operates within the Umbraco.Cms.Core.Notifications namespace. Developers can create and register notification handlers to perform actions at specific moments, enhancing the CMS’s functionality and allowing for highly customised content management workflows. For detailed guidance on utilising Notifications in Umbraco, please visit Umbraco’s official documentation.

Identifying and Removing Deleted Product Variants from Carts

I aimed to automatically remove products from the carts if they were deleted in the backoffice. While I successfully used ContentMovingToRecycleBinNotification for removed products, I encountered challenges identifying when a product variant was deleted, as there was no specific notification for this scenario. After consulting support without finding a direct solution, I discovered SendingContentNotification, find out more here, which activates just before content is loaded for editing in the backoffice. Although it didn’t directly indicate deleted variants, it provided a workaround. By caching the published variants and comparing them with the current ones, I was able to identify the removed product variants, effectively solving my problem.

How do we Implement this

A brief overview of how I accomplished the removal of product variants from carts:

  • I created a CartService equipped with the logic to remove product variants from carts.
  • I established an OrderService designed to search for product variants in carts using either ProductReference or SKU.
  • I implemented a CacheService to store the product variants, enabling us to compare them with current product variants to identify which ones have been removed.
  • I developed the NotificationHandler, which contains the critical logic where all our efforts converge.

Registering your Dependencies

Remember to register your service with Dependency Injection (DI). To learn more about this in Umbraco, you can read further here. Another valuable resource is the Umbraco CMS Dependency Injection and IoC documentation.

Setup the Cart Service

The cart service will assist us in removing product variants from all carts. We will use a Data Transfer Object (DTO) model to capture the OrderId and OrderLineId of each found product variant. We will then perform a bulk removal operation to eliminate all identified product variants from the carts.

Creating the Model

namespace MyProject.Models.Dtos;

public class BulkRemoveFromCartDto
{
    public Guid OrderId { get; set; }
    public Guid OrderLineId { get; set; }
}

Creating the Interface

using MyProject.Models.Dtos;

namespace MyProject.Core.Services.Interfaces;

public interface ICartService
{
    void BulkRemoveFromCart(IEnumerable<BulkRemoveFromCartDto> orders);
}

Implementing the Interface

using MyProject.Core.Services.Interfaces;
using MyProject.Models.Dtos;
using Microsoft.Extensions.Logging;
using Umbraco.Commerce.Core.Api;

namespace MyProject.Core.Services;

public sealed class CartService : ICartService
{
    private readonly IUmbracoCommerceApi _commerceApi;
    private readonly ILogger<CartService> _logger;

    public CartService(
        IUmbracoCommerceApi commerceApi,
        ILogger<CartService> logger
    )
    {
        _commerceApi = commerceApi;
        _logger = logger;
    }

    public void BulkRemoveFromCart(IEnumerable<BulkRemoveFromCartDto> orders)
    {
        try
        {
            _commerceApi.Uow.Execute(uow =>
            {
                foreach (var order in orders)
                {
                    var currentOrder = _commerceApi.GetOrder(order.OrderId)
                                        .AsWritable(uow)
                                        .RemoveOrderLine(order.OrderLineId);

                    _commerceApi.SaveOrder(currentOrder!);
                }

                uow.Complete();
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "An error occurred while removing the order line items");
            throw new Exception("Failed to remove product from the carts.", ex.InnerException);
        }
    }
}

Setup the Order Service

The OrderService will assist us in locating the products we have identified as removed. We achieve this by utilising the SearchOrders method from Umbraco’s IUmbracoCommerceApi interface.

Creating the Interface

using Umbraco.Commerce.Core.Models;

namespace MyProject.Core.Services.Interfaces;

public interface IOrderService
{
    IReadOnlyList<OrderReadOnly> GetOrdersByProductReferenceOrSku(string productReferenceOrSku);
}

Implementing the Interface

using MyProject.Core.Services.Interfaces;
using Microsoft.Extensions.Logging;
using Umbraco.Commerce.Common.Models;
using Umbraco.Commerce.Core.Api;
using Umbraco.Commerce.Core.Models;

namespace MyProject.Core.Services;

public sealed class OrderService : IOrderService
{
    private readonly IUmbracoCommerceApi _commerceApi;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IUmbracoCommerceApi commerceApi,
        ILogger<OrderService> logger
    )
    {
        _commerceApi = commerceApi;
        _logger = logger;
    }

    public IReadOnlyList<OrderReadOnly> GetOrdersByProductReferenceOrSku(string productReferenceOrSku)
    {
        try
        {
            var orders = new List<OrderReadOnly>();

            int currentPage = 1;
            int itemsPerPage = 50;
            var searchOrders = GetSearchOrders(productReferenceOrSku, currentPage, itemsPerPage);
            if (searchOrders == null || searchOrders.TotalItems == 0) return new List<OrderReadOnly>();

            orders.AddRange(searchOrders.Items);

            while (searchOrders.TotalPages > currentPage)
            {
                currentPage++;
                searchOrders = GetSearchOrders(productReferenceOrSku, currentPage, itemsPerPage);
                orders.AddRange(searchOrders.Items);
            }

            return orders;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, $"An error occurred while getting orders by product reference or sku {productReferenceOrSku}");
            throw new Exception($"An error occurred while getting orders by product reference or sku {productReferenceOrSku}", ex.InnerException);
        }
    }

    private PagedResult<OrderReadOnly> GetSearchOrders(string productReferenceOrSku, int currentPage, int itemsPerPage)
    {
        return _commerceApi.SearchOrders(
            (x) => x.HasOrderLineWithProduct(productReferenceOrSku),
            currentPage,
            itemsPerPage
        );
    }
}

Setup the Cache Service

The CacheService will assist us in storing product variants and using them to compare with the current ones to determine which have been removed.

Creating the Interface

namespace MyProject.Core.Services.Interfaces;

public interface ICacheService
{
    bool Exists<T>(string key) where T : class;
    T? Get<T>(string key) where T : class;
    void Add<T>(string key, T value) where T : class;
    void Add<T>(string key, T value, TimeSpan timeout) where T : class;
    void Remove(string key);
}

Implementing the Interface

using MyProject.Core.Services.Interfaces;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Cache;

namespace MyProject.Core.Services;

public sealed class CacheService : ICacheService
{
    private readonly AppCaches _appCaches;
    private readonly ILogger<CacheService> _logger;

    public CacheService(
        AppCaches appCaches, 
        ILogger<CacheService> logger
    )
    {
        _appCaches = appCaches;
        _logger = logger;
    }

    public bool Exists<T>(string key) where T : class
        => Get<T>(key) is not null;

    public void Add<T>(string key, T value) where T : class
        => Add(key, value, DateTime.Now.AddMinutes(60).TimeOfDay);

    public void Add<T>(string key, T value, TimeSpan timeout) where T : class
    {
        if (!ValidateKey(key)) throw new InvalidOperationException("Invalid cache key.");

        if (value == null) throw new InvalidOperationException("Value is incorrect.");

        try
        {
            _appCaches.RuntimeCache.InsertCacheItem(key, () => value, timeout);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, $"Failed to add value to cache. {ex.Message}");
            throw new Exception("Failed to add value to cache.", ex.InnerException);
        }
    }

    public T? Get<T>(string key) where T : class
    {
        if (!ValidateKey(key)) throw new InvalidOperationException("Invalid cache key.");

        var value = _appCaches.RuntimeCache.Get(key) as T;

        if (value == null) return default;

        return value;
    }

    public void Remove(string key)
    {
        try
        {
            _appCaches.RuntimeCache.Clear(key);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, $"Failed to delete value from cache. {ex.Message}");
            throw new Exception("Failed to delete value from cache.", ex.InnerException);
        }
    }

    private static bool ValidateKey(string key) => !string.IsNullOrWhiteSpace(key);
}

Setup the Notification Handler

The NotificationHandler will be utilised to intercept changes made in the backoffice and to identify which product variants have been removed, leveraging the CacheService or by searching with the OrderService. This process allows us to locate all carts that contain those product variants, with the aid of the OrderService, and then meticulously remove them from each cart using the CartService. Please remember to register your notification handler. For more details on how to accomplish this in Umbraco, refer to the provided reading material here.

Implementing the Notification Handler

using MyProject.Core.Services.Interfaces;
using MyProject.Models.Dtos;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Commerce.Core.Models;

namespace MyProject.Core.Handlers
{
    public class RemoveProductNotificationHandler : INotificationHandler<SendingContentNotification>
    {
        private readonly ICartService _cartService;
        private readonly IOrderService _orderService;
        private readonly ICacheService _cacheService;

        public RemoveProductNotificationHandler(
            ICartService cartService,
            IOrderService orderService,
            ICacheService cacheService
        )
        {
            _cartService = cartService;
            _orderService = orderService;
            _cacheService = cacheService;
        }

        public void Handle(SendingContentNotification notification)
        {
            // Check if the request is a post save request to reduce running the handler on every request
            bool isPostSave = notification.UmbracoContext
                                .OriginalRequestUrl.AbsolutePath
                                .ToLower().Contains("postsave");
            if (!isPostSave) return;

            // Make sure the content is a product page
            var content = notification.Content;
            if (!content.ContentTypeAlias.Equals("productPage")) return;

            // Get the product skus from the content
            var productSkus = content.Variants
                                .SelectMany(x => x.Tabs)
                                .Where(x => x.Alias is not null &&
                                            x.Alias.Equals("uc_variants") &&
                                            x.Properties is not null)
                                .SelectMany(x => x.Properties!)
                                .Where(x => x.Alias.Equals("variants") &&
                                            x.Value is not null)
                                .Select(x => x.Value!.SafeCast<BlockValue>())
                                .Where(x => x is not null)
                                .SelectMany(x => x!.ContentData)
                                .Where(x => x.ContentTypeAlias.Equals("productMultiVariant"))
                                .SelectMany(x => x.PropertyValues)
                                .Where(x => x.Key.Equals("sku") && x.Value.Value is not null)
                                .Select(x => x.Value.Value!.ToString()!)
                                .ToList();

            // Get the product key, create cache key, and get removed product variants
            var productKey = content.Key ?? Guid.Empty;
            var cacheKey = $"LATEST_PRODUCT_VARIANTS_FOR_PRODUCT_WITH_KEY_{productKey.ToString().ToUpper()}_CACHE_KEY";
            var removedProductVariants = GetRemovedProductVariants(productKey, productSkus, cacheKey);

            // Add the current product variants to cache
            AddProductVariantKeysToCache(productSkus, cacheKey);

            // Remove the removed product variants from carts
            if (!removedProductVariants.Any()) return;
            RemoveProductsFromCartsBySkus(removedProductVariants);
        }

        private IReadOnlyList<string> GetRemovedProductVariants(Guid productKey, List<string> productSkus, string cacheKey)
        {
            var productVariants = new List<string>();

            // Check if the product variants are already cached, else get them from database
            var isExistingCachedProductVariants = _cacheService.Exists<IReadOnlyList<string>>(cacheKey);
            if (!isExistingCachedProductVariants)
            {
                var orderLineSkus = GetOrderLineSkus(productKey.ToString());
                productVariants.AddRange(orderLineSkus);
            }
            else
            {
                productVariants = _cacheService.Get<List<string>>(cacheKey);
            }

            if (productVariants is null || !productVariants.Any()) return new List<string>();

            // Determine the removed product variants
            var removedProductVariants = productVariants.Except(productSkus).ToList();
            return removedProductVariants;
        }

        private void AddProductVariantKeysToCache(List<string> productSkus, string cacheKey)
        {
            var isExistingCachedProductVariants = _cacheService.Exists<IReadOnlyList<string>>(cacheKey);
            if (isExistingCachedProductVariants) _cacheService.Remove(cacheKey);
            _cacheService.Add<IReadOnlyList<string>>(cacheKey, productSkus);
        }

        private IReadOnlyList<string> GetOrderLineSkus(string productReferenceOrSku)
        {
            var orderSkus = GetOrderLines(productReferenceOrSku)
                                .Select(orderLine => orderLine.Sku)
                                .ToList();

            return orderSkus;
        }

        private IReadOnlyList<BulkRemoveFromCartDto> GetOrdersToRemove(string productReferenceOrSku)
        {
            var removeOrders = GetOrderLines(productReferenceOrSku)
                                .Select(orderLine => new BulkRemoveFromCartDto
                                    {
                                        OrderLineId = orderLine.Id,
                                        OrderId = orderLine.OrderId
                                    })
                                    .ToList();

            return removeOrders;
        }

        private IReadOnlyList<OrderLineReadOnly> GetOrderLines(string productReferenceOrSku)
        {
            // Get the order lines by product reference or sku
            var foundOrders = _orderService.GetOrdersByProductReferenceOrSku(productReferenceOrSku);
            var orderLines = foundOrders.SelectMany(order => order.OrderLines
                                            .Where(orderLine => orderLine.ProductReference.Equals(productReferenceOrSku) ||
                                                                orderLine.Sku.Equals(productReferenceOrSku)))
                                            .ToList();
            return orderLines;
        }

        private void RemoveProductsFromCartsBySkus(IEnumerable<string> skus)
        {
            foreach (var sku in skus)
            {
                var removeOrders = GetOrdersToRemove(sku);
                if (!removeOrders.Any()) continue;

                _cartService.BulkRemoveFromCart(removeOrders);
            }
        }
    }
}

Register the Notification Handler

public static IUmbracoBuilder AddNotificationHandlers(this IUmbracoBuilder builder)
{
    builder.AddNotificationHandler<SendingContentNotification, RemoveProductNotificationHandler>();

    return builder;
}

Test Removing a Product Variant

Test this by adding a product variant you have created to your cart on the frontend. Then, in the backoffice, remove that product variant, click “Save and Publish,” and it should trigger the NotificationHandler we set up. Feel free to make any necessary adjustments to fit your scenario.

Latest