Recently Viewed Pages Block using Profile Store in Episerver/Optimizely

Recently Viewed Pages Block using Profile Store in Episerver/Optimizely

We recently needed to add a recently viewed pages feature in a site for a client using the Profile Store from Episerver/Optimizely and it was not as easy as we though would be, Fortunately, we had a lot of help from these blogs (Using KQL to list popular content from Profile Store and Listing popular content with Profile Store), which allowed us to not start from scratch. Unfortunately, those blogs were not getting the recently viewed pages for an specific user, they were getting the most popular content based on all users interactions. So, in this blogs post we are going to expand what we learned from those blogs to get what we need by using a different KQL query. This is going to be a longer blog post than usual so I hope you can read it to the end.

First, we will start creating a new block which will have just a heading and and an aria label field.

using EPiServer.Core;
using EPiServer.DataAbstraction;
using EPiServer.DataAnnotations;
using System.ComponentModel.DataAnnotations;

namespace Foundation.Cms.Features.Common.Blocks.RecentlyViewed
{
    [ContentType(DisplayName = "Recently Viewed",
        GUID = "01d514cb-b845-4eef-bf97-b53a4e2cfd3a",
        Description = "Display the user's recently viewed content.",
        GroupName = "Content")]
    public class RecentlyViewedBlock : BlockData
    {
        [Required]
        [Display(Name = "Heading", GroupName = "Content", Order = 10)]
        public virtual string Heading { get; set; }

        #region Accessibility 

        [CultureSpecific]
        [Display(Name = "Module Title", Description = "If not managed, Heading will be used instead.", GroupName = "Accessibility", Order = 10)]
        public virtual string ModuleAriaLabel
        {
            get
            {
                var title = this.GetPropertyValue(p => p.ModuleAriaLabel);

                return !string.IsNullOrWhiteSpace(title)
                    ? title
                    : Heading;
            }
            set => this.SetPropertyValue(p => p.ModuleAriaLabel, value);
        }

        #endregion

        public override void SetDefaultValues(ContentType contentType)
        {
            base.SetDefaultValues(contentType);

            Heading = "Recently Viewed";
            ModuleAriaLabel = "Recently Viewed";
        }
    }
}

We will also need a recently viewed block view model which will include the just created block plus the recently viewed pages that we got from the Profile Store.

using Foundation.Cms.Models.Data;
using System.Collections.Generic;

namespace Foundation.Cms.Features.Common.Blocks.RecentlyViewed
{
    public class RecentlyViewedBlockViewModel
    {
        public RecentlyViewedBlock CurrentBlock { get; set; }

        public List<RecentlyViewedResponse> RecentlyViewed { get; set; }

        public RecentlyViewedBlockViewModel(RecentlyViewedBlock currentBlock)
        {
            CurrentBlock = currentBlock;
        }
    }
}

The recently viewed response is a POCO model with the information we need for our implementation, but can be anything you need.

namespace Foundation.Cms.Models.Data
{
    public class RecentlyViewedResponse
    {
        public string Format { get; set; }
        public string Title { get; set; }
        public string Description { get; set; }
        public string Url { get; set; }
    }
}

Now, we go to the block controller which will use the recently viewed helper to get the recently viewed pages which are going to be send to the view as part of the view model.

using EPiServer.Framework.DataAnnotations;
using EPiServer.Web.Mvc;
using System.Collections.Generic;
using System.Web.Mvc;
using Foundation.Cms.Features.Common.Blocks.RecentlyViewed;
using Foundation.Cms.Helpers;

namespace Foundation.Website.Features.Common.Blocks.RecentlyViewed
{
    [TemplateDescriptor(Default = true)]
    public class RecentlyViewedBlockController : BlockController<RecentlyViewedBlock>
    {
        public override ActionResult Index(RecentlyViewedBlock currentContent)
        {
            var model = new RecentlyViewedBlockViewModel(currentContent)
            {
                RecentlyViewed = RecentlyViewedHelper.GetRecentlyViewedFromProfileStore(User)
                   ?? new List<Cms.Models.Data.RecentlyViewedResponse>()
            };

            return this.PartialView("~/Features/Common/Blocks/RecentlyViewed/RecentlyViewedBlock.cshtml", model);
        }
    }
}

The RecentlyViewedHelper class implementation works as a middleware between the controller and the ProfileStoreHelper, and it is responsible to transform what we get from the Profile Store Api to the POCO Models we use in the view. Pay special attention to the first lines of the class which tries to get the email from the authenticated user. If the user is not authenticated it will get all recent pages from all users. Also, it is important to mention that we do setup the recently viewed pages response with the data our project needed. You can return whatever is needed in yours. There are also some helper methods in this class like methods ExternalUrl, GetContent and GetBaseUrl which are there for completion purposes.

using EPiServer;
using EPiServer.Core;
using EPiServer.Globalization;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using EPiServer.Web.Routing;
using System.Collections.Generic;
using System.Security.Principal;
using System.Threading;
using Foundation.Cms.Models.Pages;
using Foundation.Cms.Models.Data;
using System.Web;
using EPiServer.Cms.Shell;

namespace Foundation.Cms.Helpers
{
    public static class RecentlyViewedHelper
    {
        private static readonly Injected<IContentRepository> _contentRepository;

        public static List<RecentlyViewedResponse> GetRecentlyViewedFromProfileStore(IPrincipal user)
        {
            var contactInformation = user != null && user.Identity.IsAuthenticated
                ? user.Identity.Name // Should be the email from the user
                : null;

            var lstRecentlyViewed = new List<RecentlyViewedResponse>();
            var results = ProfileStoreHelper.GetRecentPages(SiteDefinition.Current.Id,
                Thread.CurrentThread.CurrentCulture.TwoLetterISOLanguageName, contactInformation,
            20, 168);

            foreach (var item in results)
            {
                var page = GetContent<FoundationPageData>(item.ContentLink);

                if (page == null || page.ExcludeFromTracking)
                {
                    continue;
                }

                var metaTitle = page.OpenGraphTitle ?? page.MetaTitle ?? page.Name ?? string.Empty;
                var description = !string.IsNullOrEmpty(page.PageDescription)
                    ? page.PageDescription
                    : page.Teaser ?? string.Empty;

                var itemResult = new RecentlyViewedResponse()
                {
                    Title = metaTitle,
                    Description = description,
                    Format = page.GetOriginalType().ToString(),
                    Url = ExternalUrl(page.ContentLink)
                };

                lstRecentlyViewed.Add(itemResult);
            }

            return lstRecentlyViewed;
        }

        private static T GetContent<T>(ContentReference contentReference) where T : IContentData
        {
            if (contentReference == null) return default;

            return _contentRepository.Service.TryGet(contentReference, out T content) ? content : default;
        }

        private static string GetBaseUrl(bool includeLanguage = false)
        {
            var request = HttpContext.Current.Request;
            var currentLanguage = ContentLanguage.PreferredCulture;
            var currentSiteStartPage = GetContent<PageData>(SiteDefinition.Current?.StartPage);

            if (currentSiteStartPage != null && includeLanguage && !currentSiteStartPage.IsMasterLanguageBranch())
            {
                return $"{request.Url.Scheme}://{request.Url.Authority}/{currentLanguage.Name.ToLower()}";
            }

            return $"{request.Url.Scheme}://{request.Url.Authority}";
        }

        private static string ExternalUrl(ContentReference contentReference, string parameters = "")
        {
            var baseUrl = GetBaseUrl();

            if (baseUrl.EndsWith("/"))
            {
                baseUrl = baseUrl.TrimEnd('/');
            }

            var contentPath = ServiceLocator.Current.GetInstance<UrlResolver>().GetUrl(contentReference);

            if (!contentPath.StartsWith("/"))
            {
                contentPath += '/';
            }

            var contentUrl = string.Concat(baseUrl, contentPath);
            var fullUrl = $"{contentUrl}{parameters}";
            return fullUrl;
        }
    }
}

Before we move forward with the ProfileStoreHelper implementation, we are going to show the FoundationPageData class which was used in the code above as base page class to all our pages. This foundation page have several fields that allows us to get the information we are going to display like: Title, Description and the most important field ExcludeFromTracking, which allows the editor to mark a page as excluded from tracking. If it is excluded, it will not be taken into account as a recently viewed page.

using EPiServer.Core;
using EPiServer.DataAnnotations;
using EPiServer.Web;
using System.ComponentModel.DataAnnotations;

namespace Foundation.Cms.Models.Pages
{
    public abstract class FoundationPageData : PageData
    {
        // Basic page data which all other pages inherit

        [CultureSpecific]
        [Required]
        [UIHint(UIHint.Textarea)]
        [Display(Name = "Teaser", Description = "Brief description of the page. This content will be displayed on the listing pages.",
            GroupName = "Content", Order = 5000)]
        public virtual string Teaser { get; set; }

        [CultureSpecific]
        [Display(Name = "Title", GroupName = "Metadata", Order = 100)]
        public virtual string MetaTitle
        {
            get
            {
                var metaTitle = this.GetPropertyValue(p => p.MetaTitle);

                return !string.IsNullOrWhiteSpace(metaTitle)
                    ? metaTitle
                    : PageName;
            }
            set => this.SetPropertyValue(p => p.MetaTitle, value);
        }

        [CultureSpecific]
        [UIHint(UIHint.Textarea)]
        [Display(Name = "Page description", GroupName = "Metadata", Order = 300)]
        public virtual string PageDescription { get; set; }
        
        [CultureSpecific]
        [Display(Name = "Open Graph Title", Description = "If managed, this field will overwrite the Page Title value that has been managed for the current page",
            GroupName = "Metadata", Order = 400)]
        public virtual string OpenGraphTitle { get; set; }

        [CultureSpecific]
        [Display(Name = "Exclude from Tracking", Description = "This will determine whether or not to track the page.",
            GroupName = "Settings", Order = 110)]
        public virtual bool ExcludeFromTracking { get; set; }
    }
}

The most crucial component of this implementation is the ProfileStoreHelper which is in charge to call the Profile Store Api using a KQL query to get the most recently viewed pages by user. We use several variables which come from the web.config file so we can change them easily in other environments. Here are some of those variables and also the endpoint we use to get the events from the Profile Store.

VariableDescriptionValue
_apiRootUrlFrom web.config file, is the profile Api base Url which is given to your account manager.https://profilesapi-something.profilestore.episerver.net
_appKey From web.config file, is the profile Api subscription key given to your account manager. ALONGAPIKEY
_scope From web.config file, the scope used to track events, if not set by default is the string “default”. default
_eventUrlStatic, it is the endpoint to get the events from the Profile Store/api/v2.0/TrackEvents/preview

The KQL in this implementation is also quite different from the one to get the most popular ones. We needed to summarize by content guid and event time first. Then, order those results ascending by content guid and descending by event time . After that, we extend the dataset with the row number per content guid, so each time a different content guid appears it starts again from zero. Then we filter all the other rows and only get the ones with row number zero because is the most recent one per content guid and finally return the top n items descending by event time again. Using the results from the KQL query we then transform each content guid found to an IContent enumerable and return back the results to the RecentlyViewedHelper.

using EPiServer;
using EPiServer.Core;
using EPiServer.ServiceLocation;
using Newtonsoft.Json;
using RestSharp;
using System;
using System.Collections.Generic;
using System.Configuration;

namespace Foundation.Cms.Helpers
{
    public static class ProfileStoreHelper
    {
        //Settings
        private const string _eventUrl = "/api/v2.0/TrackEvents/preview";
        private static readonly string _apiRootUrl = ConfigurationManager.AppSettings["episerver:profiles.ProfileApiBaseUrl"];
        private static readonly string _appKey = ConfigurationManager.AppSettings["episerver:profiles.ProfileApiSubscriptionKey"];
        private static readonly string _scope = ConfigurationManager.AppSettings["episerver:profiles.Scope"] ?? "default";

        private static readonly Injected<IContentLoader> _contentLoader;
        
        /// <summary>
        /// Get all recent content regardless of type
        /// </summary>
        public static IEnumerable<IContent> GetRecentPages(Guid siteId, string lang, string userEmail, int resultCount = 5, int recentHours = 24)
        {
            var hits = GetRecentContentResponse(siteId, lang, userEmail, resultCount, recentHours);
            var recent = new List<IContent>();
            if (hits?.Items != null)
            {
                foreach (var hit in hits.Items)
                {
                    try
                    {
                        var item = _contentLoader.Service.Get<IContent>(hit.Value);
                        recent.Add(item);
                    }
                    catch (Exception e)
                    {
                        Console.WriteLine(e.Message);
                    }
                }
            }

            return recent;
        }
        
        /// <summary>
        /// Make request to profile store API
        /// </summary>
        private static RecentContentResponse GetRecentContentResponse(Guid siteId, string lang, string userEmail, int resultCount = 5, int recentHours = 24, int typeId = 0)
        {
            var requestBody = $"{{\"Query\": \"{GenerateKqlRecentQuery(siteId, lang, userEmail, resultCount, recentHours, typeId)}\", \"Scope\": \"{_scope}\" }}";
            var req = new RestRequest(_eventUrl, Method.POST);
            req.AddHeader("Authorization", $"epi-single {_appKey}");
            req.AddParameter("application/json-patch+json", requestBody, ParameterType.RequestBody);
            req.RequestFormat = DataFormat.Json;
            req.AddJsonBody(requestBody);
            var client = new RestClient(_apiRootUrl);
            var getEventResponse = client.Execute(req);
            return JsonConvert.DeserializeObject<RecentContentResponse>(getEventResponse.Content);
        }
        
        private static string GenerateKqlRecentQuery(Guid siteId, string lang, string userEmail, int resultCount = 5, int recentHours = 24, int typeId = 0)
        {
            var kqlQueryObj = @"Events 
            | where EventTime between(ago({0}h) .. now()) 
                and EventType == 'epiPageView' 
                and Payload.epi.language == '{1}' 
                and Payload.epi.siteId == '{2}'
                {3} 
                {4} 
            | summarize by Value = tostring(Payload.epi.contentGuid), EventTime 
            | order by Value asc, EventTime desc 
            | extend rowNumber = row_number(0, Value != prev(Value)) 
            | where rowNumber == 0 
            | top {5} by EventTime desc";
            //Only add type restriction if a type has been specified
            var userQuery = !string.IsNullOrEmpty(userEmail) ? $"and User.Email == '{userEmail}'" : string.Empty;
            var typeQuery = typeId > 0 ? $"and Payload.epi.typeId == {typeId}" : string.Empty;
            return string.Format(kqlQueryObj, recentHours, lang, siteId.ToString(), userQuery, typeQuery, resultCount);
        }
    }
}

The response POCO models from the Profile Store Api are implemented as follows:

using System;

namespace Foundation.Cms.Helpers
{
    public class RecentContentResponse
    {
        public int Count { get; set; }
        public RecentContentResponseItem[] Items { get; set; }
    }

    public class RecentContentResponseItem
    {
        public Guid Value { get; set; }
    }
}

With all these in place we can then implement the view which is going to display the results we got from the Profile Store Api. For our case, we used an slider with a for each loop to display each recently viewed page found, but in your scenario you can implement it as you want.

@model Foundation.Cms.Features.Common.Blocks.RecentlyViewed.RecentlyViewedBlockViewModel

@if (Model?.RecentlyViewed == null || !Model.RecentlyViewed.Any())
{
    return;
}

<section class="cards-slider cards-slider--recently-viewed container" aria-label="@Model.CurrentBlock.ModuleAriaLabel">
    <div class="cards-slider__inner">
        <div class="cards-slider__content" @Html.EditAttributes(x => x.CurrentBlock.Heading)>
            <h2 class="cards-slider__heading">
                @Html.DisplayFor(x => x.CurrentBlock.Heading)
            </h2>
        </div>
    </div>
    <div class="cards-slider__cards" data-module="content-cards-slider">
        <div class="cards-slider__cards-container swiper-container">
            <div class="swiper-wrapper">
                @foreach (var recommendation in Model.RecentlyViewed)
                {
                    <div class="swiper-slide">
                        <a href="@recommendation.Url" aria-label="Recently Viewed Slide" class="content-card  ">
                            <img src="@recommendation.Format" alt="Document icon" width="20" height="25">
                            <div role="presentation" class="icon-divider"></div>
                            <strong class="content-card__heading">
                                @recommendation.Title
                            </strong>
                            <p class="content-card__copy content-card__copy-slide">
                                @recommendation.Description
                            </p>
                            <span class="content-card__faux-button">
                                <svg viewBox="0 0 13 13"
                                     role="presentation">
                                    <use xlink:href="#arrow-right" />
                                </svg>
                            </span>
                        </a>
                    </div>
                }
            </div>
        </div>
        <div class="slider-nav container ">
            <p class="slider-nav__pager"></p>
            <button class="slider-nav__arrow slider-nav__arrow--prev" aria-label="Previous">
                <svg viewBox="0 0 320 202"
                     width="25"
                     height="16"
                     role="presentation">
                    <use xlink:href="#prev" />
                </svg>
            </button>
            <button class="slider-nav__arrow slider-nav__arrow--next" aria-label="Next">
                <svg viewBox="0 0 320 202"
                     width="25"
                     height="16"
                     role="presentation">
                    <use xlink:href="#next" />
                </svg>
            </button>
        </div>
    </div>
</section>

This is what you need to get the most recently viewed pages from the Profile Store Api, but what about the tracking part?. Well you can have two different implementations. The first one is use the PageViewTracking attribute in each page controller you want to track and the other option is to create your own implementation so you can decide what is going to be send to the profile store. You can find more information about tracking in this link, but below we will show our current implementation for the project. Pay special attention to the comments related to what user information is going to be send to the Profile Store because we use the email as part of the identifiers in the KQL for recently viewed pages.

using EPiServer;
using EPiServer.Core;
using EPiServer.ServiceLocation;
using EPiServer.Tracking.Core;
using EPiServer.Tracking.PageView;
using EPiServer.Web;
using Microsoft.AspNet.Identity;
using System.Linq;
using System.Security.Principal;
using System.Threading.Tasks;
using System.Web;

namespace Foundation.Cms.ExternalApis.ProfileStore
{
    public class PageViewTracker
    {
        private readonly Injected<ITrackingService> _trackingService;
        private readonly Injected<IContentLoader> _contentLoader;
        private const string _formsTrackingEventType = "epiPageView";

        public async Task Track(PageData pageData, HttpContextBase httpContextBase, IPrincipal user)
        {
            var site = SiteDefinition.Current;
            var ancestors = _contentLoader.Service.GetAncestors(pageData.ContentLink).Select(c => c.ContentGuid).ToArray();
            var pageDataTrackingModel = new EpiPageViewWrapper
            {
                Epi = new EpiPageView
                {
                    ContentGuid = pageData.ContentGuid,
                    Language = pageData.Language?.Name,
                    SiteId = site.Id,
                    Ancestors = ancestors,
                }
            };

            var value = $"Viewed page {pageData.Name}";
            var userData = CreateUserData(user);
            var trackingData = new TrackingData<EpiPageViewWrapper>
            {
                EventType = _formsTrackingEventType,
                User = userData,
                Value = value,
                Payload = pageDataTrackingModel
            };

            await _trackingService.Service.Track(trackingData, httpContextBase);
        }

        private UserData CreateUserData(IPrincipal user)
        {
            var contactInformation = user != null && user.Identity.IsAuthenticated
                ? user.Identity // Should be the full profile from the user including the email (really important)
                : null;

            var name = contactInformation?.GetUserName() ?? string.Empty; // Get the name from the profile
            var email = contactInformation?.Name ?? string.Empty; // Get the email from the profile

            return new UserData()
            {
                Name = name,
                Email = email
            };
        }
    }
}

Finally, to use this tracker we implemented a base controller which will have the method TrackCurrentPage that will call the PageViewTracker class in order to send the information of the current page to the Profile Store if and only if the page is not excluded from tracking.

using EPiServer.Web.Mvc;
using Foundation.Cms.Models.Pages;
using Foundation.Cms.ExternalApis.ProfileStore;

namespace Foundation.Website.Features
{
    public class BaseController<T> : PageController<T> where T : FoundationPageData
    {
        private readonly PageViewTracker _pageViewTracker;

        public BaseController()
        {
            _pageViewTracker = new PageViewTracker();
        }

        public void TrackCurrentPage(T currentPage)
        {
            if (!currentPage.ExcludeFromTracking)
            {
                _pageViewTracker.Track(currentPage, HttpContext, User).ConfigureAwait(false);
            }
        }
    }
}

Now, if you want a page controller to have the tracking functionality you just have to inherit from it and then call the track current page method in the Index event before returning the view. Here is the CmsHomePage controller as an example.

using System.Web.Mvc;
using Foundation.Cms.Features.Home.Pages.CmsHome;

namespace Foundation.Website.Features.Home.Pages.CmsHome
{
    public class HomePageController : BaseController<CmsHomePage>
    {
        public ActionResult Index(CmsHomePage currentPage)
        {
            TrackCurrentPage(currentPage);
            return View(currentPage);
        }
    }
}

The CmsHomePage class is only a placeholder class which can be any other page model that you need to track, but we include the code below for completion purposes.

using EPiServer.DataAnnotations;
using Foundation.Cms.Models.Pages;

namespace Foundation.Cms.Features.Home.Pages.CmsHome
{
    [ContentType(DisplayName = "Home Page",
        GUID = "452d1812-7385-42c3-8073-c1b7481e7b20",
        Description = "Display a home page on the site.",
        AvailableInEditMode = true,
        GroupName = "Content")]
    public class CmsHomePage : FoundationPageData
    {
         // Do nothing
    }
}

And that is it, You can now have your own recently viewed pages feature in your site that uses the Episerver/Optimizely Profile Store Api. If you have any question let me know. I hope it will help someone and as always keep learning !!!

Written by:

Jorge Cardenas

Developer with several years of experience who is passionate about technology and how to solve problems through it.

View All Posts

Leave a Reply