From 1e043cbcfb3f25c635ab42951f127ce3e7095a9a Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Thu, 29 Feb 2024 09:16:27 +0100 Subject: [PATCH] V14: Member group controller (#15669) * Add models & mapping * Add controller * Add create async to service * Add auth policy * Implement delete * Rename response model * Implement updateAsync * Refactor update to use own model * Implement all async counterparts for IMemberService * Add tests * Implement update member group mapping * Dont fail if updating the current user group * Return not found if not found * Add missing OperationResults to MemberGroupOperationStatusResult * Add 404 to response type * Update openapi * Update OpenApi * Update OpenApi.json --- .../MemberGroup/AllMemberGroupController.cs | 38 ++ .../CreateMemberGroupController.cs | 37 ++ .../DeleteMemberGroupController.cs | 30 + .../MemberGroup/MemberGroupControllerBase.cs | 48 ++ .../UpdateMemberGroupController.cs | 45 ++ .../MemberGroupsBuilderExtensions.cs | 16 + .../UmbracoBuilderExtensions.cs | 1 + .../MemberGroup/MemberGroupMapDefinition.cs | 32 ++ src/Umbraco.Cms.Api.Management/OpenApi.json | 536 ++++++++++++++++++ .../CreateMemberGroupRequestModel.cs | 6 + .../MemberGroupPresentationBase.cs | 6 + .../MemberGroup/MemberGroupResponseModel.cs | 6 + .../UpdateMemberGroupRequestModel.cs | 5 + .../Services/IMemberGroupService.cs | 54 ++ .../Services/MemberGroupService.cs | 183 ++++-- .../MemberGroupOperationStatus.cs | 11 + .../Services/MemberGroupServiceTests.cs | 164 ++++++ 17 files changed, 1170 insertions(+), 48 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/AllMemberGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/CreateMemberGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/DeleteMemberGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/MemberGroupControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/UpdateMemberGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/DependencyInjection/MemberGroupsBuilderExtensions.cs create mode 100644 src/Umbraco.Cms.Api.Management/Mapping/MemberGroup/MemberGroupMapDefinition.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/CreateMemberGroupRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/MemberGroupPresentationBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/MemberGroupResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/UpdateMemberGroupRequestModel.cs create mode 100644 src/Umbraco.Core/Services/OperationStatus/MemberGroupOperationStatus.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MemberGroupServiceTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/AllMemberGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/AllMemberGroupController.cs new file mode 100644 index 0000000000..d5dcad9245 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/AllMemberGroupController.cs @@ -0,0 +1,38 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.ViewModels.MemberGroup; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.MemberGroup; + +[ApiVersion("1.0")] +public class AllMemberGroupController : MemberGroupControllerBase +{ + private readonly IMemberGroupService _memberGroupService; + private readonly IUmbracoMapper _mapper; + + public AllMemberGroupController(IMemberGroupService memberGroupService, IUmbracoMapper mapper) + { + _memberGroupService = memberGroupService; + _mapper = mapper; + } + + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> All(int skip = 0, int take = 100) + { + IMemberGroup[] memberGroups = (await _memberGroupService.GetAllAsync()).ToArray(); + var viewModel = new PagedViewModel + { + Total = memberGroups.Length, + Items = _mapper.MapEnumerable(memberGroups.Skip(skip).Take(take)), + }; + + return await Task.FromResult(Ok(viewModel)); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/CreateMemberGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/CreateMemberGroupController.cs new file mode 100644 index 0000000000..3bb4092a86 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/CreateMemberGroupController.cs @@ -0,0 +1,37 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.MemberGroup; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.MemberGroup; + +[ApiVersion("1.0")] +public class CreateMemberGroupController : MemberGroupControllerBase +{ + private readonly IMemberGroupService _memberGroupService; + private readonly IUmbracoMapper _mapper; + + public CreateMemberGroupController(IMemberGroupService memberGroupService, IUmbracoMapper mapper) + { + _memberGroupService = memberGroupService; + _mapper = mapper; + } + + [HttpPost] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(MemberGroupResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Create(CreateMemberGroupRequestModel model) + { + IMemberGroup? memberGroup = _mapper.Map(model); + Attempt result = await _memberGroupService.CreateAsync(memberGroup!); + return result.Success + ? Ok(_mapper.Map(result.Result)) + : MemberGroupOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/DeleteMemberGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/DeleteMemberGroupController.cs new file mode 100644 index 0000000000..a3c9d86501 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/DeleteMemberGroupController.cs @@ -0,0 +1,30 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.MemberGroup; + +[ApiVersion("1.0")] +public class DeleteMemberGroupController : MemberGroupControllerBase +{ + private readonly IMemberGroupService _memberGroupService; + + public DeleteMemberGroupController(IMemberGroupService memberGroupService) => _memberGroupService = memberGroupService; + + [HttpDelete("{key:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Delete(Guid key) + { + Attempt result = await _memberGroupService.DeleteAsync(key); + return result.Success + ? Ok() + : MemberGroupOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/MemberGroupControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/MemberGroupControllerBase.cs new file mode 100644 index 0000000000..c1759cdb7f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/MemberGroupControllerBase.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.Controllers.MemberGroup; + +[ApiController] +[VersionedApiBackOfficeRoute($"{Constants.UdiEntityType.MemberGroup}")] +[ApiExplorerSettings(GroupName = "Member Group")] +[Authorize(Policy = "New" + AuthorizationPolicies.SectionAccessMembers)] +public class MemberGroupControllerBase : ManagementApiControllerBase +{ + protected IActionResult MemberGroupOperationStatusResult(MemberGroupOperationStatus status) => + status switch + { + MemberGroupOperationStatus.Success => Ok(), + MemberGroupOperationStatus.NotFound => MemberGroupNotFound(), + MemberGroupOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cancelled by notification") + .WithDetail("A notification handler prevented the member group operation.") + .Build()), + MemberGroupOperationStatus.CannotHaveEmptyName => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Name was empty or null") + .WithDetail("The provided member group name cannot be null or empty.") + .Build()), + MemberGroupOperationStatus.DuplicateName => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Duplicate name") + .WithDetail("Another group with the same name already exists.") + .Build()), + MemberGroupOperationStatus.DuplicateKey => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Duplicate key") + .WithDetail("Another group with the same key already exists.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() + .WithTitle("Unknown member group operation status.") + .Build()), + }; + + protected IActionResult MemberGroupNotFound() => OperationStatusResult(MemberGroupOperationStatus.NotFound, problemDetailsBuilder + => NotFound(problemDetailsBuilder + .WithTitle("The requested member group could not be found") + .Build())); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/UpdateMemberGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/UpdateMemberGroupController.cs new file mode 100644 index 0000000000..b0d18c4a2c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/UpdateMemberGroupController.cs @@ -0,0 +1,45 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.MemberGroup; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.MemberGroup; + +[ApiVersion("1.0")] +public class UpdateMemberGroupController : MemberGroupControllerBase +{ + private readonly IUmbracoMapper _mapper; + private readonly IMemberGroupService _memberGroupService; + + public UpdateMemberGroupController(IUmbracoMapper mapper, IMemberGroupService memberGroupService) + { + _mapper = mapper; + _memberGroupService = memberGroupService; + } + + [HttpPut($"{{{nameof(id)}:guid}}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(MemberGroupResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Update(Guid id, UpdateMemberGroupRequestModel model) + { + IMemberGroup? current = await _memberGroupService.GetAsync(id); + if (current is null) + { + return MemberGroupNotFound(); + } + + IMemberGroup updated = _mapper.Map(model, current); + + Attempt result = await _memberGroupService.UpdateAsync(updated); + return result.Success + ? Ok(_mapper.Map(result.Result)) + : MemberGroupOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/MemberGroupsBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/MemberGroupsBuilderExtensions.cs new file mode 100644 index 0000000000..7852ca684d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/MemberGroupsBuilderExtensions.cs @@ -0,0 +1,16 @@ +using Umbraco.Cms.Api.Management.Mapping.MemberGroup; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Mapping; + +namespace Umbraco.Cms.Api.Management.DependencyInjection; + +public static class MemberGroupsBuilderExtensions +{ + internal static IUmbracoBuilder AddMemberGroups(this IUmbracoBuilder builder) + { + builder.WithCollectionBuilder() + .Add(); + + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs index 718b2de9ed..7861e96b62 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs @@ -37,6 +37,7 @@ public static partial class UmbracoBuilderExtensions .AddDocumentTypes() .AddMedia() .AddMediaTypes() + .AddMemberGroups() .AddMember() .AddMemberTypes() .AddLanguages() diff --git a/src/Umbraco.Cms.Api.Management/Mapping/MemberGroup/MemberGroupMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/MemberGroup/MemberGroupMapDefinition.cs new file mode 100644 index 0000000000..2d5198e807 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Mapping/MemberGroup/MemberGroupMapDefinition.cs @@ -0,0 +1,32 @@ +using Umbraco.Cms.Api.Management.ViewModels.MemberGroup; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Mapping.MemberGroup; + +public class MemberGroupMapDefinition : IMapDefinition +{ + public void DefineMaps(IUmbracoMapper mapper) + { + mapper.Define((_, _) => new Core.Models.MemberGroup(), Map); + mapper.Define((_, _) => new Core.Models.MemberGroup(), Map); + mapper.Define((_, _) => new MemberGroupResponseModel { Name = string.Empty }, Map); + } + + // Umbraco.Code.MapAll -Id -CreateDate -CreatorId -DeleteDate -UpdateDate + private static void Map(CreateMemberGroupRequestModel source, IMemberGroup target, MapperContext context) + { + target.Name = source.Name; + target.Key = source.Id ?? Guid.NewGuid(); + } + + // Umbraco.Code.MapAll -Id -CreateDate -CreatorId -DeleteDate -UpdateDate -Key + private static void Map(UpdateMemberGroupRequestModel source, IMemberGroup target, MapperContext context) => target.Name = source.Name; + + // Umbraco.Code.MapAll + private static void Map(IMemberGroup source, MemberGroupResponseModel target, MapperContext context) + { + target.Name = source.Name ?? string.Empty; + target.Id = source.Key; + } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index de8f272de3..fa5c523723 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -15125,6 +15125,463 @@ ] } }, + "/umbraco/management/api/v1/member-group": { + "get": { + "tags": [ + "Member Group" + ], + "operationId": "GetMemberGroup", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedMemberGroupResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PagedMemberGroupResponseModel" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PagedMemberGroupResponseModel" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + }, + "post": { + "tags": [ + "Member Group" + ], + "operationId": "PostMemberGroup", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateMemberGroupRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateMemberGroupRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateMemberGroupRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberGroupResponseModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberGroupResponseModel" + } + ] + } + }, + "text/plain": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberGroupResponseModel" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/member-group/{id}": { + "put": { + "tags": [ + "Member Group" + ], + "operationId": "PutMemberGroupById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateMemberGroupRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateMemberGroupRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateMemberGroupRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberGroupResponseModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberGroupResponseModel" + } + ] + } + }, + "text/plain": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberGroupResponseModel" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/member-group/{key}": { + "delete": { + "tags": [ + "Member Group" + ], + "operationId": "DeleteMemberGroupByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "200": { + "description": "Success", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/tree/member-group/root": { "get": { "tags": [ @@ -32000,6 +32457,22 @@ }, "additionalProperties": false }, + "CreateMemberGroupRequestModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MemberGroupPresentationBaseModel" + } + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, "CreateMemberRequestModel": { "required": [ "email", @@ -34994,6 +35467,36 @@ ], "additionalProperties": false }, + "MemberGroupPresentationBaseModel": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "MemberGroupResponseModel": { + "required": [ + "id" + ], + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MemberGroupPresentationBaseModel" + } + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "additionalProperties": false + }, "MemberItemResponseModel": { "required": [ "memberType", @@ -36218,6 +36721,30 @@ }, "additionalProperties": false }, + "PagedMemberGroupResponseModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberGroupResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, "PagedMemberResponseModel": { "required": [ "items", @@ -38777,6 +39304,15 @@ }, "additionalProperties": false }, + "UpdateMemberGroupRequestModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MemberGroupPresentationBaseModel" + } + ], + "additionalProperties": false + }, "UpdateMemberRequestModel": { "required": [ "email", diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/CreateMemberGroupRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/CreateMemberGroupRequestModel.cs new file mode 100644 index 0000000000..c9cafda556 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/CreateMemberGroupRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.MemberGroup; + +public class CreateMemberGroupRequestModel : MemberGroupPresentationBase +{ + public Guid? Id { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/MemberGroupPresentationBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/MemberGroupPresentationBase.cs new file mode 100644 index 0000000000..602deb0b29 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/MemberGroupPresentationBase.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.MemberGroup; + +public class MemberGroupPresentationBase +{ + public required string Name { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/MemberGroupResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/MemberGroupResponseModel.cs new file mode 100644 index 0000000000..b09e08b2a5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/MemberGroupResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.MemberGroup; + +public class MemberGroupResponseModel : MemberGroupPresentationBase +{ + public Guid Id { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/UpdateMemberGroupRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/UpdateMemberGroupRequestModel.cs new file mode 100644 index 0000000000..16b959dfae --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/UpdateMemberGroupRequestModel.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.MemberGroup; + +public class UpdateMemberGroupRequestModel : MemberGroupPresentationBase +{ +} diff --git a/src/Umbraco.Core/Services/IMemberGroupService.cs b/src/Umbraco.Core/Services/IMemberGroupService.cs index 24cc6845ad..67e0ac1997 100644 --- a/src/Umbraco.Core/Services/IMemberGroupService.cs +++ b/src/Umbraco.Core/Services/IMemberGroupService.cs @@ -1,20 +1,74 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; public interface IMemberGroupService : IService { + [Obsolete("Please use the asynchronous counterpart. Scheduled for removal in v15.")] IEnumerable GetAll(); + [Obsolete("Please use Guid instead of Int id. Scheduled for removal in v15.")] IMemberGroup? GetById(int id); + [Obsolete("Please use the asynchronous counterpart. Scheduled for removal in v15.")] IMemberGroup? GetById(Guid id); + [Obsolete("Please use the asynchronous counterpart. Scheduled for removal in v15.")] IEnumerable GetByIds(IEnumerable ids); IMemberGroup? GetByName(string? name); + [Obsolete("Please use the respective CreateAsync/UpdateAsync for you save operations. Scheduled for removal in v15.")] void Save(IMemberGroup memberGroup); + [Obsolete("Please use the asynchronous counterpart. Scheduled for removal in v15.")] void Delete(IMemberGroup memberGroup); + + /// + /// Get a member group by name. + /// + /// Name of the member group to get. + /// A object. + Task GetByNameAsync(string name); + + /// + /// Get a member group by key. + /// + /// of the member group to get. + /// A object. + Task GetAsync(Guid key); + + /// + /// Gets all member groups + /// + /// An enumerable list of objects. + Task> GetAllAsync(); + + /// + /// Gets a list of member groups with the given ids. + /// + /// An enumerable list of ids, to get the member groups by. + /// An enumerable list of objects. + Task> GetByIdsAsync(IEnumerable ids); + + /// + /// Creates a new object + /// + /// to create + /// An attempt with a status of whether the operation was successful or not, and the created object if it succeeded. + Task> CreateAsync(IMemberGroup memberGroup); + + /// + /// Deletes a by removing it and its usages from the db + /// + /// The key of the to delete + Task> DeleteAsync(Guid key); + + /// + /// Updates object + /// + /// to create + /// An attempt with a status of whether the operation was successful or not, and the object. + Task> UpdateAsync(IMemberGroup memberGroup); } diff --git a/src/Umbraco.Core/Services/MemberGroupService.cs b/src/Umbraco.Core/Services/MemberGroupService.cs index 5a68236455..bf490c2613 100644 --- a/src/Umbraco.Core/Services/MemberGroupService.cs +++ b/src/Umbraco.Core/Services/MemberGroupService.cs @@ -4,6 +4,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; @@ -15,26 +16,9 @@ internal class MemberGroupService : RepositoryService, IMemberGroupService : base(provider, loggerFactory, eventMessagesFactory) => _memberGroupRepository = memberGroupRepository; - public IEnumerable GetAll() - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _memberGroupRepository.GetMany(); - } - } + public IEnumerable GetAll() => GetAllAsync().GetAwaiter().GetResult(); - public IEnumerable GetByIds(IEnumerable ids) - { - if (ids == null || ids.Any() == false) - { - return new IMemberGroup[0]; - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _memberGroupRepository.GetMany(ids.ToArray()); - } - } + public IEnumerable GetByIds(IEnumerable ids) => GetByIdsAsync(ids).GetAwaiter().GetResult(); public IMemberGroup? GetById(int id) { @@ -44,21 +28,9 @@ internal class MemberGroupService : RepositoryService, IMemberGroupService } } - public IMemberGroup? GetById(Guid id) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _memberGroupRepository.Get(id); - } - } + public IMemberGroup? GetById(Guid id) => GetAsync(id).GetAwaiter().GetResult(); - public IMemberGroup? GetByName(string? name) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _memberGroupRepository.GetByName(name); - } - } + public IMemberGroup? GetByName(string? name) => name is null ? null : GetByNameAsync(name).GetAwaiter().GetResult(); public void Save(IMemberGroup memberGroup) { @@ -86,24 +58,139 @@ internal class MemberGroupService : RepositoryService, IMemberGroupService } } - public void Delete(IMemberGroup memberGroup) + public void Delete(IMemberGroup memberGroup) => DeleteAsync(memberGroup.Key).GetAwaiter().GetResult(); + + /// + public Task GetByNameAsync(string name) { - EventMessages evtMsgs = EventMessagesFactory.Get(); + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return Task.FromResult(_memberGroupRepository.GetByName(name)); + } - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + /// + public Task GetAsync(Guid key) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return Task.FromResult(_memberGroupRepository.Get(key)); + } + + /// + public Task> GetAllAsync() + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return Task.FromResult(_memberGroupRepository.GetMany()); + } + + public Task> GetByIdsAsync(IEnumerable ids) + { + if (ids.Any() == false) { - var deletingNotification = new MemberGroupDeletingNotification(memberGroup, evtMsgs); - if (scope.Notifications.PublishCancelable(deletingNotification)) - { - scope.Complete(); - return; - } - - _memberGroupRepository.Delete(memberGroup); - scope.Complete(); - - scope.Notifications.Publish( - new MemberGroupDeletedNotification(memberGroup, evtMsgs).WithStateFrom(deletingNotification)); + return Task.FromResult>(Array.Empty()); } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return Task.FromResult(_memberGroupRepository.GetMany(ids.ToArray())); + } + + /// + public async Task> CreateAsync(IMemberGroup memberGroup) + { + if (string.IsNullOrWhiteSpace(memberGroup.Name)) + { + return Attempt.FailWithStatus(MemberGroupOperationStatus.CannotHaveEmptyName, null); + } + + EventMessages eventMessages = EventMessagesFactory.Get(); + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IMemberGroup? existingMemberGroup = await GetAsync(memberGroup.Key); + if (existingMemberGroup is not null) + { + return Attempt.FailWithStatus(MemberGroupOperationStatus.DuplicateKey, null); + } + + if (await NameAlreadyExistsAsync(memberGroup)) + { + return Attempt.FailWithStatus(MemberGroupOperationStatus.DuplicateName, null); + } + + var savingNotification = new MemberGroupSavingNotification(memberGroup, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(savingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(MemberGroupOperationStatus.CancelledByNotification, null); + } + + _memberGroupRepository.Save(memberGroup); + scope.Complete(); + + scope.Notifications.Publish(new MemberGroupSavedNotification(memberGroup, eventMessages).WithStateFrom(savingNotification)); + return Attempt.SucceedWithStatus(MemberGroupOperationStatus.Success, memberGroup); + } + + /// + public async Task> DeleteAsync(Guid key) + { + EventMessages eventMessages = EventMessagesFactory.Get(); + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IMemberGroup? memberGroup = _memberGroupRepository.Get(key); + + if (memberGroup is null) + { + return Attempt.FailWithStatus(MemberGroupOperationStatus.NotFound, null); + } + + var deletingNotification = new MemberGroupDeletingNotification(memberGroup, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(deletingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(MemberGroupOperationStatus.CancelledByNotification, null); + } + + _memberGroupRepository.Delete(memberGroup); + scope.Complete(); + + scope.Notifications.Publish(new MemberGroupDeletedNotification(memberGroup, eventMessages).WithStateFrom(deletingNotification)); + + return Attempt.SucceedWithStatus(MemberGroupOperationStatus.Success, memberGroup); + } + + public async Task> UpdateAsync(IMemberGroup memberGroup) + { + if (string.IsNullOrWhiteSpace(memberGroup.Name)) + { + return Attempt.FailWithStatus(MemberGroupOperationStatus.CannotHaveEmptyName, null); + } + + EventMessages eventMessages = EventMessagesFactory.Get(); + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + IMemberGroup? existingMemberGroup = await GetByNameAsync(memberGroup.Name!); + + if (existingMemberGroup is not null && existingMemberGroup.Key != memberGroup.Key) + { + return Attempt.FailWithStatus(MemberGroupOperationStatus.DuplicateName, null); + } + + var savingNotification = new MemberGroupSavingNotification(memberGroup, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(savingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(MemberGroupOperationStatus.CancelledByNotification, null); + } + + _memberGroupRepository.Save(memberGroup); + scope.Complete(); + + scope.Notifications.Publish(new MemberGroupSavedNotification(memberGroup, eventMessages).WithStateFrom(savingNotification)); + return Attempt.SucceedWithStatus(MemberGroupOperationStatus.Success, memberGroup); + } + + private async Task NameAlreadyExistsAsync(IMemberGroup memberGroup) + { + IMemberGroup? existingMemberGroup = await GetByNameAsync(memberGroup.Name!); + return existingMemberGroup is not null; } } diff --git a/src/Umbraco.Core/Services/OperationStatus/MemberGroupOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/MemberGroupOperationStatus.cs new file mode 100644 index 0000000000..8345aeecfd --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/MemberGroupOperationStatus.cs @@ -0,0 +1,11 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum MemberGroupOperationStatus +{ + Success, + NotFound, + CannotHaveEmptyName, + CancelledByNotification, + DuplicateName, + DuplicateKey, +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MemberGroupServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MemberGroupServiceTests.cs new file mode 100644 index 0000000000..cdf64709ff --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MemberGroupServiceTests.cs @@ -0,0 +1,164 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +/// +/// Tests covering the MemberGroupService +/// +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class MemberGroupServiceTests : UmbracoIntegrationTest +{ + private IMemberGroupService MemberGroupService => GetRequiredService(); + + [Test] + public async Task Can_Create_MemberGroup() + { + var memberGroup = new MemberGroup + { + Name = "TestGroup", + }; + await MemberGroupService.CreateAsync(memberGroup); + + var fetchedGroup = await MemberGroupService.GetAsync(memberGroup.Key); + + Assert.Multiple(() => + { + Assert.IsNotNull(fetchedGroup); + Assert.AreEqual(fetchedGroup, memberGroup); + }); + } + + [Test] + public async Task Can_Create_MemberGroup_With_Key() + { + Guid key = Guid.NewGuid(); + var memberGroup = new MemberGroup + { + Name = "TestGroup", + Key = key, + }; + await MemberGroupService.CreateAsync(memberGroup); + + var fetchedGroup = await MemberGroupService.GetAsync(memberGroup.Key); + + Assert.Multiple(() => + { + Assert.IsNotNull(fetchedGroup); + Assert.AreEqual(key, fetchedGroup.Key); + Assert.AreEqual(fetchedGroup, memberGroup); + }); + } + + [Test] + public async Task Can_Update_MemberGroup() + { + const string updatedName = "UpdatedName"; + var memberGroup = new MemberGroup + { + Name = "TestGroup", + }; + await MemberGroupService.CreateAsync(memberGroup); + + memberGroup.Name = updatedName; + await MemberGroupService.UpdateAsync(memberGroup); + + var fetchedGroup = await MemberGroupService.GetAsync(memberGroup.Key); + + Assert.Multiple(() => + { + Assert.IsNotNull(fetchedGroup); + Assert.AreEqual(fetchedGroup.Name, updatedName); + Assert.AreEqual(fetchedGroup, memberGroup); + }); + } + + [Test] + public async Task Can_Delete_MemberGroup() + { + var memberGroup = new MemberGroup + { + Name = "TestGroup", + }; + await MemberGroupService.CreateAsync(memberGroup); + + var fetchedGroup = await MemberGroupService.GetAsync(memberGroup.Key); + + Assert.IsNotNull(fetchedGroup); + + await MemberGroupService.DeleteAsync(memberGroup.Key); + + // re-get + fetchedGroup = await MemberGroupService.GetAsync(memberGroup.Key); + + Assert.IsNull(fetchedGroup); + } + + [Test] + public async Task Cannot_Create_MemberGroup_With_Duplicate_Name() + { + const string name = "TestGroup"; + var memberGroupOne = new MemberGroup + { + Name = name, + }; + var memberGroupTwo = new MemberGroup + { + Name = name, + }; + var attemptOne = await MemberGroupService.CreateAsync(memberGroupOne); + + Assert.Multiple(() => + { + Assert.IsTrue(attemptOne.Success); + Assert.AreEqual(MemberGroupOperationStatus.Success, attemptOne.Status); + }); + + var attemptTwo = await MemberGroupService.CreateAsync(memberGroupTwo); + + Assert.Multiple(() => + { + Assert.IsFalse(attemptTwo.Success); + Assert.AreEqual(MemberGroupOperationStatus.DuplicateName, attemptTwo.Status); + }); + } + + [Test] + public async Task Cannot_Update_MemberGroup_With_Duplicate_Name() + { + const string name = "TestGroup"; + var memberGroupOne = new MemberGroup + { + Name = name, + }; + var memberGroupTwo = new MemberGroup + { + Name = "TestGroupTwo", + }; + var attemptOne = await MemberGroupService.CreateAsync(memberGroupOne); + var attemptTwo = await MemberGroupService.CreateAsync(memberGroupTwo); + + Assert.Multiple(() => + { + Assert.IsTrue(attemptOne.Success); + Assert.AreEqual(MemberGroupOperationStatus.Success, attemptOne.Status); + Assert.IsTrue(attemptTwo.Success); + Assert.AreEqual(MemberGroupOperationStatus.Success, attemptTwo.Status); + }); + + // Update to already existing name. + memberGroupTwo.Name = name; + var updateAttempt = await MemberGroupService.UpdateAsync(memberGroupTwo); + + Assert.Multiple(() => + { + Assert.IsFalse(updateAttempt.Success); + Assert.AreEqual(MemberGroupOperationStatus.DuplicateName, updateAttempt.Status); + }); + } +}