refactor(core): delegate Move/Copy/Sort operations to MoveOperationService

ContentService now delegates:
- Move (non-recycle bin moves)
- EmptyRecycleBin, RecycleBinSmells, GetPagedContentInRecycleBin
- Copy (both overloads)
- Sort (both overloads)

MoveToRecycleBin stays in facade for unpublish orchestration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-23 17:45:34 +00:00
parent b86e9ffe22
commit 60cdab8586

View File

@@ -59,6 +59,10 @@ public class ContentService : RepositoryService, IContentService
private readonly IContentVersionOperationService? _versionOperationService;
private readonly Lazy<IContentVersionOperationService>? _versionOperationServiceLazy;
// Move operation service fields (for Phase 4 extracted move operations)
private readonly IContentMoveOperationService? _moveOperationService;
private readonly Lazy<IContentMoveOperationService>? _moveOperationServiceLazy;
/// <summary>
/// Gets the query operation service.
/// </summary>
@@ -75,6 +79,14 @@ public class ContentService : RepositoryService, IContentService
_versionOperationService ?? _versionOperationServiceLazy?.Value
?? throw new InvalidOperationException("VersionOperationService not initialized. Ensure the service is properly injected via constructor.");
/// <summary>
/// Gets the move operation service.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if the service was not properly initialized.</exception>
private IContentMoveOperationService MoveOperationService =>
_moveOperationService ?? _moveOperationServiceLazy?.Value
?? throw new InvalidOperationException("MoveOperationService not initialized. Ensure the service is properly injected via constructor.");
#region Constructors
[Microsoft.Extensions.DependencyInjection.ActivatorUtilitiesConstructor]
@@ -98,7 +110,8 @@ public class ContentService : RepositoryService, IContentService
IRelationService relationService,
IContentCrudService crudService,
IContentQueryOperationService queryOperationService, // NEW PARAMETER - Phase 2 query operations
IContentVersionOperationService versionOperationService) // NEW PARAMETER - Phase 3 version operations
IContentVersionOperationService versionOperationService, // NEW PARAMETER - Phase 3 version operations
IContentMoveOperationService moveOperationService) // NEW PARAMETER - Phase 4 move operations
: base(provider, loggerFactory, eventMessagesFactory)
{
_documentRepository = documentRepository;
@@ -133,6 +146,11 @@ public class ContentService : RepositoryService, IContentService
ArgumentNullException.ThrowIfNull(versionOperationService);
_versionOperationService = versionOperationService;
_versionOperationServiceLazy = null; // Not needed when directly injected
// Phase 4: Move operation service (direct injection)
ArgumentNullException.ThrowIfNull(moveOperationService);
_moveOperationService = moveOperationService;
_moveOperationServiceLazy = null; // Not needed when directly injected
}
[Obsolete("Use the non-obsolete constructor instead. Scheduled removal in v19.")]
@@ -194,6 +212,11 @@ public class ContentService : RepositoryService, IContentService
_versionOperationServiceLazy = new Lazy<IContentVersionOperationService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentVersionOperationService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 4: Lazy resolution of IContentMoveOperationService
_moveOperationServiceLazy = new Lazy<IContentMoveOperationService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentMoveOperationService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
}
[Obsolete("Use the non-obsolete constructor instead. Scheduled removal in v19.")]
@@ -254,6 +277,11 @@ public class ContentService : RepositoryService, IContentService
_versionOperationServiceLazy = new Lazy<IContentVersionOperationService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentVersionOperationService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 4: Lazy resolution of IContentMoveOperationService
_moveOperationServiceLazy = new Lazy<IContentMoveOperationService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentMoveOperationService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
}
#endregion
@@ -701,17 +729,7 @@ public class ContentService : RepositoryService, IContentService
/// </summary>
/// <returns>An Enumerable list of <see cref="IContent" /> objects</returns>
public IEnumerable<IContent> GetPagedContentInRecycleBin(long pageIndex, int pageSize, out long totalRecords, IQuery<IContent>? filter = null, Ordering? ordering = null)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
ordering ??= Ordering.By("Path");
scope.ReadLock(Constants.Locks.ContentTree);
IQuery<IContent>? query = Query<IContent>()?
.Where(x => x.Path.StartsWith(Constants.System.RecycleBinContentPathPrefix));
return _documentRepository.GetPage(query, pageIndex, pageSize, out totalRecords, filter, ordering);
}
}
=> MoveOperationService.GetPagedContentInRecycleBin(pageIndex, pageSize, out totalRecords, filter, ordering);
/// <summary>
/// Checks whether an <see cref="IContent" /> item has any children
@@ -2003,78 +2021,13 @@ public class ContentService : RepositoryService, IContentService
/// <param name="userId">Optional Id of the User moving the Content</param>
public OperationResult Move(IContent content, int parentId, int userId = Constants.Security.SuperUserId)
{
EventMessages eventMessages = EventMessagesFactory.Get();
if (content.ParentId == parentId)
{
return OperationResult.Succeed(eventMessages);
}
// if moving to the recycle bin then use the proper method
// If moving to recycle bin, use MoveToRecycleBin which handles unpublish
if (parentId == Constants.System.RecycleBinContent)
{
return MoveToRecycleBin(content, userId);
}
var moves = new List<(IContent, string)>();
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
IContent? parent = parentId == Constants.System.Root ? null : GetById(parentId);
if (parentId != Constants.System.Root && (parent == null || parent.Trashed))
{
throw new InvalidOperationException("Parent does not exist or is trashed."); // causes rollback
}
TryGetParentKey(parentId, out Guid? parentKey);
var moveEventInfo = new MoveEventInfo<IContent>(content, content.Path, parentId, parentKey);
var movingNotification = new ContentMovingNotification(moveEventInfo, eventMessages);
if (scope.Notifications.PublishCancelable(movingNotification))
{
scope.Complete();
return OperationResult.Cancel(eventMessages); // causes rollback
}
// if content was trashed, and since we're not moving to the recycle bin,
// indicate that the trashed status should be changed to false, else just
// leave it unchanged
var trashed = content.Trashed ? false : (bool?)null;
// if the content was trashed under another content, and so has a published version,
// it cannot move back as published but has to be unpublished first - that's for the
// root content, everything underneath will retain its published status
if (content.Trashed && content.Published)
{
// however, it had been masked when being trashed, so there's no need for
// any special event here - just change its state
content.PublishedState = PublishedState.Unpublishing;
}
PerformMoveLocked(content, parentId, parent, userId, moves, trashed);
scope.Notifications.Publish(
new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshBranch, eventMessages));
// changes
MoveEventInfo<IContent>[] moveInfo = moves
.Select(x =>
{
TryGetParentKey(x.Item1.ParentId, out Guid? itemParentKey);
return new MoveEventInfo<IContent>(x.Item1, x.Item2, x.Item1.ParentId, itemParentKey);
})
.ToArray();
scope.Notifications.Publish(
new ContentMovedNotification(moveInfo, eventMessages).WithStateFrom(movingNotification));
Audit(AuditType.Move, userId, content.Id);
scope.Complete();
return OperationResult.Succeed(eventMessages);
}
return MoveOperationService.Move(content, parentId, userId);
}
// MUST be called from within WriteLock
@@ -2143,67 +2096,16 @@ public class ContentService : RepositoryService, IContentService
}
public async Task<OperationResult> EmptyRecycleBinAsync(Guid userId)
=> EmptyRecycleBin(await _userIdKeyResolver.GetAsync(userId));
=> await MoveOperationService.EmptyRecycleBinAsync(userId);
/// <summary>
/// Empties the Recycle Bin by deleting all <see cref="IContent" /> that resides in the bin
/// </summary>
public OperationResult EmptyRecycleBin(int userId = Constants.Security.SuperUserId)
{
var deleted = new List<IContent>();
EventMessages eventMessages = EventMessagesFactory.Get();
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
// emptying the recycle bin means deleting whatever is in there - do it properly!
IQuery<IContent>? query = Query<IContent>().Where(x => x.ParentId == Constants.System.RecycleBinContent);
IContent[] contents = _documentRepository.Get(query).ToArray();
var emptyingRecycleBinNotification = new ContentEmptyingRecycleBinNotification(contents, eventMessages);
var deletingContentNotification = new ContentDeletingNotification(contents, eventMessages);
if (scope.Notifications.PublishCancelable(emptyingRecycleBinNotification) || scope.Notifications.PublishCancelable(deletingContentNotification))
{
scope.Complete();
return OperationResult.Cancel(eventMessages);
}
if (contents is not null)
{
foreach (IContent content in contents)
{
if (_contentSettings.DisableDeleteWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child))
{
continue;
}
DeleteLocked(scope, content, eventMessages);
deleted.Add(content);
}
}
scope.Notifications.Publish(
new ContentEmptiedRecycleBinNotification(deleted, eventMessages).WithStateFrom(
emptyingRecycleBinNotification));
scope.Notifications.Publish(
new ContentTreeChangeNotification(deleted, TreeChangeTypes.Remove, eventMessages));
Audit(AuditType.Delete, userId, Constants.System.RecycleBinContent, "Recycle bin emptied");
scope.Complete();
}
return OperationResult.Succeed(eventMessages);
}
=> MoveOperationService.EmptyRecycleBin(userId);
public bool RecycleBinSmells()
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.RecycleBinSmells();
}
}
=> MoveOperationService.RecycleBinSmells();
#endregion
@@ -2219,7 +2121,8 @@ public class ContentService : RepositoryService, IContentService
/// <param name="relateToOriginal">Boolean indicating whether the copy should be related to the original</param>
/// <param name="userId">Optional Id of the User copying the Content</param>
/// <returns>The newly created <see cref="IContent" /> object</returns>
public IContent? Copy(IContent content, int parentId, bool relateToOriginal, int userId = Constants.Security.SuperUserId) => Copy(content, parentId, relateToOriginal, true, userId);
public IContent? Copy(IContent content, int parentId, bool relateToOriginal, int userId = Constants.Security.SuperUserId)
=> MoveOperationService.Copy(content, parentId, relateToOriginal, userId);
/// <summary>
/// Copies an <see cref="IContent" /> object by creating a new Content object of the same type and copies all data from
@@ -2233,137 +2136,7 @@ public class ContentService : RepositoryService, IContentService
/// <param name="userId">Optional Id of the User copying the Content</param>
/// <returns>The newly created <see cref="IContent" /> object</returns>
public IContent? Copy(IContent content, int parentId, bool relateToOriginal, bool recursive, int userId = Constants.Security.SuperUserId)
{
EventMessages eventMessages = EventMessagesFactory.Get();
// keep track of updates (copied item key and parent key) for the in-memory navigation structure
var navigationUpdates = new List<Tuple<Guid, Guid?>>();
IContent copy = content.DeepCloneWithResetIdentities();
copy.ParentId = parentId;
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
TryGetParentKey(parentId, out Guid? parentKey);
if (scope.Notifications.PublishCancelable(new ContentCopyingNotification(content, copy, parentId, parentKey, eventMessages)))
{
scope.Complete();
return null;
}
// note - relateToOriginal is not managed here,
// it's just part of the Copied event args so the RelateOnCopyHandler knows what to do
// meaning that the event has to trigger for every copied content including descendants
var copies = new List<Tuple<IContent, IContent>>();
scope.WriteLock(Constants.Locks.ContentTree);
// a copy is not published (but not really unpublishing either)
// update the create author and last edit author
if (copy.Published)
{
copy.Published = false;
}
copy.CreatorId = userId;
copy.WriterId = userId;
// get the current permissions, if there are any explicit ones they need to be copied
EntityPermissionCollection currentPermissions = GetPermissions(content);
currentPermissions.RemoveWhere(p => p.IsDefaultPermissions);
// save and flush because we need the ID for the recursive Copying events
_documentRepository.Save(copy);
// store navigation update information for copied item
navigationUpdates.Add(Tuple.Create(copy.Key, GetParent(copy)?.Key));
// add permissions
if (currentPermissions.Count > 0)
{
var permissionSet = new ContentPermissionSet(copy, currentPermissions);
_documentRepository.AddOrUpdatePermissions(permissionSet);
}
// keep track of copies
copies.Add(Tuple.Create(content, copy));
var idmap = new Dictionary<int, int> { [content.Id] = copy.Id };
// process descendants
if (recursive)
{
const int pageSize = 500;
var page = 0;
var total = long.MaxValue;
while (page * pageSize < total)
{
IEnumerable<IContent> descendants =
GetPagedDescendants(content.Id, page++, pageSize, out total);
foreach (IContent descendant in descendants)
{
// when copying a branch into itself, the copy of a root would be seen as a descendant
// and would be copied again => filter it out.
if (descendant.Id == copy.Id)
{
continue;
}
// if parent has not been copied, skip, else gets its copy id
if (idmap.TryGetValue(descendant.ParentId, out parentId) == false)
{
continue;
}
IContent descendantCopy = descendant.DeepCloneWithResetIdentities();
descendantCopy.ParentId = parentId;
if (scope.Notifications.PublishCancelable(new ContentCopyingNotification(descendant, descendantCopy, parentId, parentKey, eventMessages)))
{
continue;
}
// a copy is not published (but not really unpublishing either)
// update the create author and last edit author
if (descendantCopy.Published)
{
descendantCopy.Published = false;
}
descendantCopy.CreatorId = userId;
descendantCopy.WriterId = userId;
// since the repository relies on the dirty state to figure out whether it needs to update the sort order, we mark it dirty here
descendantCopy.SortOrder = descendantCopy.SortOrder;
// save and flush (see above)
_documentRepository.Save(descendantCopy);
// store navigation update information for descendants
navigationUpdates.Add(Tuple.Create(descendantCopy.Key, GetParent(descendantCopy)?.Key));
copies.Add(Tuple.Create(descendant, descendantCopy));
idmap[descendant.Id] = descendantCopy.Id;
}
}
}
// not handling tags here, because
// - tags should be handled by the content repository
// - a copy is unpublished and therefore has no impact on tags in DB
scope.Notifications.Publish(
new ContentTreeChangeNotification(copy, TreeChangeTypes.RefreshBranch, eventMessages));
foreach (Tuple<IContent, IContent> x in CollectionsMarshal.AsSpan(copies))
{
scope.Notifications.Publish(new ContentCopiedNotification(x.Item1, x.Item2, parentId, parentKey, relateToOriginal, eventMessages));
}
Audit(AuditType.Copy, userId, content.Id);
scope.Complete();
}
return copy;
}
=> MoveOperationService.Copy(content, parentId, relateToOriginal, recursive, userId);
private bool TryGetParentKey(int parentId, [NotNullWhen(true)] out Guid? parentKey)
{
@@ -2446,24 +2219,7 @@ public class ContentService : RepositoryService, IContentService
/// <param name="userId"></param>
/// <returns>Result indicating what action was taken when handling the command.</returns>
public OperationResult Sort(IEnumerable<IContent> items, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
IContent[] itemsA = items.ToArray();
if (itemsA.Length == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
scope.Complete();
return ret;
}
}
=> MoveOperationService.Sort(items, userId);
/// <summary>
/// Sorts a collection of <see cref="IContent" /> objects by updating the SortOrder according
@@ -2477,90 +2233,7 @@ public class ContentService : RepositoryService, IContentService
/// <param name="userId"></param>
/// <returns>Result indicating what action was taken when handling the command.</returns>
public OperationResult Sort(IEnumerable<int>? ids, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
var idsA = ids?.ToArray();
if (idsA is null || idsA.Length == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
IContent[] itemsA = GetByIds(idsA).ToArray();
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
scope.Complete();
return ret;
}
}
private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, EventMessages eventMessages)
{
var sortingNotification = new ContentSortingNotification(itemsA, eventMessages);
var savingNotification = new ContentSavingNotification(itemsA, eventMessages);
// raise cancelable sorting event
if (scope.Notifications.PublishCancelable(sortingNotification))
{
return OperationResult.Cancel(eventMessages);
}
// raise cancelable saving event
if (scope.Notifications.PublishCancelable(savingNotification))
{
return OperationResult.Cancel(eventMessages);
}
var published = new List<IContent>();
var saved = new List<IContent>();
var sortOrder = 0;
foreach (IContent content in itemsA)
{
// if the current sort order equals that of the content we don't
// need to update it, so just increment the sort order and continue.
if (content.SortOrder == sortOrder)
{
sortOrder++;
continue;
}
// else update
content.SortOrder = sortOrder++;
content.WriterId = userId;
// if it's published, register it, no point running StrategyPublish
// since we're not really publishing it and it cannot be cancelled etc
if (content.Published)
{
published.Add(content);
}
// save
saved.Add(content);
_documentRepository.Save(content);
Audit(AuditType.Sort, userId, content.Id, "Sorting content performed by user");
}
// first saved, then sorted
scope.Notifications.Publish(
new ContentSavedNotification(itemsA, eventMessages).WithStateFrom(savingNotification));
scope.Notifications.Publish(
new ContentSortedNotification(itemsA, eventMessages).WithStateFrom(sortingNotification));
scope.Notifications.Publish(
new ContentTreeChangeNotification(saved, TreeChangeTypes.RefreshNode, eventMessages));
if (published.Any())
{
scope.Notifications.Publish(new ContentPublishedNotification(published, eventMessages));
}
return OperationResult.Succeed(eventMessages);
}
=> MoveOperationService.Sort(ids, userId);
private static bool HasUnsavedChanges(IContent content) => content.HasIdentity is false || content.IsDirty();