In this post I’ll show you how we resolved a use case where the customer wanted to have notifications to content editors when they publish changes to a master page which has language versions.
“Requirement”
Notify editors that they should review content in other languages when they make changes to the master language content. Notification(s) should not be too annoying so no notifications during save – which means we can’t use IValidate<T> implementation to give feedback to user (documentation and a blog posting) as that would be triggered everytime content is saved.
Episerver user notification framework to the rescue
The Episerver user notification was released as beta long time ago, something like CMS core 9.4 (maybe?). Anyways currently is not in the beta release anymore and it is there for us to use it, documentation. So we decided to give it a shot to solve our notification needs as that’s what it is for.
Implementation
The “pseudo code” for the implementation is as follows:
- have an initialization module to hook up to the content published event
- register our notification channel with immediate delivery
- when content is published our notification code should be executed
- our notification code should check
- is the published content of certain page type
- is it the master language version and other languages exists of the content
- notify the content editor that other languages exists
- handle situation where the publisher is different user from the content editor
- notify both users
- the notification should have link to the master language and to all other language versions
- handle situation where the publisher is different user from the content editor
The following code is “Proof-of-concept” done with the Episerver Alloy demo site. So you could grab the code from gist and change namespace and start experimenting with code in your local demo site. The code is commented so I don’t think I need to repeat the same comments in the blog content.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using AlloyWithFind.Models.Pages; | |
using EPiServer; | |
using EPiServer.Core; | |
using EPiServer.Editor; | |
using EPiServer.Framework; | |
using EPiServer.Framework.Initialization; | |
using EPiServer.Logging; | |
using EPiServer.Notification; | |
using EPiServer.ServiceLocation; | |
namespace AlloyWithFind.Business.Initialization | |
{ | |
[InitializableModule] | |
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))] | |
public class UserNotificationsInitialization : IInitializableModule | |
{ | |
private static readonly ILogger logger = LogManager.GetLogger(typeof(UserNotificationsInitialization)); | |
/// <summary> | |
/// Name of the channel this notification demo uses. | |
/// </summary> | |
public const string NotificationChannelName = "Sample.MasterLanguage.Published"; | |
private IContentEvents contentEvents = null; | |
private IContentLoader contentLoader = null; | |
private IContentVersionRepository contentVersionRepository = null; | |
private INotifier notifier = null; | |
private static readonly INotificationUser NotificationSender = new NotificationUser("SystemNotificationSender"); | |
private bool isInitialized = false; | |
public void Initialize(InitializationEngine context) | |
{ | |
if (!isInitialized) | |
{ | |
// this is just personal preference to get the reference to local variable | |
ServiceLocationHelper serviceLocationHelper = context.Locate; | |
contentEvents = serviceLocationHelper.ContentEvents() ?? throw new Exception("Failed to get IContentEvents from initialization engine context."); | |
contentLoader = serviceLocationHelper.ContentLoader() ?? throw new Exception("Failed to get IContentLoader from initialization engine context."); | |
contentVersionRepository = serviceLocationHelper.Advanced.GetInstance<IContentVersionRepository>() ?? throw new Exception("Failed to get IContentVersionRepository from initialization engine context."); | |
notifier = serviceLocationHelper.Advanced.GetInstance<INotifier>() ?? throw new Exception("Failed to get INotifier from initialization engine context."); | |
// register the channel with immediate delivery | |
RegisterChannel(serviceLocationHelper.Advanced.GetInstance<INotificationChannelOptionsRegistry>()); | |
contentEvents.PublishedContent += ContentEventsPublishedContent; | |
isInitialized = true; | |
} | |
} | |
private void RegisterChannel(INotificationChannelOptionsRegistry notificationChannelOptionsRegistry) | |
{ | |
if (notificationChannelOptionsRegistry == null) | |
{ | |
throw new ArgumentNullException(nameof(notificationChannelOptionsRegistry)); | |
} | |
// is the channel already registered | |
var channelOptions = notificationChannelOptionsRegistry.Get(NotificationChannelName); | |
if (channelOptions == null) | |
{ | |
// register the channel to immediatly notify user | |
//NOTE: the channel option seems not to be persisted currently (EPiServer.CMS NuGet, version 11.12.0) | |
//so this code is executed everytime initialization happens | |
notificationChannelOptionsRegistry.Add(NotificationChannelName, new NotificationChannelOptions(true)); | |
logger.Debug($"Registered notification channel '{NotificationChannelName}' with immediate delivery option."); | |
} | |
else | |
{ | |
logger.Debug($"Notification channel '{NotificationChannelName}' already registered."); | |
} | |
} | |
private void ContentEventsPublishedContent(object sender, ContentEventArgs e) | |
{ | |
// we are only interested of pages inherited from StandardPage in this demo | |
// not using SitePageData because container page inherits it – but you get the idea | |
if (e?.Content is StandardPage page) | |
{ | |
logger.Debug(page, (state) => | |
{ | |
// if debug is not on, this will never get executed | |
return $"PublishedContent, IsMasterLanguage: '{state.IsMasterLanguageBranch}', has other languages: '{state.ExistingLanguages.Count() > 1}'."; | |
}); | |
// we are only interested about pages that are published in master language and have other language versions | |
// if a language version exists it doesn't mean that the language version is published (we don't check that for now) | |
if (page.IsMasterLanguageBranch && page.ExistingLanguages.Count() > 1 && TryGetReceivers(page, out List<NotificationUser> receivers)) | |
{ | |
// reference without version information | |
ContentReference contentWithoutVersion = page.ContentLink.ToReferenceWithoutVersion(); | |
// get the master lanuage edit link | |
string masterCultureName = page.MasterLanguage.Name; | |
string masterPageEditUrl = PageEditing.GetEditUrlForLanguage(contentWithoutVersion, masterCultureName); | |
// with PageEditing.GetEditUrlForLanguage we get a link like this: | |
//http://localhost:12345/EPiServer/CMS//?language=en#context=epi.cms.contentdata:///5 | |
// create a list of "urls" for the other languages | |
var otherLanguages = page.ExistingLanguages | |
.Where(ci => !ci.Name.Equals(masterCultureName, StringComparison.OrdinalIgnoreCase)) | |
.Select(ci => CreateLink(PageEditing.GetEditUrlForLanguage(contentWithoutVersion, ci.Name), ci.Name)); | |
NotificationMessage notification = new NotificationMessage() | |
{ | |
ChannelName = NotificationChannelName, | |
Content = $"Master language page '{CreateLink(masterPageEditUrl, page.Name)}' was just published which has language versions. Please review laguage versions if those require modifications: {string.Join(", ", otherLanguages)}.", | |
Recipients = receivers, | |
Sender = NotificationSender, | |
Subject = "Page with language versions published", | |
TypeName = "PagePublished" | |
}; | |
try | |
{ | |
// always nice to call async code from synchronous code | |
var task = notifier.PostNotificationAsync(notification); | |
// blocking hack: https://msdn.microsoft.com/en-us/magazine/mt238404.aspx | |
// PostNotificationAsync uses ConfigureAwait(false) so this shouldn't deadlock | |
task.GetAwaiter().GetResult(); | |
} | |
catch (Exception ex) | |
{ | |
logger.Error($"Failed to send notification messages to users about published master language page (id: {page.ContentLink?.ID}) with language versions.", ex); | |
} | |
} | |
} | |
} | |
private static string CreateLink(string url, string linkText) | |
{ | |
return $"<a style=\"color:#0000FF;text-decoration:underline;\" href=\"{url}\">{linkText}</a>"; | |
} | |
/// <summary> | |
/// Tries to get the recipients from the page changed by or created by and who published tha page. | |
/// </summary> | |
/// <param name="page">the page instance</param> | |
/// <param name="recipients">recipients if the method returns true otherwise empty list</param> | |
/// <param name="getPublisher">should the username of the published also fetched and included to the recipients</param> | |
/// <returns>true if a username was taken from page ChangedBy or CreatedBy property or false if no username was found</returns> | |
private bool TryGetReceivers(PageData page, out List<NotificationUser> recipients, bool getPublisher = true) | |
{ | |
bool success = false; | |
recipients = new List<NotificationUser>(2); | |
if (page != null) | |
{ | |
string editorUser = null; | |
if (!string.IsNullOrWhiteSpace(page.ChangedBy)) | |
{ | |
editorUser = page.ChangedBy; | |
recipients.Add(new NotificationUser(editorUser)); | |
success = true; | |
} | |
else if (!string.IsNullOrWhiteSpace(page.CreatedBy)) | |
{ | |
editorUser = page.CreatedBy; | |
recipients.Add(new NotificationUser(editorUser)); | |
success = true; | |
} | |
if (getPublisher) | |
{ | |
// NOTE: ChangedBy/CreatedBy user is not the same thing as who published the content but it can be | |
// publisher info is not stored to the page data, the publish state change is stored to 'tblWorkContent' | |
// so you could view that info in SQL query like this: select * from tblWorkContent where fkContentID = 138 | |
// where the fkContentID is your content id | |
// get version info | |
string publisherUsername = contentVersionRepository.LoadPublished(page.ContentLink)?.StatusChangedBy; | |
// also check that the publisher is not the same user as the editor user | |
if (!string.IsNullOrWhiteSpace(publisherUsername) && !publisherUsername.Equals(editorUser, StringComparison.OrdinalIgnoreCase)) | |
{ | |
recipients.Add(new NotificationUser(publisherUsername)); | |
success = true; | |
} | |
} | |
if (!success) | |
{ | |
logger.Warning($"Failed to get notification users for page id: {page.ContentLink?.ID}."); | |
} | |
} | |
return success; | |
} | |
public void Uninitialize(InitializationEngine context) | |
{ | |
if (isInitialized && contentEvents != null) | |
{ | |
contentEvents.PublishedContent -= ContentEventsPublishedContent; | |
isInitialized = false; | |
// on purpose not setting the service references to null | |
} | |
} | |
} | |
} |
Notification displayed to the content editor
Wrap-up
The code might not be totally polished but it shows the idea how to use the Episerver user notifications framework. The only “challenge” in the code is the “how to correctly call async from sync code”. Stephen Cleary has blogged a lot about the feared deadlock in ASP.NET (I’m still afraid of that deadlock-monster, and would rather have just sync code or all the way async). So the sample code is using so called “blocking hack” to call async code from sync (Writer of the article is surprise surprise – Stephen Cleary ). He has also created AsyncEx helper library – and the “blocking hack” is included also there in the TaskExtensions.cs file as extension method ‘WaitAndUnwrapException’ (NuGet packages available from NuGet feed).
Other thing is that currently the implementation relies on the Episerver built-in scheduled job to clean read notifications, but that will only remove notifications that are older than three months. So next step will be to have a custom job to clean up these notifications (maybe a short follow up post on how to implement the clean up job).