diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/Filter/FilterMemberFilterController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/Filter/FilterMemberFilterController.cs index 3814dc5fc5..8c858d7c33 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/Filter/FilterMemberFilterController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/Filter/FilterMemberFilterController.cs @@ -6,6 +6,7 @@ using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Member; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Api.Management.Controllers.Member.Filter; @@ -13,16 +14,13 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member.Filter; [ApiVersion("1.0")] public class FilterMemberFilterController : MemberFilterControllerBase { - private readonly IMemberTypeService _memberTypeService; private readonly IMemberService _memberService; private readonly IMemberPresentationFactory _memberPresentationFactory; public FilterMemberFilterController( - IMemberTypeService memberTypeService, IMemberService memberService, IMemberPresentationFactory memberPresentationFactory) { - _memberTypeService = memberTypeService; _memberService = memberService; _memberPresentationFactory = memberPresentationFactory; } @@ -33,38 +31,30 @@ public class FilterMemberFilterController : MemberFilterControllerBase [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task Filter( Guid? memberTypeId = null, + string? memberGroupName = null, + bool? isApproved = null, + bool? isLockedOut = null, string orderBy = "username", Direction orderDirection = Direction.Ascending, string? filter = null, int skip = 0, int take = 100) { - // TODO: Move to service once we have FilterAsync method for members - string? memberTypeAlias = null; - if (memberTypeId.HasValue) + var memberFilter = new MemberFilter() { - IMemberType? memberType = await _memberTypeService.GetAsync(memberTypeId.Value); - if (memberType == null) - { - return MemberTypeNotFound(); - } + MemberTypeId = memberTypeId, + MemberGroupName = memberGroupName, + IsApproved = isApproved, + IsLockedOut = isLockedOut, + Filter = filter, + }; - memberTypeAlias = memberType.Alias; - } - - IEnumerable members = await Task.FromResult(_memberService.GetAll( - skip, - take, - out var totalRecords, - orderBy, - orderDirection, - memberTypeAlias, - filter ?? string.Empty)); + PagedModel members = await _memberService.FilterAsync(memberFilter, orderBy, orderDirection, skip, take); var pageViewModel = new PagedViewModel { - Items = await _memberPresentationFactory.CreateMultipleAsync(members), - Total = totalRecords, + Items = await _memberPresentationFactory.CreateMultipleAsync(members.Items), + Total = members.Total, }; return Ok(pageViewModel); diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 9dfd2ca6f2..6f1fd53b2f 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -16636,6 +16636,27 @@ "format": "uuid" } }, + { + "name": "memberGroupName", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "isApproved", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isLockedOut", + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "orderBy", "in": "query", diff --git a/src/Umbraco.Core/Models/Membership/MemberFilter.cs b/src/Umbraco.Core/Models/Membership/MemberFilter.cs new file mode 100644 index 0000000000..d65a1bb9ca --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/MemberFilter.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Models.Membership; + +public class MemberFilter +{ + public Guid? MemberTypeId { get; set; } + + public string? MemberGroupName { get; set; } + + public bool? IsApproved { get; set; } + + public bool? IsLockedOut { get; set; } + + public string? Filter { get; set; } +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IMemberRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IMemberRepository.cs index 32c04bdb4b..94f9bcac6f 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IMemberRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IMemberRepository.cs @@ -1,5 +1,7 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Core.Persistence.Repositories; @@ -38,4 +40,6 @@ public interface IMemberRepository : IContentRepository /// /// int GetCountByQuery(IQuery? query); + + Task> GetPagedByFilterAsync(MemberFilter memberFilter,int skip, int take, Ordering? ordering = null); } diff --git a/src/Umbraco.Core/Services/IMemberService.cs b/src/Umbraco.Core/Services/IMemberService.cs index 293155d55a..df3bde6ce1 100644 --- a/src/Umbraco.Core/Services/IMemberService.cs +++ b/src/Umbraco.Core/Services/IMemberService.cs @@ -1,4 +1,5 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Persistence.Querying; namespace Umbraco.Cms.Core.Services; @@ -91,6 +92,13 @@ public interface IMemberService : IMembershipMemberService, IContentServiceBase< string? memberTypeAlias, string filter); + public Task> FilterAsync( + MemberFilter memberFilter, + string orderBy = "username", + Direction orderDirection = Direction.Ascending, + int skip = 0, + int take = 100); + /// /// Creates an object without persisting it /// diff --git a/src/Umbraco.Core/Services/MemberService.cs b/src/Umbraco.Core/Services/MemberService.cs index bf0b0935f5..2a83960cf2 100644 --- a/src/Umbraco.Core/Services/MemberService.cs +++ b/src/Umbraco.Core/Services/MemberService.cs @@ -99,6 +99,20 @@ namespace Umbraco.Cms.Core.Services #region Create + public async Task> FilterAsync( + MemberFilter memberFilter, + string orderBy = "username", + Direction orderDirection = Direction.Ascending, + int skip = 0, + int take = 100) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + scope.ReadLock(Constants.Locks.MemberTypes); + scope.ReadLock(Constants.Locks.MemberTree); + + return await _memberRepository.GetPagedByFilterAsync(memberFilter, skip, take, Ordering.By(orderBy, orderDirection)); + } + /// /// Creates an object without persisting it /// diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs index c77f5bd684..a382cd2a27 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using NPoco; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; @@ -202,6 +203,100 @@ public class MemberRepository : ContentRepositoryBase(fullSql); } + public async Task> GetPagedByFilterAsync(MemberFilter memberFilter, int skip, int take, Ordering? ordering = null) + { + Sql sql = Sql().Select(x => x.NodeId) + .From() + .InnerJoin().On((n, m) => n.NodeId == m.NodeId); + + if (memberFilter.MemberTypeId.HasValue) + { + sql = sql + .InnerJoin().On((memberNode, memberContent) => memberContent.NodeId == memberNode.NodeId) + .InnerJoin("mtn").On((memberTypeNode, memberContent) => memberContent.ContentTypeId == memberTypeNode.NodeId && memberTypeNode.UniqueId == memberFilter.MemberTypeId, "mtn"); + } + + if (memberFilter.MemberGroupName.IsNullOrWhiteSpace() is false) + { + sql = sql + .InnerJoin().On((m, memberToGroup) => m.NodeId == memberToGroup.Member) + .InnerJoin("mgn").On((memberGroupNode, memberToGroup) => memberToGroup.MemberGroup == memberGroupNode.NodeId && memberGroupNode.Text == memberFilter.MemberGroupName, "mgn"); + } + + if (memberFilter.IsApproved is not null) + { + sql = sql.Where(member => member.IsApproved == memberFilter.IsApproved); + } + + if (memberFilter.IsLockedOut is not null) + { + sql = sql.Where(member => member.IsLockedOut == memberFilter.IsLockedOut); + } + + if (memberFilter.Filter is not null) + { + var whereClauses = new List, Sql>>() + { + (x) => x.Where(memberNode => memberNode.Text != null && memberNode.Text.Contains(memberFilter.Filter)), + (x) => x.Where(memberNode => memberNode.Email.Contains(memberFilter.Filter)), + (x) => x.Where(memberNode => memberNode.LoginName.Contains(memberFilter.Filter)), + }; + + if (int.TryParse(memberFilter.Filter, out int filterAsIntId)) + { + whereClauses.Add((x) => x.Where(memberNode => memberNode.NodeId == filterAsIntId)); + } + + if (Guid.TryParse(memberFilter.Filter, out Guid filterAsGuid)) + { + whereClauses.Add((x) => x.Where(memberNode => memberNode.UniqueId == filterAsGuid)); + } + + sql = sql.WhereAny(whereClauses.ToArray()); + } + + if (ordering is not null) + { + ApplyOrdering(ref sql, ordering); + } + + var pageIndex = skip / take; + Page? pageResult = await Database.PageAsync(pageIndex+1, take, sql); + + // shortcut so our join is not too big, but we also hope these are cached, so we don't have to map them again. + var nodeIds = pageResult.Items.Select(x => x.NodeId).ToArray(); + + return new PagedModel(pageResult.TotalItems, nodeIds.Any() ? GetMany(nodeIds) : Array.Empty()); + } + + private void ApplyOrdering(ref Sql sql, Ordering ordering) + { + ArgumentNullException.ThrowIfNull(sql); + ArgumentNullException.ThrowIfNull(ordering); + + if (ordering.OrderBy.IsNullOrWhiteSpace()) + { + return; + } + + var orderBy = ordering.OrderBy.ToLowerInvariant() switch + { + "username" => sql.GetAliasedField(SqlSyntax.GetFieldName(x => x.LoginName)), + "name" => sql.GetAliasedField(SqlSyntax.GetFieldName(x => x.Text)), + "email" => sql.GetAliasedField(SqlSyntax.GetFieldName(x => x.Email)), + _ => throw new NotSupportedException("Ordering not supported"), + }; + + if (ordering.Direction == Direction.Ascending) + { + sql.OrderBy(orderBy); + } + else + { + sql.OrderByDescending(orderBy); + } + } + /// /// Gets paged member results. ///