Tailoring member content with segments

Tailoring member content with segments

Welcome back to another round of exploring segments for Umbraco 16 šŸ˜„

In this post I’ll explore a somewhat different take on segments, albeit still slightly related to personalization. The goal is to utilize segments to create member specific content based on member groups.

Source code

There are a fair few moving parts in the following - also a few more than is mentioned here, specifically for setting up the Delivery API for member auth.

I have gathered it all up in this GitHub repository, in case you want to play around with it.

The Umbraco DB is bundled up with the source, and the admin login is:

  • Username: admin@localhost
  • Password: SuperSecret123

Motivation

Over the years I’ve seen a fair few approaches to tailoring content for logged-in members. For example:

  • Adding extra fields to document types (e.g. ā€œTitle for Some Membersā€).
  • Using dynamic editors like Block List, likely in combination with a Member Group Picker.
  • Having ā€œhiddenā€ children in the document structure: Document with "hidden" children for specific member groups

While all of these approaches can work just fine, they often suffer either from lack of flexibility or lack of editor friendliness. So I thought - why not use segments?

Member group segments

The first half of the puzzle is to tell Umbraco which segments should be available for editing. That means creating an implementation of ISegmentService.

In this case, the available segments should be defined from the active member groups:

using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;

namespace My.Site;

public class MemberGroupSegmentService : ISegmentService
{
    private readonly IMemberGroupService _memberGroupService;

    public MemberGroupSegmentService(IMemberGroupService memberGroupService)
        => _memberGroupService = memberGroupService;

    public async Task<Attempt<PagedModel<Segment>?, SegmentOperationStatus>> GetPagedSegmentsAsync(int skip = 0, int take = 100)
    {
        var allMemberGroups = (await _memberGroupService.GetAllAsync()).ToArray();
        return Attempt.SucceedWithStatus<PagedModel<Segment>?, SegmentOperationStatus>
        (
            SegmentOperationStatus.Success,
            new PagedModel<Segment>
            {
                Total = allMemberGroups.Length,
                Items = allMemberGroups
                    .Skip(skip)
                    .Take(take)
                    .Select(memberGroup => new Segment
                    {
                        Alias = memberGroup.Name!.AsSegmentAlias(),
                        Name = memberGroup.Name!,
                    })
            }
        );
    }
}

…where AsSegmentAlias() is an extension method which turns a string into an applicable segment alias - lowercased and without non-alphanumeric characters:

namespace My.Site;

public static class StringExtensions
{
    public static string AsSegmentAlias(this string roleName)
        => roleName.ToLowerInvariant().ReplaceNonAlphanumericChars('-');
}

Finally, the ISegmentService implementation needs registering as a replacement for the core implementation, and segment editing must also be enabled:

using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;

namespace My.Site;

public class MemberGroupSegmentsComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
         // replace the default segment service with the custom implementation 
        builder.Services.AddUnique<ISegmentService, MemberGroupSegmentService>();

        // ensure that segmentation is enabled
        builder.Services.Configure<SegmentSettings>(settings => settings.Enabled = true);
   }
}

And presto! Member groups now double as editor segments, when segmentation is enabled for a document type 🤘

Document editor with segments based on the active member groups

Contextualizing for logged-in members

The other half of the puzzle is to tell Umbraco which segment to render for - that is, set the correct variation context for logged-in members.

I have opted to handle this in a custom middleware. Besides being a convenient place to handle the contextualization, it’s also a solution that works both for templated rendering and when querying the Delivery API as a logged-in member.

Without further ado: Here’s the middleware to contextualize the request:

using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Web;

namespace My.Site;

public class MemberGroupSegmentMiddleware : IMiddleware
{
    private readonly IUmbracoContextAccessor _umbracoContextAccessor;
    private readonly IVariationContextAccessor _variationContextAccessor;
    private readonly IMemberManager _memberManager;
    private readonly ILogger<MemberGroupSegmentMiddleware> _logger;

    public MemberGroupSegmentMiddleware(
        IUmbracoContextAccessor umbracoContextAccessor,
        IVariationContextAccessor variationContextAccessor,
        IMemberManager memberManager,
        ILogger<MemberGroupSegmentMiddleware> logger)
    {
        _umbracoContextAccessor = umbracoContextAccessor;
        _variationContextAccessor = variationContextAccessor;
        _memberManager = memberManager;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await Contextualize();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unable to contextualize the request.");
        }
        await next(context);
    }

    private async Task Contextualize()
    {
        if (_umbracoContextAccessor.TryGetUmbracoContext(out var umbracoContext) is false)
        {
            return;
        }

        // is it a frontend request?
        if (umbracoContext.IsFrontEndUmbracoRequest() is false)
        {
            // nope - is it a Content Delivery API request?
            if (umbracoContext.CleanedUmbracoUrl.PathAndQuery.StartsWith("/umbraco/delivery/api/v2/content") is false)
            {
                // none of the above - probably a backoffice request, don't mess with it
                return;
            }
        }

        var member = await _memberManager.GetCurrentMemberAsync();
        if (member is null)
        {
            return;
        }

        var role = (await _memberManager.GetRolesAsync(member)).FirstOrDefault();
        if (role is null)
        {
            return;
        }

        // set the variation context segment while retaining any already resolved culture
        _variationContextAccessor.VariationContext = new VariationContext(
            culture: _variationContextAccessor.VariationContext?.Culture,
            segment: role.AsSegmentAlias()
        );
    }
}

The middleware must be registered in the DI and added to the request pipeline, so the composer needs amending:

using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.ApplicationBuilder;

namespace My.Site;

public class MemberGroupSegmentsComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        // replace the default segment service with the custom implementation 
        builder.Services.AddUnique<ISegmentService, MemberGroupSegmentService>();

        // ensure that segmentation is enabled
        builder.Services.Configure<SegmentSettings>(settings => settings.Enabled = true);

        // add the middleware for contextualizing logged-in members
        builder.Services.AddScoped<MemberGroupSegmentMiddleware>();
        builder.Services.Configure<UmbracoPipelineOptions>(options =>
            options.AddFilter(new UmbracoPipelineFilter(nameof(MemberGroupSegmentMiddleware))
            {
                PostPipeline = app => app.UseMiddleware<MemberGroupSegmentMiddleware>()
            }));
   }
}

With all that in place, segmented content is rendered for the individual member groups:

Templated renderings for anonymous and for logged-in members

…and it also works with the Delivery API šŸ‘

Delivery API output for anonymous and for logged-in members

Parting remarks

So… this is all really slick. What’s the catch, you ask? šŸ¤”

I’ve found two main caveats with this approach to member specific content.

First: The member group names are used for the calculation of segment aliases, primarily because the actual member group entities are troublesome - and potentially costly - to access in the middleware. This means that the member groups cannot be renamed without risking data (content) loss.

Second: Segments operate at document level (block level segmentation is yet to come). Thus, it’s an ā€œall or nothingā€ kind of situation when choosing to segment content; you have to fill out all properties that are segmented. Depending on your document schema and flexibility requirements, this might become rather tedious for the editors.

The first one is hard to do anything about. The latter can be addressed with some extra code. I’ll get back to that in a follow-up post, since this one has gone on for long enough already 😓

I still think this is a neat way to utilize segments, though!

..and as it happens, I’m not the only one. When I was nearing the completion of writing all this up, I realized that it was also the subject of this article in the 2022 edition of 24 Days In Umbraco. So with that, here’s a shout-out to the authors of the 2022 article šŸ‘

Happy hacking šŸ’œ