Developer forum

Forum » Development » Automatically create redirects when product URLs change

Automatically create redirects when product URLs change

Ferri Halfhide
Reply

Hi Team,

We’re looking into implementing this ourselves through custom code and were wondering if anyone has already built something similar or could point us in the right direction.

Our use case is the following:

* Product URLs are generated based on the product name and category/channel path.
* When a product name (or category/channel name) changes, the product URL changes as well.
* We’d like to automatically create a 301 redirect from the old URL to the new one whenever this happens.

Care suggested implementing this by listening to:

* Notifications.Ecommerce.Product.BeforeSave
* Notifications.Ecommerce.Product.AfterSave

and then creating redirects using the Dynamicweb.Frontend.DirectPath.Path API.

Before we start building this, I was wondering:

* Has anyone already implemented something similar?
* Are there any examples or code snippets available?
* Are there any pitfalls we should be aware of (multiple product groups, multilingual URLs, meta URLs, redirect chains, etc.)?

Any guidance or experience would be greatly appreciated.

Thanks in advance!


Replies

 
Adrian Ursu Dynamicweb Employee
Adrian Ursu
Reply

Hi Ferri,
We have considered something similar ourselves, but did not go through with it.
The main challenge is the allocation of the product to multiple groups.
A change can occur either in group names (by changing the group name or product allocation) or product names. Both types of changes generate new URLs.
We initially considered saving the exact URL on the product every time there is a change to a product, a group, or an allocation. But this would only work for situations where a product is assigned to only one group.

In order to create all relevant redirections, you have to keep track of all product links for all Shop group paths. And this is where we decided to postpone building something.
If you only care about the product changes (product names), then those 2 notifications should be enough.

Adrian

 
Nicolai Pedersen Dynamicweb Employee
Nicolai Pedersen
Reply

FYI: We have just last week made a POC of a new URL system for DW10 - and it contains a history supporting both 301s for renames and 410s for deletes. But it is very early and POC only and no promise it will make in production.

Instead of trying to record history in redirects table

Change to high volume URLs. You can rename products as much as you want and the URL continues to work and redirect to the new URL. Also it performs better and for many results in better SEO since the SKU is in the URL.

To implement history - my notes:

We do not have this feature because it will in no time add potentially million of records to the URL table. So part of your solution should be to clean that table up.

A good place to start is to fill the URL field on a product and a group so that it will not change URLs before the 'use in url' field is changed - deliberately. That would make it much more manageable as simple renames of products will not change the URL.

A good way to start is to take a look at what is in Dynamicweb.Frontend.UrlHandling.Indexes - it contains all the URLindexes and their urls and querystrings. So that is where you should find the data you want to store in the redirect table.

Some sample code:

internal static string WriteNodes()
        {
            var stringBuilder = new StringBuilder();
            foreach (KeyValuePair<string, UrlIndex> index in UrlIndex.Indexes)
            {
                stringBuilder.AppendFormat(CultureInfo.InvariantCulture, "<h3>Index name: {0}</h3>", index.Key);
                foreach (UrlNode rootnode in index.Value.RootNodes)
                    WriteNode(rootnode, 0, stringBuilder);
            }

            return stringBuilder.ToString();
        }

        internal static void WriteNode(UrlNode node, int depth, StringBuilder builder)
        {
            string indent = string.Empty;
            for (int i = 0, loopTo = depth; i <= loopTo; i++)
                indent += "&nbsp;&nbsp;&nbsp;";
            builder.AppendLine(indent + node.PathName + string.Format(CultureInfo.InvariantCulture, " ({0}, {1}, Count: {2})", node.GetPath(), node.GetQueryString(), node.NodeCount()) + "<br>");
            foreach (UrlNode n in node.Children)
                WriteNode(n, depth + 1, builder);
        }

Below is how we reset the URL index - meaning when changes occur that affects the URLs. You can use the same concept and then figure out what to put in the redirect table. But just a warning that it is not easy and you will end up with data, collisions and other things you need to support and handle.

[Subscribe(Notifications.Ecommerce.Product.ProductGroupRelationAfterSave)]
    [Subscribe(Notifications.Ecommerce.Product.ProductGroupRelationDeleted)]
    public class ProductGroupRelationChangedObserver : NotificationSubscriber
    {
        public override void OnNotify(string notification, NotificationArgs args)
        {
            if (SystemConfiguration.Instance.GetBoolean("/Globalsettings/System/Url/HighVolumeProductUrls/Enable"))
                return;
            var needsReset = false;
            if (args is Notifications.Ecommerce.Product.ProductGroupRelationAfterSaveArgs productGroupRelationAfterSaveArgs)
            {
                var newGrouprelation = Services.ProductGroups.GetGroup(productGroupRelationAfterSaveArgs.GroupRelation.GroupId, Services.Languages.GetDefaultLanguageId());
                if (newGrouprelation is null || GroupService.IsGroupInEcommerceShop(newGrouprelation))
                {
                    needsReset = true;
                }
            }

            if (args is Notifications.Ecommerce.Product.ProductGroupRelationDeletedArgs productGroupRelationDeletedArgs)
            {
                var groupId = productGroupRelationDeletedArgs.GroupId;
                var languageId = productGroupRelationDeletedArgs.Group?.LanguageId ?? Services.Languages.GetDefaultLanguageId();
                
                var deletedGrouprelation = !string.IsNullOrEmpty(groupId)
                    ? Services.ProductGroups.GetGroup(groupId, languageId)
                    : null;

                if (deletedGrouprelation is null || GroupService.IsGroupInEcommerceShop(deletedGrouprelation))
                {
                    needsReset = true;
                }
            }
            if (needsReset)
            {
                lazyGroupProductRelationIndex = new Lazy<ConcurrentDictionary<string, List<string>>>(InitializeGroupProductRelationIndex);
                UrlHelper.SetResetNeeded();
            }
        }
    }

    [Subscribe(Notifications.Ecommerce.Product.AfterSave)]
    [Subscribe(Notifications.Ecommerce.Product.AfterDelete)]
    public class ProductChangedObserver : NotificationSubscriber
    {
        public override void OnNotify(string notification, NotificationArgs args)
        {
            if (SystemConfiguration.Instance.GetBoolean("/Globalsettings/System/Url/HighVolumeProductUrls/Enable"))
                return;
            var changeAffectUrlIndex = false;
            if (args is Dynamicweb.Ecommerce.Notifications.Ecommerce.Product.AfterSaveArgs afterSaveArgs)
            {
                var beforeSaveProduct = afterSaveArgs.ProductBeforeChanges;
                var afterSaveProduct = afterSaveArgs.Product;

                if (beforeSaveProduct is not null)
                {
                    if (beforeSaveProduct.Groups.Count > 0 && !beforeSaveProduct.Groups.Any(group => GroupService.IsGroupInEcommerceShop(group) is true))
                    {
                        return; //The product is not member of any ecommerce groups
                    }
                    changeAffectUrlIndex |= !string.Equals(beforeSaveProduct.Id, afterSaveProduct.Id, StringComparison.OrdinalIgnoreCase);
                    changeAffectUrlIndex |= !string.Equals(beforeSaveProduct.VariantId, afterSaveProduct.VariantId, StringComparison.OrdinalIgnoreCase);
                    changeAffectUrlIndex |= !string.Equals(beforeSaveProduct.LanguageId, afterSaveProduct.LanguageId, StringComparison.OrdinalIgnoreCase);
                    changeAffectUrlIndex |= !string.Equals(beforeSaveProduct.Name, afterSaveProduct.Name, StringComparison.OrdinalIgnoreCase);
                    changeAffectUrlIndex |= !string.Equals(beforeSaveProduct.Meta.Url, afterSaveProduct.Meta.Url, StringComparison.OrdinalIgnoreCase);
                    changeAffectUrlIndex |= beforeSaveProduct.Active != afterSaveProduct.Active;
                }
                else
                {
                    changeAffectUrlIndex = true;
                }
            }

            if (changeAffectUrlIndex)
            {
                lazyProductUrlDataIndex = new Lazy<ConcurrentDictionary<string, Dictionary<string, UrlDataNode>>>(InitializeProductUrlDataIndex);
                UrlHelper.SetResetNeeded();
            }
        }
    }

    [Subscribe(Notifications.Ecommerce.Group.AfterSave)]
    [Subscribe(Notifications.Ecommerce.Group.Deleted)]
    public class GroupChangedObserver : NotificationSubscriber
    {
        public override void OnNotify(string notification, NotificationArgs args)
        {
            var changeAffectUrlIndex = false;
            if (args is Dynamicweb.Ecommerce.Notifications.Ecommerce.Group.AfterSaveArgs afterSaveArgs)
            {
                var beforeSaveGroup = afterSaveArgs.GroupBeforeChanges;
                var afterSaveGroup = afterSaveArgs.Group;

                if (beforeSaveGroup is not null)
                {
                    if (GroupService.IsGroupInEcommerceShop(beforeSaveGroup))
                    {
                        changeAffectUrlIndex |= !string.Equals(beforeSaveGroup.Id, afterSaveGroup.Id, StringComparison.OrdinalIgnoreCase);
                        changeAffectUrlIndex |= !string.Equals(beforeSaveGroup.LanguageId, afterSaveGroup.LanguageId, StringComparison.OrdinalIgnoreCase);
                        changeAffectUrlIndex |= !string.Equals(beforeSaveGroup.Name, afterSaveGroup.Name, StringComparison.OrdinalIgnoreCase);
                        changeAffectUrlIndex |= !string.Equals(beforeSaveGroup.Meta.Url, afterSaveGroup.Meta.Url, StringComparison.OrdinalIgnoreCase);
                        changeAffectUrlIndex |= !beforeSaveGroup.NavigationShowInSiteMap.Equals(afterSaveGroup.NavigationShowInSiteMap);
                        changeAffectUrlIndex |= !beforeSaveGroup.Meta.UrlIgnoreParent.Equals(afterSaveGroup.Meta.UrlIgnoreParent);
                        changeAffectUrlIndex |= !beforeSaveGroup.Meta.UrlIgnoreForChildren.Equals(afterSaveGroup.Meta.UrlIgnoreForChildren);
                    }
                }
                else
                {
                    changeAffectUrlIndex = true;
                }
            }

            if (changeAffectUrlIndex)
            {
                lazyProductUrlDataIndex = new Lazy<ConcurrentDictionary<string, Dictionary<string, UrlDataNode>>>(InitializeProductUrlDataIndex);
                UrlHelper.SetResetNeeded();
            }
        }
    }

    [Subscribe(Notifications.Ecommerce.Group.RelationAdded)]
    public class GroupRelationChangedObserver : NotificationSubscriber
    {
        public override void OnNotify(string notification, NotificationArgs args)
        {
            if (args is Notifications.Ecommerce.Group.GroupRelationModifiedEventArgs groupRelationModifiedEventArgs)
            {
                var newParent = Services.ProductGroups.GetGroup(groupRelationModifiedEventArgs.ParentId, Services.Languages.GetDefaultLanguageId());
                var newChild = Services.ProductGroups.GetGroup(groupRelationModifiedEventArgs.ChildId, Services.Languages.GetDefaultLanguageId());
                var needsReset = false;
                if (newParent is not null && GroupService.IsGroupInEcommerceShop(newParent))
                {
                    needsReset = true;
                }
                if (newChild is not null && GroupService.IsGroupInEcommerceShop(newChild))
                {
                    needsReset = true;
                }

                if (needsReset)
                    UrlHelper.SetResetNeeded();
            }
        }
    }
 
Ferri Halfhide
Reply

Hi Nicolai,

Thanks for the additional information.

I was wondering about the High Volume URLs approach. How would we actually implement this in an existing solution? Is it mainly a configuration change, or does it require code changes and/or URL regeneration?

Also, what would be the impact of enabling it on an existing website? Since we only have a single product detail page, I’m trying to understand how this changes the way product URLs are generated and resolved.

The custom redirect solution is certainly interesting, but considering all the edge cases it seems like quite an extensive implementation. Therefore I’m wondering whether High Volume URLs would be the recommended approach in this scenario, and what would be involved in adopting it.

 
Nicolai Pedersen Dynamicweb Employee
Nicolai Pedersen
Reply
This post has been marked as an answer

It is simply a configuration change. But all product URLs will change (page and group urls stay the same) - so you would have that issue to deal with.

High volume URLs is different in the way it does not generate URLs for products - it uses the product SKU as the unique identifier to resolve the url to a product. so {whatever-name-the-product-has}-{sku} is the basic format (with some more details). This means that if the product name changes, the URL will change the first part - but it does not matter as it is the last part, the SKU part, that will be used to resolve it.

Custom redirect solution is almost guarenteed to make someone cry - probably your self :-).

Right now - the easiest thing you can do is to fill out the URL field of a product with its current URL. Then renaming the product will not change the URL and that will give you time to plan for the right direction forward - migrating to high volume or wait and see if we ship the new URL system in the fall.

Votes for this answer: 1

 

You must be logged in to post in the forum