Set up custom workflow notifications

When using workflows in your content editing process, users in various roles need to work with items at different stages of the content life cycle. Notifications about workflow step transitions can help people keep track of their work. For example, users may wish to be notified when an item transitions to a step that requires their approval or review.

Developers can set up notifications by handling global events that the system triggers when items transition between workflow steps:

  • MoveToStep – triggered when a content item, page, or headless item is moved from one workflow step to another. Handled via the ContentItemWorkflowEvents, WebPageWorkflowEvents or HeadlessItemWorkflowEvents classes.
  • Publish – triggered when a content item, page or headless item finishes its workflow cycle and is published. Handled via the ContentItemEvents, WebPageEvents or HeadlessItemEvents classes.

Limitations:

  • The system currently does not provide events covering workflow transitions of emails.
  • Workflow events are not triggered when an item enters the system’s default Draft step for the first time in a workflow cycle, for example after creating a completely new item or a new version of a published or archived item. For newly added items, you can handle the Create event via the ContentItemEvents, WebPageEvents or HeadlessItemEvents classes.

Example

Prerequisites

To integrate the code of this example, prepare a custom Class Library project in your solution with the Kentico.Xperience.Admin NuGet package installed. The example uses administration-specific code, and assumes that workflow transitions are done through the admin UI.

To allow the system to send out emails, you also need to set up and configure an email client (for example an SMTP server or SendGrid integration). For more information, see Email configuration.

This example demonstrates how to set up basic email notifications informing about workflow step transitions. The example sends notifications when a reusable content item or website channel page is moved to a different step in any workflow. The recipients of the emails depend on the step:

  • Custom workflow steps – sent to users who are allowed to work with the item in the given step, i.e. all users belonging to the roles assigned to the step.
  • Draft (system step)Draft is the default first step in all workflows, and cannot have any roles assigned. The notification emails are instead sent to all users who have the Update permission for the related application. Note that notifications are not sent when an item enters the Draft step for the first time in a workflow cycle, for example after creating a completely new item or a new version of a published or archived item.

For the sake of simplicity, this example does not send notifications when pages or content items are published. However, you can set up publish notifications using a similar approach by handling the ContentItemEvents.Publish and WebPageItemEvents.Publish events.

You can expand or simplify the implementation based on your project’s content types and workflow steps, as well as the requirements of your content editors. If you wish to use a different type of notifications than emails, replace the email API calls in the example’s code (e.g., with an external messaging SDK).

To view the example’s code, download the following files or see the code blocks below:

Module class and handler assignment

using CMS;
using CMS.DataEngine;
using CMS.ContentWorkflowEngine;
using CMS.Core;
using CMS.EmailEngine;
using CMS.Membership;
using CMS.Websites;

using Kentico.Xperience.Admin.Base.UIPages;
using Kentico.Xperience.Admin.Websites.UIPages;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

// Registers the custom module into the system
[assembly: RegisterModule(typeof(Custom.CustomWorkflowModule))]

namespace Custom
{    
    public class CustomWorkflowModule : Module
    {
        private IApplicationPermissionInfoProvider applicationPermissionProvider;
        private IEmailService emailService;
        private IInfoProvider<ContentWorkflowStepInfo> workflowStepProvider;
        private IInfoProvider<ContentWorkflowStepRoleInfo> workflowStepRoleProvider;
        private IInfoProvider<WebsiteChannelInfo> websiteChannelInfoProvider;
        private IRoleInfoProvider roleInfoProvider;
        private IUserInfoProvider userInfoProvider;
        private IUserRoleInfoProvider userRoleInfoProvider;
        private IOptionsMonitor<SystemEmailOptions> systemEmailOptions;

        // Module class constructor, the system registers the module under the name "CustomWorkflow"
        public CustomWorkflowModule()
            : base(nameof(CustomWorkflowModule))
        {
        }

        // Contains initialization code that is executed when the application starts
        protected override void OnInit(ModuleInitParameters parameters)
        {
            base.OnInit();

            // Gets instances of required services
            applicationPermissionProvider = parameters.Services.GetRequiredService<IApplicationPermissionInfoProvider>();
            emailService = parameters.Services.GetRequiredService<IEmailService>();
            workflowStepProvider = parameters.Services.GetRequiredService<IInfoProvider<ContentWorkflowStepInfo>>();
            workflowStepRoleProvider = parameters.Services.GetRequiredService<IInfoProvider<ContentWorkflowStepRoleInfo>>();
            websiteChannelInfoProvider = parameters.Services.GetRequiredService<IInfoProvider<WebsiteChannelInfo>>();
            roleInfoProvider = parameters.Services.GetRequiredService<IRoleInfoProvider>();
            userInfoProvider = parameters.Services.GetRequiredService<IUserInfoProvider>();
            userRoleInfoProvider = parameters.Services.GetRequiredService<IUserRoleInfoProvider>();
            systemEmailOptions = parameters.Services.GetRequiredService<IOptionsMonitor<SystemEmailOptions>>();

            // Assigns a handler to the MoveToStep workflow event for reusable content items
            ContentItemWorkflowEvents.MoveToStep.Execute += ContentItem_MoveToStepEventHandler;

            // Assigns a handler to the MoveToStep workflow event for website channel pages
            WebPageWorkflowEvents.MoveToStep.Execute += WebPage_MoveToStepEventHandler;
        }

        // The event handlers and other helper methods can be added here
        // ...
    }
}
Email data class

// Contains properties for passing data to workflow notification email messages
public class EmailData
{
    public string CurrentStepDisplayName { get; set; }
    public string OriginalStepDisplayName { get; set; }
    public string StepChangedByUserName { get; set; }
    public string ItemName { get; set; }
    public string Recipients { get; set; }
}
Content item MoveToStep handler

// Handler for the MoveToStep event for content items
// Sends an email notification to all users who can work with the item in the current step
private void ContentItem_MoveToStepEventHandler(object sender, ContentItemWorkflowMoveToStepArguments e)
{
    ContentWorkflowStepInfo currentWorkflowStep = workflowStepProvider.Get(e.StepName);

    string recipients;
    
    // If the current step is the 'Draft' system step, gets the email addresses of all users
    // with the Update permission for the Content hub application.
    if (currentWorkflowStep.ContentWorkflowStepType == ContentWorkflowStepType.SystemDraft)
    {
        recipients = GetRecipientsForUserIds(GetUserIdsWithApplicationUpdatePermission(ContentHubApplication.IDENTIFIER));
    }
    // For custom workflow steps, gets the email addresses of all users belonging
    // to roles assigned to work with the given workflow step.
    else
    {
        recipients = GetRecipientsForUserIds(GetUserIdsForWorkflowStep(currentWorkflowStep.ContentWorkflowStepID));
    }

    // Prepares data for the email message
    EmailData emailData = new EmailData
    {
        CurrentStepDisplayName = currentWorkflowStep.ContentWorkflowStepDisplayName,
        OriginalStepDisplayName = workflowStepProvider.Get(e.OriginalStepName).ContentWorkflowStepDisplayName,
        StepChangedByUserName = userInfoProvider.Get(e.UserID).UserName,
        ItemName = e.DisplayName,
        Recipients = recipients
    };

    SendWorkflowNotificationEmail(emailData);
}
Page MoveToStep handler

// Handler for the MoveToStep event for website channel pages
// Sends an email notification to all users who can work with the page in the current step
private void WebPage_MoveToStepEventHandler(object sender, WebPageWorkflowMoveToStepArguments e)
{
    ContentWorkflowStepInfo currentWorkflowStep = workflowStepProvider.Get(e.StepName);            
    
    string recipients;

    // If the current step is the 'Draft' system step, gets the email addresses of all users
    // with the Update permission for the website channel application where the page belongs.
    if (currentWorkflowStep.ContentWorkflowStepType == ContentWorkflowStepType.SystemDraft)
    {
        string webSiteChannelApplicationIdentifier = $"{WebPagesApplication.IDENTIFIER}_{websiteChannelInfoProvider.Get(e.WebsiteChannelID).WebsiteChannelGUID}";
        recipients = GetRecipientsForUserIds(GetUserIdsWithApplicationUpdatePermission(webSiteChannelApplicationIdentifier));
    }
    else
    {
        // For custom workflow steps, gets the email addresses of all users belonging
        // to roles assigned to work with the given workflow step.
        recipients = GetRecipientsForUserIds(GetUserIdsForWorkflowStep(currentWorkflowStep.ContentWorkflowStepID));
    }

    // Prepares data for the email message
    EmailData emailData = new EmailData
    {
        CurrentStepDisplayName = currentWorkflowStep.ContentWorkflowStepDisplayName,
        OriginalStepDisplayName = workflowStepProvider.Get(e.OriginalStepName).ContentWorkflowStepDisplayName,
        StepChangedByUserName = userInfoProvider.Get(e.UserID).UserName,
        ItemName = e.DisplayName,
        Recipients = recipients
    };

    SendWorkflowNotificationEmail(emailData);
}
Helper methods

// Gets the IDs of all users belonging to a role that can work with a specified workflow step
private IEnumerable<int> GetUserIdsForWorkflowStep(int stepId)
{
    // Gets the IDs of roles assigned to the workflow step
    var roleIds = workflowStepRoleProvider
                 .Get()
                 .WhereEquals(nameof(ContentWorkflowStepRoleInfo.ContentWorkflowStepRoleContentWorkflowStepID), stepId)
                 .Column(nameof(ContentWorkflowStepRoleInfo.ContentWorkflowStepRoleRoleID))
                 .GetListResult<int>();

    // Gets the IDs of users belonging to the roles (without duplicates)
    var userIds = new HashSet<int>();
    foreach (int roleId in roleIds)
    {
        var userIdsForRole = userRoleInfoProvider
                 .Get()
                 .WhereEquals(nameof(UserRoleInfo.RoleID), roleId)
                 .Column(nameof(UserRoleInfo.UserID))
                 .GetListResult<int>();

        userIds.UnionWith(userIdsForRole);
    }

    return userIds;
}

// Gets the IDs of all users with the Update permission for the specified application
private IEnumerable<int> GetUserIdsWithApplicationUpdatePermission(string appIdentifier)
{
    // Gets the IDs of roles with the Update permission for the specified application
    var roleIds = applicationPermissionProvider
                    .GetApplicationPermissionRoles(appIdentifier, SystemPermissions.UPDATE)
                    .ToList<int>();

    // Adds the system 'administrator' role, which can automatically work with all applications
    roleIds.Add(roleInfoProvider.Get(RoleName.ADMINISTRATOR).RoleID);

    // Gets the IDs of users belonging to the roles (without duplicates)
    var userIds = new HashSet<int>();
    foreach (int roleId in roleIds)
    {
        var userIdsForRole = userRoleInfoProvider
                 .Get()
                 .WhereEquals(nameof(UserRoleInfo.RoleID), roleId)
                 .Column(nameof(UserRoleInfo.UserID))
                 .GetListResult<int>();

        userIds.UnionWith(userIdsForRole);
    }

    return userIds;
}

// Converts a collection of user IDs to a recipient string containing the users' email addresses, separated by semicolons
private string GetRecipientsForUserIds(IEnumerable<int> userIds)
{
    var userEmails = userIds.Select(userId => userInfoProvider.Get(userId).Email);

    return String.Join(";", userEmails);
}
Email sending logic

private void SendWorkflowNotificationEmail(EmailData emailData)
{
    // Only sends the notification email if the recipient string is not empty
    if (string.IsNullOrEmpty(emailData.Recipients))
    {
        return;
    }

    // Creates the email message object
    EmailMessage msg = new EmailMessage()
    {                
        From = $"no-reply@{systemEmailOptions.CurrentValue.SendingDomain}",
        
        // Sets the 'To' email address
        // Separate addresses with a semicolon ';' to add multiple recipients
        Recipients = emailData.Recipients,
        
        Priority = EmailPriorityEnum.Normal,
        
        // Sets the subject line of the email
        Subject = $"Item '{emailData.ItemName}' moved to step '{emailData.CurrentStepDisplayName}'",

        // Sets the body of the email
        Body = $"The '{emailData.ItemName}' item was moved from step '{emailData.OriginalStepDisplayName}' to '{emailData.CurrentStepDisplayName}' by user '{emailData.StepChangedByUserName}'.",
    };

    // Adds the email message to the email queue
    // The email is then sent using a configured email client (e.g., an SMTP server or SendGrid)
    emailService.SendEmail(msg);
}