diff --git a/docs/schema/V1/swagger.verified.json b/docs/schema/V1/swagger.verified.json index ee359b42f..5776bd1b1 100644 --- a/docs/schema/V1/swagger.verified.json +++ b/docs/schema/V1/swagger.verified.json @@ -6379,6 +6379,72 @@ ] } }, + "/api/v1/serviceowner/dialogs/{dialogId}/actions/restore": { + "post": { + "description": "Restore a dialog. For more information see the documentation (link TBD). ", + "operationId": "V1ServiceOwnerDialogsRestore_RestoreDialog", + "parameters": [ + { + "in": "path", + "name": "dialogId", + "required": true, + "schema": { + "format": "guid", + "type": "string" + } + }, + { + "in": "header", + "name": "if-Match", + "schema": { + "format": "guid", + "nullable": true, + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "The dialog aggregate was restored successfully." + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "The given dialog ID was not found." + }, + "412": { + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "The supplied If-Match header did not match the current Revision value for the dialog. The request was not applied." + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ], + "summary": "Restore a dialog", + "tags": [ + "Serviceowner" + ] + } + }, "/api/v1/serviceowner/dialogs/{dialogId}/actions/should-send-notification": { "get": { "description": "Used by Altinn Notification only. Takes a dialogId and returns a boolean value based on conditions used to determine if a notification is to be sent.", diff --git a/src/Digdir.Domain.Dialogporten.Application/Externals/IUnitOfWork.cs b/src/Digdir.Domain.Dialogporten.Application/Externals/IUnitOfWork.cs index 92db5aee3..47a571ab5 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Externals/IUnitOfWork.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Externals/IUnitOfWork.cs @@ -7,7 +7,6 @@ namespace Digdir.Domain.Dialogporten.Application.Externals; public interface IUnitOfWork { - IUnitOfWork WithoutAggregateSideEffects(); Task SaveChangesAsync(CancellationToken cancellationToken = default); IUnitOfWork EnableConcurrencyCheck( @@ -16,6 +15,11 @@ IUnitOfWork EnableConcurrencyCheck( where TEntity : class, IVersionableEntity; Task BeginTransactionAsync(CancellationToken cancellationToken = default); + + IUnitOfWork DisableAggregateFilter(); + IUnitOfWork DisableVersionableFilter(); + IUnitOfWork DisableUpdatableFilter(); + IUnitOfWork DisableSoftDeletableFilter(); } [GenerateOneOf] diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Events/AltinnForwarders/DialogEventToAltinnForwarder.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Events/AltinnForwarders/DialogEventToAltinnForwarder.cs index 2b5b57c63..ee8a0d7d1 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Events/AltinnForwarders/DialogEventToAltinnForwarder.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Events/AltinnForwarders/DialogEventToAltinnForwarder.cs @@ -11,6 +11,7 @@ internal sealed class DialogEventToAltinnForwarder : DomainEventToAltinnForwarde INotificationHandler, INotificationHandler, INotificationHandler, + INotificationHandler, INotificationHandler { public DialogEventToAltinnForwarder(ICloudEventBus cloudEventBus, IOptions settings) @@ -108,6 +109,30 @@ public async Task Handle(DialogDeletedDomainEvent domainEvent, CancellationToken await CloudEventBus.Publish(cloudEvent, cancellationToken); } + [EndpointName("DialogEventToAltinnForwarder_DialogRestoredDomainEvent")] + public async Task Handle(DialogRestoredDomainEvent domainEvent, CancellationToken cancellationToken) + { + if (domainEvent.ShouldNotBeSentToAltinnEvents()) + { + return; + } + + var cloudEvent = new CloudEvent + { + Id = domainEvent.EventId, + Type = CloudEventTypes.Get(nameof(DialogRestoredDomainEvent)), + Time = domainEvent.OccurredAt, + Resource = domainEvent.ServiceResource, + ResourceInstance = domainEvent.DialogId.ToString(), + Subject = domainEvent.Party, + Source = $"{SourceBaseUrl()}{domainEvent.DialogId}", + Data = GetCloudEventData(domainEvent) + }; + + await CloudEventBus.Publish(cloudEvent, cancellationToken); + + } + private static Dictionary? GetCloudEventData(IProcessEvent domainEvent) { var data = new Dictionary(); @@ -123,4 +148,5 @@ public async Task Handle(DialogDeletedDomainEvent domainEvent, CancellationToken return data.Count == 0 ? null : data; } + } diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Events/CloudEventTypes.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Events/CloudEventTypes.cs index 011f9ecad..3ff5f3e47 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Events/CloudEventTypes.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Events/CloudEventTypes.cs @@ -8,6 +8,7 @@ internal static class CloudEventTypes internal static string Get(string eventName) => eventName switch { // Dialog + nameof(DialogRestoredDomainEvent) => "dialogporten.dialog.restored.v1", nameof(DialogCreatedDomainEvent) => "dialogporten.dialog.created.v1", nameof(DialogUpdatedDomainEvent) => "dialogporten.dialog.updated.v1", nameof(DialogDeletedDomainEvent) => "dialogporten.dialog.deleted.v1", diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Get/GetDialogQuery.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Get/GetDialogQuery.cs index 8b08da294..7c807edc6 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Get/GetDialogQuery.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Get/GetDialogQuery.cs @@ -125,7 +125,8 @@ public async Task Handle(GetDialogQuery request, CancellationTo currentUserInformation.Name); var saveResult = await _unitOfWork - .WithoutAggregateSideEffects() + .DisableUpdatableFilter() + .DisableVersionableFilter() .SaveChangesAsync(cancellationToken); saveResult.Switch( diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Restore/RestoreDialogCommand.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Restore/RestoreDialogCommand.cs new file mode 100644 index 000000000..56437b4e0 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Restore/RestoreDialogCommand.cs @@ -0,0 +1,71 @@ +using System.Diagnostics; +using Digdir.Domain.Dialogporten.Application.Common; +using Digdir.Domain.Dialogporten.Application.Common.Extensions; +using Digdir.Domain.Dialogporten.Application.Common.ReturnTypes; +using Digdir.Domain.Dialogporten.Application.Externals; +using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities; +using Digdir.Library.Entity.Abstractions.Features.SoftDeletable; +using MediatR; +using Microsoft.EntityFrameworkCore; +using OneOf; + +namespace Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Restore; + +public sealed class RestoreDialogCommand : IRequest +{ + public Guid DialogId { get; set; } + public Guid? IfMatchDialogRevision { get; set; } +} + +[GenerateOneOf] +public sealed partial class RestoreDialogResult : OneOfBase; + +public sealed record RestoreDialogSuccess(Guid Revision); + +internal sealed class RestoreDialogCommandHandler : IRequestHandler +{ + private readonly IDialogDbContext _db; + private readonly IUnitOfWork _unitOfWork; + private readonly IUserResourceRegistry _userResourceRegistry; + + public RestoreDialogCommandHandler(IDialogDbContext db, IUnitOfWork unitOfWork, IUserResourceRegistry userResourceRegistry) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); + _userResourceRegistry = userResourceRegistry ?? throw new ArgumentNullException(nameof(userResourceRegistry)); + } + + public async Task Handle(RestoreDialogCommand request, CancellationToken cancellationToken) + { + var resourceIds = await _userResourceRegistry.GetCurrentUserResourceIds(cancellationToken); + + var dialog = await _db.Dialogs + .WhereIf(!_userResourceRegistry.IsCurrentUserServiceOwnerAdmin(), x => resourceIds.Contains(x.ServiceResource)) + .IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == request.DialogId, cancellationToken); + + if (dialog is null) + { + return new EntityNotFound(request.DialogId); + } + + if (!dialog.Deleted) + { + return new RestoreDialogSuccess(dialog.Revision); + } + + dialog.Restore(); + + var saveResult = await _unitOfWork + .DisableUpdatableFilter() + .DisableSoftDeletableFilter() + .EnableConcurrencyCheck(dialog, request.IfMatchDialogRevision) + .SaveChangesAsync(cancellationToken); + + return saveResult.Match( + success => new RestoreDialogSuccess(dialog.Revision), + domainError => throw new UnreachableException("Should never get a domain error when restoring a dialog"), + concurrencyError => concurrencyError + ); + } +} diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Get/GetDialogQuery.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Get/GetDialogQuery.cs index d33d7fe5c..c6910d68c 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Get/GetDialogQuery.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Get/GetDialogQuery.cs @@ -115,7 +115,8 @@ public async Task Handle(GetDialogQuery request, CancellationTo currentUserInformation.Name); var saveResult = await _unitOfWork - .WithoutAggregateSideEffects() + .DisableUpdatableFilter() + .DisableVersionableFilter() .SaveChangesAsync(cancellationToken); saveResult.Switch( diff --git a/src/Digdir.Domain.Dialogporten.Domain/Dialogs/Entities/DialogEntity.cs b/src/Digdir.Domain.Dialogporten.Domain/Dialogs/Entities/DialogEntity.cs index 78aaba71e..86e9fdadb 100644 --- a/src/Digdir.Domain.Dialogporten.Domain/Dialogs/Entities/DialogEntity.cs +++ b/src/Digdir.Domain.Dialogporten.Domain/Dialogs/Entities/DialogEntity.cs @@ -19,7 +19,8 @@ public sealed class DialogEntity : ISoftDeletableEntity, IVersionableEntity, IAggregateChangedHandler, - IEventPublisher + IEventPublisher, + IAggregateRestoredHandler { public Guid Id { get; set; } public Guid Revision { get; set; } @@ -84,6 +85,9 @@ public void OnUpdate(AggregateNode self, DateTimeOffset utcNow) => public void OnDelete(AggregateNode self, DateTimeOffset utcNow) => _domainEvents.Add(new DialogDeletedDomainEvent(Id, ServiceResource, Party, Process, PrecedingProcess)); + public void OnRestore(AggregateNode self, DateTimeOffset utcNow) => + _domainEvents.Add(new DialogRestoredDomainEvent(Id, ServiceResource, Party, Process, PrecedingProcess)); + public void UpdateSeenAt(string endUserId, DialogUserType.Values userTypeId, string? endUserName) { var lastSeenAt = SeenLog diff --git a/src/Digdir.Domain.Dialogporten.Domain/Dialogs/Events/DialogRestoredDomainEvent.cs b/src/Digdir.Domain.Dialogporten.Domain/Dialogs/Events/DialogRestoredDomainEvent.cs new file mode 100644 index 000000000..e5fb90215 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Domain/Dialogs/Events/DialogRestoredDomainEvent.cs @@ -0,0 +1,10 @@ +using Digdir.Domain.Dialogporten.Domain.Common.DomainEvents; + +namespace Digdir.Domain.Dialogporten.Domain.Dialogs.Events; + +public sealed record DialogRestoredDomainEvent( + Guid DialogId, + string ServiceResource, + string Party, + string? Process, + string? PrecedingProcess) : DomainEvent, IProcessEvent; diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Persistence/DialogDbContext.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Persistence/DialogDbContext.cs index da2764034..fed4a1d71 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Persistence/DialogDbContext.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Persistence/DialogDbContext.cs @@ -62,8 +62,9 @@ internal bool TrySetOriginalRevision( } var prop = Entry(entity).Property(x => x.Revision); + var isModified = prop.IsModified; prop.OriginalValue = revision.Value; - prop.IsModified = false; + prop.IsModified = isModified; return true; } diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/UnitOfWork.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/UnitOfWork.cs index 035371770..86a6caf7b 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/UnitOfWork.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/UnitOfWork.cs @@ -25,10 +25,10 @@ internal sealed class UnitOfWork : IUnitOfWork, IAsyncDisposable, IDisposable private readonly DialogDbContext _dialogDbContext; private readonly ITransactionTime _transactionTime; private readonly IDomainContext _domainContext; + private readonly SaveChangesOptions _saveChangesOptions = new(); private IDbContextTransaction? _transaction; - private bool _aggregateSideEffects = true; private bool _enableConcurrencyCheck; public UnitOfWork(DialogDbContext dialogDbContext, ITransactionTime transactionTime, IDomainContext domainContext) @@ -51,14 +51,32 @@ public IUnitOfWork EnableConcurrencyCheck( return this; } - public IUnitOfWork WithoutAggregateSideEffects() + public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) + => _transaction ??= await _dialogDbContext.Database.BeginTransactionAsync(cancellationToken); + + public IUnitOfWork DisableAggregateFilter() { - _aggregateSideEffects = false; + _saveChangesOptions.EnableAggregateFilter = false; return this; } - public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) - => _transaction ??= await _dialogDbContext.Database.BeginTransactionAsync(cancellationToken); + public IUnitOfWork DisableVersionableFilter() + { + _saveChangesOptions.EnableVersionableFilter = false; + return this; + } + + public IUnitOfWork DisableUpdatableFilter() + { + _saveChangesOptions.EnableUpdatableFilter = false; + return this; + } + + public IUnitOfWork DisableSoftDeletableFilter() + { + _saveChangesOptions.EnableSoftDeletableFilter = false; + return this; + } public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { @@ -92,12 +110,10 @@ private async Task SaveChangesAsync_Internal(CancellationToke return new Success(); } - _dialogDbContext.ChangeTracker.HandleAuditableEntities(_transactionTime.Value); - - if (_aggregateSideEffects) - { - await _dialogDbContext.ChangeTracker.HandleAggregateEntities(_transactionTime.Value, cancellationToken); - } + await _dialogDbContext.ChangeTracker.HandleAuditableEntities( + _transactionTime.Value, + _saveChangesOptions, + cancellationToken); if (!_enableConcurrencyCheck) { @@ -185,4 +201,20 @@ public void Dispose() _transaction?.Dispose(); _transaction = null; } + + // Although Digdir.Library.Entity.EntityFrameworkCore supports all the options, + // But we only have use cases for some of them. Therefore, + // only some of them have setters until the day we actually + // have a use case for them. + private sealed class SaveChangesOptions : IEntityOptions + { + public bool EnableSoftDeletableFilter { get; set; } = true; + public bool EnableImmutableFilter { get; } = true; + public bool EnableVersionableFilter { get; set; } = true; + public bool EnableUpdatableFilter { get; set; } = true; + public bool EnableCreatableFilter { get; } = true; + public bool EnableLookupFilter { get; } = true; + public bool EnableIdentifiableFilter { get; } = true; + public bool EnableAggregateFilter { get; set; } = true; + } } diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Common/Constants.cs b/src/Digdir.Domain.Dialogporten.WebApi/Common/Constants.cs index b34a4d67b..427855a04 100644 --- a/src/Digdir.Domain.Dialogporten.WebApi/Common/Constants.cs +++ b/src/Digdir.Domain.Dialogporten.WebApi/Common/Constants.cs @@ -13,6 +13,7 @@ internal static class SwaggerSummary internal const string ReturnedResult = "Successfully returned the dialog {0}."; internal const string Created = "The UUID of the created dialog {0}. A relative URL to the newly created activity is set in the \"Location\" header."; internal const string Deleted = "The dialog {0} was deleted successfully."; + internal const string Restored = "The dialog {0} was restored successfully."; internal const string Updated = "The dialog {0} was updated successfully."; internal const string ValidationError = "Validation error occurred. See problem details for a list of errors."; internal const string DomainError = "Domain error occurred. See problem details for a list of errors."; diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/Dialogs/Restore/RestoreDialogEndpoint.cs b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/Dialogs/Restore/RestoreDialogEndpoint.cs new file mode 100644 index 000000000..78bbfaea7 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/Dialogs/Restore/RestoreDialogEndpoint.cs @@ -0,0 +1,59 @@ +using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Restore; +using Digdir.Domain.Dialogporten.WebApi.Common; +using Digdir.Domain.Dialogporten.WebApi.Common.Authorization; +using Digdir.Domain.Dialogporten.WebApi.Common.Extensions; +using Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.Common.Extensions; +using FastEndpoints; +using MediatR; + +namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.Dialogs.Restore; + +public sealed class RestoreDialogEndpoint : Endpoint +{ + private readonly ISender _sender; + + public RestoreDialogEndpoint(ISender sender) + { + _sender = sender; + } + + public override void Configure() + { + Post("dialogs/{dialogId}/actions/restore"); + Policies(AuthorizationPolicy.ServiceProvider); + Group(); + + Description(b => b + .Accepts() + .ProducesOneOf( + StatusCodes.Status204NoContent, + StatusCodes.Status404NotFound, + StatusCodes.Status412PreconditionFailed)); + } + + public override async Task HandleAsync(RestoreDialogRequest req, CancellationToken ct) + { + var command = new RestoreDialogCommand + { + DialogId = req.DialogId, + IfMatchDialogRevision = req.IfMatchDialogRevision + }; + var result = await _sender.Send(command, ct); + await result.Match( + success => + { + HttpContext.Response.Headers.Append(Constants.ETag, success.Revision.ToString()); + return SendNoContentAsync(ct); + }, + notFound => this.NotFoundAsync(notFound, ct), + concurrencyError => this.PreconditionFailed(ct)); + } +} + +public sealed class RestoreDialogRequest +{ + public Guid DialogId { get; init; } + + [FromHeader(headerName: Constants.IfMatch, isRequired: false, removeFromSchema: true)] + public Guid? IfMatchDialogRevision { get; init; } +} diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/Dialogs/Restore/RestoreDialogEndpointSummary.cs b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/Dialogs/Restore/RestoreDialogEndpointSummary.cs new file mode 100644 index 000000000..5a7d301df --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/Dialogs/Restore/RestoreDialogEndpointSummary.cs @@ -0,0 +1,21 @@ +using Digdir.Domain.Dialogporten.WebApi.Common; +using Digdir.Domain.Dialogporten.WebApi.Common.Extensions; +using FastEndpoints; + +namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.Dialogs.Restore; + +public sealed class RestoreDialogEndpointSummary : Summary +{ + public RestoreDialogEndpointSummary() + { + Summary = "Restore a dialog"; + Description = """ + Restore a dialog. For more information see the documentation (link TBD). + """; + + Responses[StatusCodes.Status204NoContent] = Constants.SwaggerSummary.Restored.FormatInvariant("aggregate"); + Responses[StatusCodes.Status404NotFound] = Constants.SwaggerSummary.DialogNotFound; + Responses[StatusCodes.Status412PreconditionFailed] = Constants.SwaggerSummary.RevisionMismatch; + } + +} diff --git a/src/Digdir.Library.Entity.EntityFrameworkCore/EntityLibraryEfCoreExtensions.cs b/src/Digdir.Library.Entity.EntityFrameworkCore/EntityLibraryEfCoreExtensions.cs index d654eec6b..6c7f2ec88 100644 --- a/src/Digdir.Library.Entity.EntityFrameworkCore/EntityLibraryEfCoreExtensions.cs +++ b/src/Digdir.Library.Entity.EntityFrameworkCore/EntityLibraryEfCoreExtensions.cs @@ -26,13 +26,18 @@ namespace Digdir.Library.Entity.EntityFrameworkCore; public static class EntityLibraryEfCoreExtensions { /// - /// Updates the properties and sets the correct on the for the entities implementing the following abstractions in context of aggregates. + /// Updates the properties and sets the correct on the for the entities implementing the following abstractions. /// /// /// /// /// + /// + /// /// + /// + /// + /// /// /// /// @@ -41,38 +46,26 @@ public static class EntityLibraryEfCoreExtensions /// /// The change tracker. /// The time in UTC in which the changes took place. + /// Optional settings to configure entity handling behavior. /// A token for requesting cancellation of the operation. /// The same instance so that multiple calls can be chained. - public static Task HandleAggregateEntities( + public static Task HandleAuditableEntities( this ChangeTracker changeTracker, DateTimeOffset utcNow, + IEntityOptions options, CancellationToken cancellationToken = default) - => AggregateExtensions.HandleAggregateEntities(changeTracker, utcNow, cancellationToken); + { + changeTracker.DoIf(options.EnableLookupFilter, x => x.HandleLookupEntities()) + .DoIf(options.EnableIdentifiableFilter, x => x.HandleIdentifiableEntities()) + .DoIf(options.EnableImmutableFilter, x => x.HandleImmutableEntities()) + .DoIf(options.EnableCreatableFilter, x => x.HandleCreatableEntities(utcNow)) + .DoIf(options.EnableUpdatableFilter, x => x.HandleUpdatableEntities(utcNow)) + .DoIf(options.EnableSoftDeletableFilter, x => x.HandleSoftDeletableEntities(utcNow)); + return changeTracker.HandleAggregateEntities(utcNow, options, cancellationToken); + } - /// - /// Updates the properties and sets the correct on the for the entities implementing the following abstractions. - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// Should be called right before saving the entities. - /// - /// The change tracker. - /// The time in UTC in which the changes took place. - /// The same instance so that multiple calls can be chained. - public static ChangeTracker HandleAuditableEntities(this ChangeTracker changeTracker, DateTimeOffset utcNow) - => changeTracker.HandleLookupEntities() - .HandleIdentifiableEntities() - .HandleImmutableEntities() - .HandleCreatableEntities(utcNow) - .HandleUpdatableEntities(utcNow) - .HandleSoftDeletableEntities(utcNow); + private static ChangeTracker DoIf(this ChangeTracker changeTracker, bool predicate, Func action) + => predicate ? action(changeTracker) : changeTracker; /// /// Configures the shape of, and how the entities implementing the following abstractions are mapped to the database. diff --git a/src/Digdir.Library.Entity.EntityFrameworkCore/Features/Aggregate/AggregateExtensions.cs b/src/Digdir.Library.Entity.EntityFrameworkCore/Features/Aggregate/AggregateExtensions.cs index 370082e93..164be8b3f 100644 --- a/src/Digdir.Library.Entity.EntityFrameworkCore/Features/Aggregate/AggregateExtensions.cs +++ b/src/Digdir.Library.Entity.EntityFrameworkCore/Features/Aggregate/AggregateExtensions.cs @@ -15,7 +15,7 @@ internal static class AggregateExtensions private static readonly EntityEntryComparer _entityEntryComparer = new(); internal static async Task HandleAggregateEntities(this ChangeTracker changeTracker, - DateTimeOffset utcNow, CancellationToken cancellationToken) + DateTimeOffset utcNow, IEntityOptions options, CancellationToken cancellationToken) { var aggregateNodeByEntry = await changeTracker .Entries() @@ -24,27 +24,27 @@ internal static async Task HandleAggregateEntities(this ChangeTra foreach (var (_, aggregateNode) in aggregateNodeByEntry) { - if (aggregateNode.Entity is IAggregateCreatedHandler created && aggregateNode.IsAdded()) + if (options.EnableAggregateFilter && aggregateNode.Entity is IAggregateCreatedHandler created && aggregateNode.IsAdded()) { created.OnCreate(aggregateNode, utcNow); } - if (aggregateNode.Entity is IAggregateUpdatedHandler updated && aggregateNode.IsModified()) + if (options.EnableAggregateFilter && aggregateNode.Entity is IAggregateUpdatedHandler updated && aggregateNode.IsModified()) { updated.OnUpdate(aggregateNode, utcNow); } - if (aggregateNode.Entity is IAggregateDeletedHandler deleted && aggregateNode.IsDeleted()) + if (options.EnableAggregateFilter && aggregateNode.Entity is IAggregateDeletedHandler deleted && aggregateNode.IsDeleted()) { deleted.OnDelete(aggregateNode, utcNow); } - if (aggregateNode.Entity is IAggregateRestoredHandler restored && aggregateNode.IsRestored()) + if (options.EnableAggregateFilter && aggregateNode.Entity is IAggregateRestoredHandler restored && aggregateNode.IsRestored()) { restored.OnRestore(aggregateNode, utcNow); } - if (aggregateNode.Entity is IUpdateableEntity updatable) + if (options.EnableUpdatableFilter && aggregateNode.Entity is IUpdateableEntity updatable) { if (aggregateNode.IsModified() || aggregateNode.IsAddedWithDefaultUpdatedAt(updatable)) { @@ -52,7 +52,7 @@ internal static async Task HandleAggregateEntities(this ChangeTra } } - if (aggregateNode.Entity is IVersionableEntity versionable) + if (options.EnableVersionableFilter && aggregateNode.Entity is IVersionableEntity versionable) { versionable.NewVersion(); } diff --git a/src/Digdir.Library.Entity.EntityFrameworkCore/IEntityOptions.cs b/src/Digdir.Library.Entity.EntityFrameworkCore/IEntityOptions.cs new file mode 100644 index 000000000..859edc758 --- /dev/null +++ b/src/Digdir.Library.Entity.EntityFrameworkCore/IEntityOptions.cs @@ -0,0 +1,47 @@ +namespace Digdir.Library.Entity.EntityFrameworkCore; + +/// +/// Interface for configuring entity handling behavior options. +/// +public interface IEntityOptions +{ + /// + /// Gets a value indicating whether the soft deletable filter is enabled. + /// + bool EnableSoftDeletableFilter { get; } + + /// + /// Gets a value indicating whether the immutable filter is enabled. + /// + bool EnableImmutableFilter { get; } + + /// + /// Gets a value indicating whether the versionable filter is enabled. + /// + bool EnableVersionableFilter { get; } + + /// + /// Gets a value indicating whether the updatable filter is enabled. + /// + bool EnableUpdatableFilter { get; } + + /// + /// Gets a value indicating whether the creatable filter is enabled. + /// + bool EnableCreatableFilter { get; } + + /// + /// Gets a value indicating whether the lookup filter is enabled. + /// + bool EnableLookupFilter { get; } + + /// + /// Gets a value indicating whether the identifiable filter is enabled. + /// + bool EnableIdentifiableFilter { get; } + + /// + /// Gets a value indicating whether the aggregate filter is enabled. + /// + bool EnableAggregateFilter { get; } +} diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs index 4b84a933b..41386b2fd 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs @@ -9,6 +9,7 @@ using AutoMapper; using FluentAssertions; using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Delete; +using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Restore; using Digdir.Domain.Dialogporten.Domain.Attachments; using Digdir.Domain.Dialogporten.Domain.Dialogs.Events.Activities; using Digdir.Library.Entity.Abstractions.Features.Identifiable; @@ -246,6 +247,46 @@ await harness.Consumed cloudEvent.Type == CloudEventTypes.Get(nameof(DialogDeletedDomainEvent))); } + [Fact] + public async Task Creates_CloudEvent_When_Dialog_Is_Restored() + { + // Arrange + var harness = await Application.ConfigureServicesWithMassTransitTestHarness(); + var dialogId = IdentifiableExtensions.CreateVersion7(); + var createDialogCommand = DialogGenerator.GenerateSimpleFakeCreateDialogCommand(dialogId); + + await Application.Send(createDialogCommand); + + var deleteDialogCommand = new DeleteDialogCommand + { + Id = dialogId + }; + + await Application.Send(deleteDialogCommand); + + // Act + var restoreDialogCommand = new RestoreDialogCommand + { + DialogId = dialogId + }; + + await Application.Send(restoreDialogCommand); + + await harness.Consumed + .SelectAsync(x => x.Context.Message.DialogId == dialogId) + .FirstOrDefault(); + + var cloudEvents = Application.PopPublishedCloudEvents(); + + // Assert + cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.ResourceInstance == dialogId.ToString()); + cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.Resource == createDialogCommand.Dto.ServiceResource); + cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.Subject == createDialogCommand.Dto.Party); + + cloudEvents.Should().ContainSingle(cloudEvent => + cloudEvent.Type == CloudEventTypes.Get(nameof(DialogRestoredDomainEvent))); + } + [Fact] public async Task AltinnEvents_Should_Be_Disabled_When_DisableAltinnEvents_Is_Set() { diff --git a/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Features/V1/Common/Utils/ApplicationEventHandlerUtilsTests.Developer_Should_Use_Caution_When_Modifying_Endpoints.verified.txt b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Features/V1/Common/Utils/ApplicationEventHandlerUtilsTests.Developer_Should_Use_Caution_When_Modifying_Endpoints.verified.txt index 0215cd8fc..aa37fcfb8 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Features/V1/Common/Utils/ApplicationEventHandlerUtilsTests.Developer_Should_Use_Caution_When_Modifying_Endpoints.verified.txt +++ b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Features/V1/Common/Utils/ApplicationEventHandlerUtilsTests.Developer_Should_Use_Caution_When_Modifying_Endpoints.verified.txt @@ -2,6 +2,7 @@ DialogEventToAltinnForwarder_DialogActivityCreatedDomainEvent, DialogEventToAltinnForwarder_DialogCreatedDomainEvent, DialogEventToAltinnForwarder_DialogDeletedDomainEvent, + DialogEventToAltinnForwarder_DialogRestoredDomainEvent, DialogEventToAltinnForwarder_DialogSeenDomainEvent, DialogEventToAltinnForwarder_DialogUpdatedDomainEvent -] \ No newline at end of file +] diff --git a/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Features/V1/Common/Utils/ApplicationEventHandlerUtilsTests.Developer_Should_Use_Caution_When_Modifying_Events.verified.txt b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Features/V1/Common/Utils/ApplicationEventHandlerUtilsTests.Developer_Should_Use_Caution_When_Modifying_Events.verified.txt index 9cf796dac..2861c5ac3 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Features/V1/Common/Utils/ApplicationEventHandlerUtilsTests.Developer_Should_Use_Caution_When_Modifying_Events.verified.txt +++ b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Features/V1/Common/Utils/ApplicationEventHandlerUtilsTests.Developer_Should_Use_Caution_When_Modifying_Events.verified.txt @@ -2,6 +2,7 @@ Digdir.Domain.Dialogporten.Domain.Dialogs.Events.Activities.DialogActivityCreatedDomainEvent, Digdir.Domain.Dialogporten.Domain.Dialogs.Events.DialogCreatedDomainEvent, Digdir.Domain.Dialogporten.Domain.Dialogs.Events.DialogDeletedDomainEvent, + Digdir.Domain.Dialogporten.Domain.Dialogs.Events.DialogRestoredDomainEvent, Digdir.Domain.Dialogporten.Domain.Dialogs.Events.DialogSeenDomainEvent, Digdir.Domain.Dialogporten.Domain.Dialogs.Events.DialogUpdatedDomainEvent -] \ No newline at end of file +] diff --git a/tests/k6/tests/serviceowner/all-tests.js b/tests/k6/tests/serviceowner/all-tests.js index 0f052a4f4..36c3bbe53 100644 --- a/tests/k6/tests/serviceowner/all-tests.js +++ b/tests/k6/tests/serviceowner/all-tests.js @@ -9,6 +9,7 @@ import { default as dialogCreateInvalidProcess } from './dialogCreateInvalidProc import { default as dialogCreatePatchDelete } from './dialogCreatePatchDelete.js'; import { default as dialogCreateUpdatePatchDeleteCorrespondenceResource } from './dialogCreateUpdatePatchDeleteCorrespondenceResource.js'; import { default as dialogDetails } from './dialogDetails.js'; +import { default as dialogRestore } from './dialogRestore.js'; import { default as dialogSearch } from './dialogSearch.js'; import { default as dialogUpdateActivity } from './dialogUpdateActivity.js'; @@ -23,6 +24,7 @@ export default function() { dialogCreatePatchDelete(); dialogCreateUpdatePatchDeleteCorrespondenceResource(); dialogDetails(); + dialogRestore(); dialogSearch(); dialogUpdateActivity(); } diff --git a/tests/k6/tests/serviceowner/dialogRestore.js b/tests/k6/tests/serviceowner/dialogRestore.js new file mode 100644 index 000000000..f6ff77120 --- /dev/null +++ b/tests/k6/tests/serviceowner/dialogRestore.js @@ -0,0 +1,70 @@ +import { + describe, + expect, + expectStatusFor, + getSO, + postSO, + patchSO, + deleteSO, + purgeSO, + setSystemLabel +} from '../../common/testimports.js' +import {default as dialogToInsert} from './testdata/01-create-dialog.js' + +export default function () { + let dialogId = null; + let initialSystemLabel = "Bin"; + let initialEtag = null; + let initialUpdatedAt = null; + + const restoreDialog = (dialogId, eTag = null) => { + let header = eTag ? {'headers': {'Etag': eTag}} : null + return postSO('dialogs/' + dialogId + '/actions/restore', null, header); + } + + describe('Setup', () => { + let dialog = dialogToInsert(); + setSystemLabel(dialog, initialSystemLabel); + let r = postSO('dialogs', dialog); + expectStatusFor(r).to.equal(201); + expect(r, 'response').to.have.validJsonBody(); + expect(r.json(), 'response json').to.match(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/); + + dialogId = r.json(); + initialEtag = r.headers.Etag; + + let getResponse = getSO('dialogs/' + dialogId); + expectStatusFor(getResponse).to.equal(200); + expect(getResponse, 'get response').to.have.validJsonBody(); + expect(getResponse.json(), 'get response body').to.have.property('updatedAt'); + initialUpdatedAt = getResponse.json()['updatedAt'] + }); + + describe('Restore not deleted dialog', () => { + let r = restoreDialog(dialogId); + expectStatusFor(r).to.equal(204); + expect(r.headers.Etag, 'response Etag').to.equal(initialEtag); + }); + + describe('Restore deleted dialog', () => { + let deleteResponse = deleteSO('dialogs/' + dialogId); + expectStatusFor(deleteResponse).to.equal(204); + let Etag = deleteResponse.headers.Etag; + + let r = restoreDialog(dialogId, Etag); + expectStatusFor(r).to.equal(204); + expect(r.headers.Etag).to.not.equal(Etag); + + let getResponse = getSO('dialogs/' + dialogId); + expectStatusFor(getResponse).to.equal(200); + expect(getResponse, 'get response').to.have.validJsonBody(); + expect(getResponse.json(), 'get response body').to.have.property('systemLabel'); + expect(getResponse.json()['systemLabel'], 'get response systemlabel').to.equal(initialSystemLabel); + expect(getResponse.json()['updatedAt'], 'get response updatedAt').to.equal(initialUpdatedAt); + }); + + describe('Clean up', () => { + purgeSO('dialogs/' + dialogId); + }); + +}