diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateAndPublishDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateAndPublishDocumentController.cs new file mode 100644 index 000000000000..7823dc323e99 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateAndPublishDocumentController.cs @@ -0,0 +1,105 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +[ApiVersion("1.0")] +public class CreateAndPublishDocumentController : DocumentControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + private readonly IContentEditingService _contentEditingService; + private readonly IContentPublishingService _contentPublishingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public CreateAndPublishDocumentController( + IAuthorizationService authorizationService, + IDocumentEditingPresentationFactory documentEditingPresentationFactory, + IContentEditingService contentEditingService, + IContentPublishingService contentPublishingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _authorizationService = authorizationService; + _documentEditingPresentationFactory = documentEditingPresentationFactory; + _contentEditingService = contentEditingService; + _contentPublishingService = contentPublishingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPost("create-and-publish")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task CreateAndPublish( + CancellationToken cancellationToken, + CreateAndPublishDocumentRequestModel requestModel) + { + // Authorize both create and publish permissions upfront. + AuthorizationResult createAuthorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.WithKeys(ActionNew.ActionLetter, requestModel.Parent?.Id), + AuthorizationPolicies.ContentPermissionByResource); + + AuthorizationResult publishAuthorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.WithKeys(ActionPublish.ActionLetter, requestModel.Parent?.Id, requestModel.Cultures.OfType()), + AuthorizationPolicies.ContentPermissionByResource); + + if (createAuthorizationResult.Succeeded is false || publishAuthorizationResult.Succeeded is false) + { + return Forbidden(); + } + + // Create the document. + ContentCreateModel createModel = _documentEditingPresentationFactory.MapCreateModel(requestModel); + Attempt createResult = + await _contentEditingService.CreateAsync(createModel, CurrentUserKey(_backOfficeSecurityAccessor)); + + if (createResult.Success is false) + { + return ContentEditingOperationStatusResult(createResult.Status); + } + + // If create had validation errors, don't attempt to publish - it will fail. + if (createResult.Status == ContentEditingOperationStatus.PropertyValidationError) + { + return DocumentPublishingOperationStatusResult( + ContentPublishingOperationStatus.ContentInvalid, + invalidPropertyAliases: createResult.Result.ValidationResult.ValidationErrors.Select(e => e.Alias)); + } + + // Build immediate publish model (no schedule). + IList culturePublishSchedules = GetImmediateCulturePublishSchedule(requestModel.Cultures); + + // Publish the document immediately using the already-loaded content. + // Skip validation since create succeeded with no validation errors. + Attempt publishResult = + await _contentPublishingService.PublishAsync( + createResult.Result.Content!, + culturePublishSchedules, + CurrentUserKey(_backOfficeSecurityAccessor), + skipValidation: true); + + if (publishResult.Success is false) + { + return DocumentPublishingOperationStatusResult(publishResult.Status, invalidPropertyAliases: publishResult.Result.InvalidPropertyAliases); + } + + return CreatedAtId(controller => nameof(controller.ByKey), createResult.Result.Content!.Key); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs index da3c8a6c3d7c..f24ab345b051 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs @@ -194,4 +194,9 @@ protected IActionResult ContentQueryOperationStatusResult(ContentQueryOperationS .WithTitle("Unknown content query status.") .Build()), }); + + protected static IList GetImmediateCulturePublishSchedule(IEnumerable cultures) => + cultures + .Select(culture => new CulturePublishScheduleModel { Culture = culture }) + .ToList(); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentController.cs index 64512cc30f9b..bd585b5787c7 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentController.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; -using Umbraco.Cms.Api.Management.Security.Authorization.Content; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Actions; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs new file mode 100644 index 000000000000..2b7f98060b35 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs @@ -0,0 +1,106 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +[ApiVersion("1.0")] +public class UpdateAndPublishDocumentController : DocumentControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + private readonly IContentEditingService _contentEditingService; + private readonly IContentPublishingService _contentPublishingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public UpdateAndPublishDocumentController( + IAuthorizationService authorizationService, + IDocumentEditingPresentationFactory documentEditingPresentationFactory, + IContentEditingService contentEditingService, + IContentPublishingService contentPublishingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _authorizationService = authorizationService; + _documentEditingPresentationFactory = documentEditingPresentationFactory; + _contentEditingService = contentEditingService; + _contentPublishingService = contentPublishingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPut("{id:guid}/update-and-publish")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task UpdateAndPublish( + CancellationToken cancellationToken, + Guid id, + UpdateAndPublishDocumentRequestModel requestModel) + { + // Authorize both update and publish permissions upfront. + AuthorizationResult updateAuthorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.WithKeys(ActionUpdate.ActionLetter, id), + AuthorizationPolicies.ContentPermissionByResource); + + AuthorizationResult publishAuthorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.WithKeys(ActionPublish.ActionLetter, id, requestModel.Cultures.OfType()), + AuthorizationPolicies.ContentPermissionByResource); + + if (updateAuthorizationResult.Succeeded is false || publishAuthorizationResult.Succeeded is false) + { + return Forbidden(); + } + + // Update the document. + ContentUpdateModel updateModel = _documentEditingPresentationFactory.MapUpdateModel(requestModel); + Attempt updateResult = + await _contentEditingService.UpdateAsync(id, updateModel, CurrentUserKey(_backOfficeSecurityAccessor)); + + if (updateResult.Success is false) + { + return ContentEditingOperationStatusResult(updateResult.Status); + } + + // If update had validation errors, don't attempt to publish - it will fail. + if (updateResult.Status == ContentEditingOperationStatus.PropertyValidationError) + { + return DocumentPublishingOperationStatusResult( + ContentPublishingOperationStatus.ContentInvalid, + invalidPropertyAliases: updateResult.Result.ValidationResult.ValidationErrors.Select(e => e.Alias)); + } + + // Build immediate publish model (no schedule). + IList culturePublishSchedules = GetImmediateCulturePublishSchedule(requestModel.Cultures); + + // Publish the document immediately using the already-loaded content. + // Skip validation since update succeeded with no validation errors. + Attempt publishResult = + await _contentPublishingService.PublishAsync( + updateResult.Result.Content!, + culturePublishSchedules, + CurrentUserKey(_backOfficeSecurityAccessor), + skipValidation: true); + + if (publishResult.Success is false) + { + return DocumentPublishingOperationStatusResult(publishResult.Status, invalidPropertyAliases: publishResult.Result.InvalidPropertyAliases); + } + + return Ok(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 0b5e73b3223b..3568b790a85d 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -10570,6 +10570,148 @@ ] } }, + "/umbraco/management/api/v1/document/{id}/update-and-publish": { + "put": { + "tags": [ + "Document" + ], + "operationId": "PutDocumentByIdUpdateAndPublish", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateAndPublishDocumentRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateAndPublishDocumentRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateAndPublishDocumentRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "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": { + "oneOf": [ + { + "$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": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, "/umbraco/management/api/v1.1/document/{id}/validate": { "put": { "tags": ["Document"], @@ -10814,6 +10956,152 @@ ] } }, + "/umbraco/management/api/v1/document/create-and-publish": { + "post": { + "tags": [ + "Document" + ], + "operationId": "PostDocumentCreateAndPublish", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateAndPublishDocumentRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateAndPublishDocumentRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateAndPublishDocumentRequestModel" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "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": { + "oneOf": [ + { + "$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": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/document/sort": { "put": { "tags": ["Document"], @@ -38531,6 +38819,73 @@ }, "additionalProperties": false }, + "CreateAndPublishDocumentRequestModel": { + "required": [ + "cultures", + "documentType", + "template", + "values", + "variants" + ], + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentValueModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantRequestModel" + } + ] + } + }, + "id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "parent": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + }, + "template": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "cultures": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "CreateDataTypeRequestModel": { "required": ["editorAlias", "editorUiAlias", "name", "values"], "type": "object", @@ -39255,6 +39610,14 @@ "variesBySegment": { "type": "boolean" }, + "collection": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, "isElement": { "type": "boolean" }, @@ -39310,14 +39673,6 @@ } ] } - }, - "collection": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ], - "nullable": true } }, "additionalProperties": false @@ -48721,6 +49076,51 @@ }, "additionalProperties": false }, + "UpdateAndPublishDocumentRequestModel": { + "required": [ + "cultures", + "values", + "variants" + ], + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentValueModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantRequestModel" + } + ] + } + }, + "template": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "cultures": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "UpdateDataTypeRequestModel": { "required": ["editorAlias", "editorUiAlias", "name", "values"], "type": "object", diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/CreateAndPublishDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/CreateAndPublishDocumentRequestModel.cs new file mode 100644 index 000000000000..61d1e0d51ac6 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/CreateAndPublishDocumentRequestModel.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class CreateAndPublishDocumentRequestModel : CreateDocumentRequestModel +{ + /// + /// Gets or sets the cultures to publish immediately. Use null for invariant content. + /// + public required IEnumerable Cultures { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/UpdateAndPublishDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/UpdateAndPublishDocumentRequestModel.cs new file mode 100644 index 000000000000..19ab9dd975a4 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/UpdateAndPublishDocumentRequestModel.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class UpdateAndPublishDocumentRequestModel : UpdateDocumentRequestModel +{ + /// + /// Gets or sets the cultures to publish immediately. Use null for invariant content. + /// + public required IEnumerable Cultures { get; set; } +} diff --git a/src/Umbraco.Core/Services/ContentPublishingService.cs b/src/Umbraco.Core/Services/ContentPublishingService.cs index 26978151804d..1b61b98a3073 100644 --- a/src/Umbraco.Core/Services/ContentPublishingService.cs +++ b/src/Umbraco.Core/Services/ContentPublishingService.cs @@ -82,10 +82,46 @@ public async Task culturesToPublishOrSchedule, Guid userKey) { - var culturesToPublishImmediately = - culturesToPublishOrSchedule.Where(culture => culture.Schedule is null).Select(c => c.Culture ?? Constants.System.InvariantCulture).ToHashSet(); + CultureAndScheduleModel cultureAndSchedule = BuildCultureAndScheduleModel(key, culturesToPublishOrSchedule); - ContentScheduleCollection schedules = _contentService.GetContentScheduleByContentId(key); + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.ContentTree); + + IContent? content = _contentService.GetById(key); + if (content is null) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.ContentNotFound, new ContentPublishingResult()); + } + + return await PublishAsync(scope, content, cultureAndSchedule, userKey); + } + + /// + public async Task> PublishAsync( + IContent content, + ICollection culturesToPublishOrSchedule, + Guid userKey, + bool skipValidation = false) + { + CultureAndScheduleModel cultureAndSchedule = BuildCultureAndScheduleModel(content.Key, culturesToPublishOrSchedule); + + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.ContentTree); + + return await PublishAsync(scope, content, cultureAndSchedule, userKey, skipValidation); + } + + private CultureAndScheduleModel BuildCultureAndScheduleModel( + Guid contentKey, + ICollection culturesToPublishOrSchedule) + { + var culturesToPublishImmediately = culturesToPublishOrSchedule + .Where(culture => culture.Schedule is null) + .Select(c => c.Culture ?? Constants.System.InvariantCulture) + .ToHashSet(); + + ContentScheduleCollection schedules = _contentService.GetContentScheduleByContentId(contentKey); foreach (CulturePublishScheduleModel cultureToSchedule in culturesToPublishOrSchedule.Where(c => c.Schedule is not null)) { @@ -110,31 +146,20 @@ public async Task - // TODO - Integrate this implementation into the one above. private async Task> PublishAsync( - Guid key, + ICoreScope scope, + IContent content, CultureAndScheduleModel cultureAndSchedule, - Guid userKey) + Guid userKey, + bool skipValidation = false) { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - scope.WriteLock(Constants.Locks.ContentTree); - IContent? content = _contentService.GetById(key); - if (content is null) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.ContentNotFound, new ContentPublishingResult()); - } - // If nothing is requested for publish or scheduling, clear all schedules and publish nothing. if (cultureAndSchedule.CulturesToPublishImmediately.Count == 0 && cultureAndSchedule.Schedules.FullSchedule.Count == 0) @@ -204,18 +229,20 @@ private async Task property.Alias).ToArray() - }); + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.ContentInvalid, new ContentPublishingResult + { + Content = content, + InvalidPropertyAliases = validationResult.ValidationErrors.Select(property => property.Alias).ToArray() + }); + } } - var userId = await _userIdKeyResolver.GetAsync(userKey); PublishResult? result = null; @@ -249,7 +276,7 @@ private async Task property.Alias).ToArray() - ?? Enumerable.Empty() + ?? Enumerable.Empty(), }); } diff --git a/src/Umbraco.Core/Services/IContentPublishingService.cs b/src/Umbraco.Core/Services/IContentPublishingService.cs index 85ec74007c6e..121ebb5af7e1 100644 --- a/src/Umbraco.Core/Services/IContentPublishingService.cs +++ b/src/Umbraco.Core/Services/IContentPublishingService.cs @@ -55,6 +55,27 @@ public interface IContentPublishingService Task> PublishAsync( Guid key, ICollection culturesToPublishOrSchedule, - Guid userKey) => StaticServiceProvider.Instance.GetRequiredService() - .PublishAsync(key, culturesToPublishOrSchedule, userKey); + Guid userKey); + + /// + /// Publishes a single content item using an already-loaded content entity. + /// + /// The content entity to publish. + /// The cultures to publish or schedule. + /// The identifier of the user performing the operation. + /// + /// When true, skips property validation. Only use when the caller has already validated + /// the content (e.g., after a successful create/update with no validation errors). + /// + /// Result of the publish operation. + /// + /// Use this overload when you already have the IContent entity (e.g., after creating or updating) + /// to avoid an unnecessary database round-trip. + /// + // TODO (18): Remove the default implementation. + Task> PublishAsync( + IContent content, + ICollection culturesToPublishOrSchedule, + Guid userKey, + bool skipValidation = false) => PublishAsync(content.Key, culturesToPublishOrSchedule, userKey); } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/CreateAndPublishDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/CreateAndPublishDocumentControllerTests.cs new file mode 100644 index 000000000000..bd0247f344df --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/CreateAndPublishDocumentControllerTests.cs @@ -0,0 +1,90 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Document; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Document; + +public class CreateAndPublishDocumentControllerTests : ManagementApiUserGroupTestBase +{ + private ITemplateService TemplateService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _templateKey; + private Guid _contentTypeKey; + + [SetUp] + public async Task Setup() + { + // Template + var template = TemplateBuilder.CreateTextPageTemplate(Guid.NewGuid().ToString()); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + _templateKey = template.Key; + + // Content Type + var contentType = ContentTypeBuilder.CreateTextPageContentType(defaultTemplateId: template.Id, name: Guid.NewGuid().ToString(), alias: Guid.NewGuid().ToString()); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + _contentTypeKey = contentType.Key; + } + + protected override Expression> MethodSelector => + x => x.CreateAndPublish(CancellationToken.None, null); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Created + }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Created + }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Forbidden + }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Forbidden + }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel => new() + { + // Writers can create but cannot publish + ExpectedStatusCode = HttpStatusCode.Forbidden + }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Unauthorized + }; + + protected override async Task ClientRequest() + { + CreateAndPublishDocumentRequestModel requestModel = new() + { + Template = new ReferenceByIdModel(_templateKey), + DocumentType = new ReferenceByIdModel(_contentTypeKey), + Parent = null, + Id = Guid.NewGuid(), + Values = [], + Variants = + [ + new() { Culture = null, Segment = null, Name = "Test Document" }, + ], + Cultures = [null], // null for invariant content + }; + + return await Client.PostAsync(Url, JsonContent.Create(requestModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/UpdateAndPublishDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/UpdateAndPublishDocumentControllerTests.cs new file mode 100644 index 000000000000..c643313108cd --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/UpdateAndPublishDocumentControllerTests.cs @@ -0,0 +1,97 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Document; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Document; + +public class UpdateAndPublishDocumentControllerTests : ManagementApiUserGroupTestBase +{ + private IContentEditingService ContentEditingService => GetRequiredService(); + + private ITemplateService TemplateService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _templateKey; + private Guid _documentKey; + + [SetUp] + public async Task Setup() + { + // Template + var template = TemplateBuilder.CreateTextPageTemplate(Guid.NewGuid().ToString()); + var templateResponse = await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + _templateKey = templateResponse.Result.Key; + + // Content Type + var contentType = ContentTypeBuilder.CreateTextPageContentType(defaultTemplateId: template.Id, name: Guid.NewGuid().ToString(), alias: Guid.NewGuid().ToString()); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Content (unpublished) + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + TemplateKey = _templateKey, + ParentKey = Constants.System.RootKey, + Variants = new List { new() { Name = Guid.NewGuid().ToString() } }, + }; + var response = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _documentKey = response.Result.Content.Key; + } + + protected override Expression> MethodSelector => + x => x.UpdateAndPublish(CancellationToken.None, _documentKey, null); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.OK + }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.OK + }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Forbidden + }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Forbidden + }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel => new() + { + // Writers can update but cannot publish + ExpectedStatusCode = HttpStatusCode.Forbidden + }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Unauthorized + }; + + protected override async Task ClientRequest() + { + UpdateAndPublishDocumentRequestModel requestModel = new() + { + Variants = + [ + new() { Culture = null, Segment = null, Name = "Updated Name" }, + ], + Cultures = [null], // null for invariant content + }; + + return await Client.PutAsync(Url, JsonContent.Create(requestModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs index 82d08c9325a0..540593bf1b0b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs @@ -817,4 +817,71 @@ private void AssertBranchResultFailed(ContentPublishingBranchResult result, para Assert.AreEqual(status, item.OperationStatus); } } + + [Test] + public async Task Can_Publish_Valid_Content_Using_IContent_Overload_With_SkipValidation() + { + // Arrange: Use Textpage which has valid content + VerifyIsNotPublished(Textpage.Key); + + // Act: Publish using IContent overload with skipValidation: true + // This simulates what CreateAndPublishDocumentController does after a successful create + var result = await ContentPublishingService.PublishAsync( + Textpage, + [new CulturePublishScheduleModel()], + Constants.Security.SuperUserKey, + skipValidation: true); + + // Assert: Should succeed - content is valid + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Status); + VerifyIsPublished(Textpage.Key); + } + + [Test] + public async Task Cannot_Publish_Invalid_Content_Even_When_SkipValidation_Is_True() + { + // Arrange: Create invalid content (mandatory properties set to empty) + var content = await CreateInvalidContent(); + + // Act: Publish using IContent overload with skipValidation: true + // Note: skipValidation only skips ContentPublishingService's early validation, + // but ContentService.Publish still validates as a safety check + var result = await ContentPublishingService.PublishAsync( + content, + [new CulturePublishScheduleModel()], + Constants.Security.SuperUserKey, + skipValidation: true); + + // Assert: Should still fail because ContentService.Publish validates + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentPublishingOperationStatus.ContentInvalid, result.Status); + VerifyIsNotPublished(content.Key); + } + + [Test] + public async Task Cannot_Publish_Invalid_Content_When_SkipValidation_Is_False() + { + // Arrange: Create invalid content (mandatory properties set to empty) + var content = await CreateInvalidContent(); + + // Act: Publish using IContent overload with skipValidation: false (default) + var result = await ContentPublishingService.PublishAsync( + content, + [new CulturePublishScheduleModel()], + Constants.Security.SuperUserKey, + skipValidation: false); + + // Assert: Should fail with ContentInvalid and return invalid property aliases + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentPublishingOperationStatus.ContentInvalid, result.Status); + + var invalidPropertyAliases = result.Result.InvalidPropertyAliases.ToArray(); + Assert.AreEqual(3, invalidPropertyAliases.Length); + Assert.Contains("title", invalidPropertyAliases); + Assert.Contains("bodyText", invalidPropertyAliases); + Assert.Contains("author", invalidPropertyAliases); + + VerifyIsNotPublished(content.Key); + } }