A critical vulnerability was discovered in React Server Components (Next.js). Our systems remain protected but we advise to update packages to newest version. Learn More

Ashish Rasal
Nov 7, 2024
  56
(0 votes)

ExcludeDeleted(): Prevent Trashed Content from Appearing in Search Results

Introduction

In Optimizely CMS, content that is moved to the trash can still appear in search results if it’s not explicitly excluded using the IsDeleted property on IContent. This behavior can clutter search results and lead to a confusing user experience, especially if the "deleted" content remains accessible when it shouldn't be. Unfortunately, Optimizely’s documentation lacks a straightforward way to filter out trashed content, so we developed a custom solution that excludes trashed items from search results.

This solution leverages Optimizely Content Events to detect when content is moved to or from the trash and applies an IsDeleted flag to custom page templates that inherit from SitePageData, our project’s base page class. By using these events, we ensure real-time updates to the search index, providing a consistent and clear search experience for users.

Overview of Optimizely Content Events

Optimizely Content Events are essential for managing content lifecycle actions such as creation, updates, movement, and deletion. With these events, developers can trigger specific actions in response to content changes, enabling them to integrate with other systems or handle special tasks for particular content types.

Here’s a quick overview of common content events in Optimizely:

  • CreatedContent: Fired when new content is created.
  • PublishedContent: Triggered when content is published.
  • DeletedContent: Fires when content is permanently removed from the CMS.
  • MovedContent: Activated when content is moved to a new location or to the trash.
  • SavingContent: Occurs before content is saved, often used for validation or setting default values.

For a more in-depth look at Content Events, check out Daniel Ovaska’s blog on Content Events.

Solution Overview

Our custom solution involves setting up an OnMovedContentToTrash event handler that:

  1. Detects when content is moved to or from the trash.
  2. Sets an IsDeleted flag on custom page types that inherit from SitePageData.
  3. Updates the content index in Optimizely Find to keep search results accurate.

We’ll break down each part of this solution below, including a reusable method that dynamically identifies all custom page types derived from SitePageData and efficiently applies the IsDeleted flag.

Implementation

Step 1: Setting up the OnMovedContentToTrash Event Handler

In our OnMovedContentToTrash method, we monitor if content has been moved to or from the trash. Based on its location, we set the IsDeleted flag to true (if it’s in the trash) or false (if it’s restored). This updated information is saved in the CMS and re-indexed in Optimizely Find to ensure search results reflect the current content state.

Step 2: Creating the Processor Action for Each Page Type

To apply IsDeleted on each page type without reflection at runtime, we use an expression tree to create an action for each type. This approach avoids runtime performance overhead and prevents potential issues with reflection.

Step 3: Defining the ProcessContent Method

This method applies the IsDeleted flag to the specific content type and re-indexes it in Optimizely Find to ensure that it appears or disappears from search results based on its status.

Here's the code for the above steps:


[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule), typeof(EPiServer.Commerce.Initialization.InitializationModule))]
public class ContentEventInitialization : IInitializableModule
{

	private IClient _findClient { get; set; }

	private Injected<ICmsContentService> _cmsContentService;
	private IContentTypeRepository _contentTypeRepository { get; set; }

	private static readonly Injected<IHttpContextAccessor> _httpContextAccessor;

	private static readonly Injected<IConfiguration> _configuration;
	private static readonly Injected<IWebHostEnvironment> _environment;
	private static readonly ILogger _logger = LogManager.GetLogger(typeof(ContentEventInitialization));

	public void Initialize(InitializationEngine context)
	{
		_findClient = SearchClient.Instance;
		_contentTypeRepository = context.Locate.Advanced.GetInstance<IContentTypeRepository>();

		var contentEventsService = _contentEvents.Service;

		contentEventsService.MovedContent += OnMovedContentToTrash;
		contentEventsService.PublishedContent += OnContentPublished;
		contentEventsService.CheckedInContent += OnCheckedInContent;
	}

	public void Uninitialize(InitializationEngine context)
	{
		var contentEventsService = _contentEvents.Service;

		contentEventsService.MovedContent -= OnMovedContentToTrash;
		contentEventsService.PublishedContent -= OnContentPublished;
		contentEventsService.CheckedInContent -= OnCheckedInContent;
	}

	/// <summary>
	/// Handles the event when content is moved to or from the trash. Updates the IsDeleted property
	/// of specific custom page types and re-indexes the content in Optimizely Find.
	/// </summary>
	/// <param name="sender">The event sender.</param>
	/// <param name="e">The content event arguments containing details about the moved content.</param>
	private void OnMovedContentToTrash(object sender, ContentEventArgs e)
	{
		_logger.Information($"Moved content to trash fired for content {e.ContentLink.ID}");

		if (e is MoveContentEventArgs eventArgs)
		{
			var contentRepository = ServiceLocator.Current.GetInstance<IContentRepository>();

			// Dictionary to store processors for each custom page type
			var contentProcessors = new Dictionary<Type, Action<IContent, bool>>();
			var sitePageDataType = typeof(SitePageData);

			// Get all types that inherit from SitePageData in the current assembly
			var pageTypes = _contentTypeRepository
				.List()
				.Where(x => x.ModelType != null)
				.Select(x => x.ModelType)
				.Where(x => sitePageDataType.IsAssignableFrom(x) && x.IsClass && !x.IsAbstract);

			// Populate the dictionary with actions for each page type
			foreach (var pageType in pageTypes)
			{
				var processor = CreateProcessorAction(pageType);
				contentProcessors[pageType] = processor;
			}

			// Determine if content is moved to or from the trash, setting IsDeleted accordingly
			bool isDeleted = eventArgs.TargetLink.ID == ContentReference.WasteBasket.ID;
			bool isRestored = eventArgs.OriginalParent.ID == ContentReference.WasteBasket.ID;

			if (isDeleted || isRestored)
			{
				var contentType = e.Content.GetType().BaseType;
				if (contentProcessors.TryGetValue(contentType, out var processor))
				{
					processor.Invoke(e.Content, isDeleted);
				}
			}
		}
	}

	/// <summary>
	/// Creates a strongly-typed action for processing a specific page type when content is moved
	/// to or from the trash. This avoids the need for reflection at runtime.
	/// </summary>
	/// <param name="pageType">The type of the page for which to create the processor action.</param>
	/// <returns>An action that processes the page type when content is moved to or from the trash.</returns>
	private Action<IContent, bool> CreateProcessorAction(Type pageType)
	{
		// Define parameters for the lambda: (IContent content, bool isDeleted)
		var contentParam = Expression.Parameter(typeof(IContent), "content");
		var isDeletedParam = Expression.Parameter(typeof(bool), "isDeleted");

		// Cast content to the specific page type
		var castContent = Expression.Convert(contentParam, pageType);

		// Create method call for ProcessContent<T> using the specific page type
		var method = typeof(ContentEventInitialization)
			.GetMethod(nameof(ProcessContent), BindingFlags.NonPublic | BindingFlags.Instance)
			.MakeGenericMethod(pageType);

		// Call ProcessContent<T>((T)content, isDeleted)
		var body = Expression.Call(Expression.Constant(this), method, castContent, isDeletedParam);

		// Compile into a lambda expression: (IContent content, bool isDeleted) => ProcessContent<T>((T)content, isDeleted)
		var lambda = Expression.Lambda<Action<IContent, bool>>(body, contentParam, isDeletedParam);
		return lambda.Compile();
	}

	/// <summary>
	/// Updates the IsDeleted property for a specific content type and re-indexes it in Optimizely Find.
	/// This method is intended to be called by actions generated for each page type.
	/// </summary>
	/// <typeparam name="T">The type of the content to process, which must inherit from PageData.</typeparam>
	/// <param name="content">The content to be processed.</param>
	/// <param name="isDeleted">A boolean indicating whether the content is marked as deleted (true) or restored (false).</param>
	private void ProcessContent<T>(IContent content, bool isDeleted) where T : PageData
	{
		var contentRepository = ServiceLocator.Current.GetInstance<IContentRepository>();

                if (contentRepository.Get<T>(content.ContentLink).CreateWritableClone() is T writableContent)
                {
	                    writableContent.IsDeleted = isDeleted;

	                    if (isDeleted)
	                    {
		                        writableContent.Deleted = DateTime.Now;
		                        writableContent.DeletedBy = PrincipalInfo.CurrentPrincipal.Identity.Name;
	                     }
	                     else
	                     {
		                        writableContent.Deleted = null;
		                        writableContent.DeletedBy = null;
	                      }
	                      contentRepository.Save(writableContent, SaveAction.SkipValidation, AccessLevel.NoAccess);
	                     _findClient.Index(writableContent);
                    }
	  }
}

Using ExcludeDeleted():

var detailPageQuery = _searchClient.Search<StorytellingPage>()
    .ExcludeDeleted()
    .FilterForVisitor()
    .FilterOnCurrentSite()
    .CustomFilterForVisitor()
    .Take(PAGINATION);

Conclusion

By creating a generic solution using Optimizely Content Events, we can dynamically detect content type changes, set an IsDeleted flag, and update the search index in real time. This approach not only makes it easy to exclude trashed content from search results but also keeps our codebase clean and efficient, without runtime reflection overhead.

This solution can be adapted to any custom page type that inherits from SitePageData, making it versatile for projects that need better control over search visibility in Optimizely CMS.


P.S. Any input or alternative proposals would be gratefully received.

Nov 07, 2024

Comments

Please login to comment.
Latest blogs
A day in the life of an Optimizely OMVP: Learning Optimizely Just Got Easier: Introducing the Optimizely Learning Centre

On the back of my last post about the Opti Graph Learning Centre, I am now happy to announce a revamped interactive learning platform that makes...

Graham Carr | Jan 31, 2026

Scheduled job for deleting content types and all related content

In my previous blog post which was about getting an overview of your sites content https://world.optimizely.com/blogs/Per-Nergard/Dates/2026/1/sche...

Per Nergård (MVP) | Jan 30, 2026

Working With Applications in Optimizely CMS 13

💡 Note:  The following content has been written based on Optimizely CMS 13 Preview 2 and may not accurately reflect the final release version. As...

Mark Stott | Jan 30, 2026

Experimentation at Speed Using Optimizely Opal and Web Experimentation

If you are working in experimentation, you will know that speed matters. The quicker you can go from idea to implementation, the faster you can...

Minesh Shah (Netcel) | Jan 30, 2026

How to run Optimizely CMS on VS Code Dev Containers

VS Code Dev Containers is an extension that allows you to use a Docker container as a full-featured development environment. Instead of installing...

Daniel Halse | Jan 30, 2026

A day in the life of an Optimizely OMVP: Introducing Optimizely Graph Learning Centre Beta: Master GraphQL for Content Delivery

GraphQL is transforming how developers query and deliver content from Optimizely CMS. But let's be honest—there's a learning curve. Between...

Graham Carr | Jan 30, 2026