diff --git a/Directory.Packages.props b/Directory.Packages.props index 3f079533a..302aca426 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -87,6 +87,7 @@ + diff --git a/NuGet.Config b/NuGet.Config index 0bcd55102..7f38c9b03 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -3,8 +3,14 @@ - + + + + + + + + \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Contracts/IWorkflowInstancesApi.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Contracts/IWorkflowInstancesApi.cs index 6496b50f5..73b11b092 100644 --- a/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Contracts/IWorkflowInstancesApi.cs +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Contracts/IWorkflowInstancesApi.cs @@ -14,7 +14,7 @@ public interface IWorkflowInstancesApi /// /// Returns a list of workflow instances. /// - [Get("/workflow-instances")] + [Post("/workflow-instances")] Task> ListAsync(ListWorkflowInstancesRequest request, CancellationToken cancellationToken = default); /// diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Requests/ListWorkflowInstancesRequest.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Requests/ListWorkflowInstancesRequest.cs index 11ab8cac5..0b4283a8b 100644 --- a/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Requests/ListWorkflowInstancesRequest.cs +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Requests/ListWorkflowInstancesRequest.cs @@ -13,64 +13,74 @@ public class ListWorkflowInstancesRequest /// Gets or sets the page number. /// public int? Page { get; set; } - + /// /// Gets or sets the page size. /// public int? PageSize { get; set; } - + /// /// Gets or sets the search term. /// public string? SearchTerm { get; set; } - + /// /// Gets or sets the workflow definition id. /// public string? DefinitionId { get; set; } - + /// /// Gets or sets the workflow definition ids. /// - [Query(CollectionFormat.Multi)] public ICollection? DefinitionIds { get; set; } - + public ICollection? DefinitionIds { get; set; } + /// /// Gets or sets the correlation id. /// public string? CorrelationId { get; set; } - + /// /// Gets or sets the version. /// public int? Version { get; set; } - + + /// + /// Gets or sets whether the workflow instances have incidents or not. + /// + public bool? HasIncidents { get; set; } + /// /// Gets or sets the status. /// public WorkflowStatus? Status { get; set; } - + /// /// Gets or sets the statuses. /// - [Query(CollectionFormat.Multi)] public ICollection? Statuses { get; set; } - + public ICollection? Statuses { get; set; } + /// /// Gets or sets the sub status. /// public WorkflowSubStatus? SubStatus { get; set; } - + /// /// Gets or sets the sub statuses. /// - [Query(CollectionFormat.Multi)] public ICollection? SubStatuses { get; set; } + public ICollection? SubStatuses { get; set; } /// /// Gets or sets the key to order by. /// public OrderByWorkflowInstance? OrderBy { get; set; } - + /// /// Gets or sets the order direction. /// public OrderDirection? OrderDirection { get; set; } + + /// + /// Gets or sets the timestamp filters. + /// + public ICollection? TimestampFilters { get; set; } } \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Shared/Enums/TimestampFilterOperator.cs b/src/clients/Elsa.Api.Client/Shared/Enums/TimestampFilterOperator.cs new file mode 100644 index 000000000..cabedd9cf --- /dev/null +++ b/src/clients/Elsa.Api.Client/Shared/Enums/TimestampFilterOperator.cs @@ -0,0 +1,37 @@ +namespace Elsa.Api.Client.Shared.Enums; + +/// +/// Specifies the operators for filtering timestamps in a TimestampFilter object. +/// +public enum TimestampFilterOperator +{ + /// + /// The timestamp is equal to the specified value. + /// + Is, + + /// + /// The timestamp is not equal to the specified value. + /// + IsNot, + + /// + /// The timestamp is before the specified value. + /// + LessThan, + + /// + /// The timestamp is greater than the specified value. + /// + GreaterThan, + + /// + /// The timestamp is less than or equal to the specified value. + /// + LessThanOrEqual, + + /// + /// The timestamp is greater than or equal to the specified value. + /// + GreaterThanOrEqual +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Shared/Models/TimestampFilter.cs b/src/clients/Elsa.Api.Client/Shared/Models/TimestampFilter.cs new file mode 100644 index 000000000..0e08217cd --- /dev/null +++ b/src/clients/Elsa.Api.Client/Shared/Models/TimestampFilter.cs @@ -0,0 +1,26 @@ +using Elsa.Api.Client.Shared.Enums; +using JetBrains.Annotations; + +namespace Elsa.Api.Client.Shared.Models; + +/// +/// Represents a timestamp filter used for filtering data based on a specified timestamp column and operator. +/// +[UsedImplicitly] +public class TimestampFilter +{ + /// + /// Gets or sets the column to filter by. + /// + public string Column { get; set; } = default!; + + /// + /// Gets or sets the operator to use for filtering. + /// + public TimestampFilterOperator Operator { get; set; } + + /// + /// Gets or sets the timestamp to filter by. + /// + public DateTimeOffset Timestamp { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs index 3b56ef650..96bbed0c1 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs @@ -6,57 +6,98 @@ using Elsa.Workflows.Management.Contracts; using Elsa.Workflows.Management.Filters; using Elsa.Workflows.Management.Models; using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; namespace Elsa.Workflows.Api.Endpoints.WorkflowInstances.List; -[PublicAPI] -internal class List : ElsaEndpoint +[UsedImplicitly] +internal class List(IWorkflowInstanceStore store) : ElsaEndpoint { - private readonly IWorkflowInstanceStore _store; - - public List(IWorkflowInstanceStore store) - { - _store = store; - } - public override void Configure() { - Get("/workflow-instances"); + Verbs(FastEndpoints.Http.GET, FastEndpoints.Http.POST); + Routes("/workflow-instances"); ConfigurePermissions("read:workflow-instances"); } - public override async Task ExecuteAsync(Request request, CancellationToken cancellationToken) + public override async Task HandleAsync(Request request, CancellationToken cancellationToken) { var pageArgs = PageArgs.FromPage(request.Page, request.PageSize); + if (!await ValidateInputAsync(request, cancellationToken)) + { + await SendErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken); + return; + } + var filter = new WorkflowInstanceFilter { SearchTerm = request.SearchTerm, DefinitionId = request.DefinitionId, - DefinitionIds = request.DefinitionIds, + DefinitionIds = request.DefinitionIds?.Any() == true ? request.DefinitionIds : null, Version = request.Version, CorrelationId = request.CorrelationId, WorkflowStatus = request.Status, WorkflowSubStatus = request.SubStatus, - WorkflowStatuses = request.Statuses?.Select(Enum.Parse).ToList(), - WorkflowSubStatuses = request.SubStatuses?.Select(Enum.Parse).ToList(), - ToCreatedAt = request.ToCreatedAt, - FromCreatedAt = request.FromCreatedAt, + WorkflowStatuses = request.Statuses?.Any() == true ? request.Statuses : null, + WorkflowSubStatuses = request.SubStatuses?.Any() == true ? request.SubStatuses : null, + HasIncidents = request.HasIncidents, + TimestampFilters = request.TimestampFilters?.Any() == true ? request.TimestampFilters : null, }; var summaries = await FindAsync(request, filter, pageArgs, cancellationToken); - return new Response(summaries.Items, summaries.TotalCount); + var response = new Response(summaries.Items, summaries.TotalCount); + await SendOkAsync(response, cancellationToken); + } + + private async Task ValidateInputAsync(Request request, CancellationToken cancellationToken) + { + if (request.Page is < 0) + { + AddError("Page must be greater than or equal to 1."); + return false; + } + + if (request.PageSize is < 1) + { + AddError("Page size must be greater than or equal to 1."); + return false; + } + + var columnWhitelist = new[] + { + "CreatedAt", "UpdatedAt", "FinishedAt" + }; + + if (request.TimestampFilters?.Any() == true) + { + foreach (var timestampFilter in request.TimestampFilters) + { + if (string.IsNullOrWhiteSpace(timestampFilter.Column)) + { + AddError("Column must be specified."); + return false; + } + + if (!columnWhitelist.Contains(timestampFilter.Column)) + { + AddError($"Invalid column '{timestampFilter.Column}'."); + return false; + } + } + } + + return true; } private async Task> FindAsync(Request request, WorkflowInstanceFilter filter, PageArgs pageArgs, CancellationToken cancellationToken) { - request.OrderBy = request.OrderBy ?? OrderByWorkflowInstance.Created; - var direction = request.OrderBy == OrderByWorkflowInstance.Name ? (request.OrderDirection ?? OrderDirection.Ascending) : (request.OrderDirection ?? OrderDirection.Descending); + request.OrderBy ??= OrderByWorkflowInstance.Created; + var direction = request.OrderBy == OrderByWorkflowInstance.Name ? request.OrderDirection ?? OrderDirection.Ascending : request.OrderDirection ?? OrderDirection.Descending; switch (request.OrderBy) { default: - case OrderByWorkflowInstance.Created: { var o = new WorkflowInstanceOrder { @@ -64,7 +105,7 @@ internal class List : ElsaEndpoint Direction = direction }; - return await _store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken); + return await store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken); } case OrderByWorkflowInstance.UpdatedAt: { @@ -74,7 +115,7 @@ internal class List : ElsaEndpoint Direction = direction }; - return await _store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken); + return await store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken); } case OrderByWorkflowInstance.Finished: { @@ -84,7 +125,7 @@ internal class List : ElsaEndpoint Direction = direction }; - return await _store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken); + return await store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken); } case OrderByWorkflowInstance.Name: { @@ -94,7 +135,7 @@ internal class List : ElsaEndpoint Direction = direction }; - return await _store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken); + return await store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken); } } } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Models.cs index 2e9048e6f..b37960763 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Models.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Models.cs @@ -12,25 +12,21 @@ public class Request public string? DefinitionId { get; set; } public ICollection? DefinitionIds { get; set; } public string? CorrelationId { get; set; } + public string? Name { get; set; } public int? Version { get; set; } + public bool? HasIncidents { get; set; } public WorkflowStatus? Status { get; set; } - public ICollection? Statuses { get; set; } + public ICollection? Statuses { get; set; } public WorkflowSubStatus? SubStatus { get; set; } - public ICollection? SubStatuses { get; set; } + public ICollection? SubStatuses { get; set; } public OrderByWorkflowInstance? OrderBy { get; set; } public OrderDirection? OrderDirection { get; set; } - public DateTimeOffset? FromCreatedAt { get; set; } - public DateTimeOffset? ToCreatedAt { get; set; } + public ICollection? TimestampFilters { get; set; } } -public class Response +internal class Response(ICollection items, long totalCount) { - public Response(ICollection items, long totalCount) - { - Items = items; - TotalCount = totalCount; - } + public ICollection Items { get; set; } = items; + public long TotalCount { get; set; } = totalCount; +} - public ICollection Items { get; set; } - public long TotalCount { get; set; } -} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Elsa.Workflows.Management.csproj b/src/modules/Elsa.Workflows.Management/Elsa.Workflows.Management.csproj index 8e2be0af0..e4baba018 100644 --- a/src/modules/Elsa.Workflows.Management/Elsa.Workflows.Management.csproj +++ b/src/modules/Elsa.Workflows.Management/Elsa.Workflows.Management.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/modules/Elsa.Workflows.Management/Enums/TimestampFilterOperator.cs b/src/modules/Elsa.Workflows.Management/Enums/TimestampFilterOperator.cs new file mode 100644 index 000000000..ff6913129 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Enums/TimestampFilterOperator.cs @@ -0,0 +1,37 @@ +namespace Elsa.Workflows.Management.Enums; + +/// +/// Specifies the operators for filtering timestamps in a TimestampFilter object. +/// +public enum TimestampFilterOperator +{ + /// + /// The timestamp is equal to the specified value. + /// + Is, + + /// + /// The timestamp is not equal to the specified value. + /// + IsNot, + + /// + /// The timestamp is before the specified value. + /// + LessThan, + + /// + /// The timestamp is greater than the specified value. + /// + GreaterThan, + + /// + /// The timestamp is less than or equal to the specified value. + /// + LessThanOrEqual, + + /// + /// The timestamp is greater than or equal to the specified value. + /// + GreaterThanOrEqual +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Filters/WorkflowInstanceFilter.cs b/src/modules/Elsa.Workflows.Management/Filters/WorkflowInstanceFilter.cs index f22feec89..b1e53519f 100644 --- a/src/modules/Elsa.Workflows.Management/Filters/WorkflowInstanceFilter.cs +++ b/src/modules/Elsa.Workflows.Management/Filters/WorkflowInstanceFilter.cs @@ -1,4 +1,8 @@ +using System.Diagnostics.CodeAnalysis; using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Enums; +using Elsa.Workflows.Management.Models; +using System.Linq.Dynamic.Core; namespace Elsa.Workflows.Management.Filters; @@ -61,7 +65,7 @@ public class WorkflowInstanceFilter /// Filter workflow instances by status. /// public WorkflowStatus? WorkflowStatus { get; set; } - + /// /// Filter workflow instances by a set of statuses. /// @@ -71,25 +75,26 @@ public class WorkflowInstanceFilter /// Filter workflow instances by sub-status. /// public WorkflowSubStatus? WorkflowSubStatus { get; set; } - + /// /// Filter workflow instances by a set of sub-status. /// public ICollection? WorkflowSubStatuses { get; set; } /// - /// Filter workflow instances by created date time, start from this date time. + /// Filter workflow instances by whether they have incidents. /// - public DateTimeOffset? FromCreatedAt { get; set; } + public bool? HasIncidents { get; set; } /// - /// Filter workflow instances by created date time, to this date time. + /// Filter workflow instances by timestamp. /// - public DateTimeOffset? ToCreatedAt { get; set; } + public ICollection? TimestampFilters { get; set; } /// /// Applies the filter to the specified query. /// + [RequiresUnreferencedCode("The method uses reflection to create an expression tree.")] public IQueryable Apply(IQueryable query) { var filter = this; @@ -106,8 +111,36 @@ public class WorkflowInstanceFilter if (filter.WorkflowSubStatus != null) query = query.Where(x => x.SubStatus == filter.WorkflowSubStatus); if (filter.WorkflowStatuses != null) query = query.Where(x => filter.WorkflowStatuses.Contains(x.Status)); if (filter.WorkflowSubStatuses != null) query = query.Where(x => filter.WorkflowSubStatuses.Contains(x.SubStatus)); - if (filter.ToCreatedAt is not null) query = query.Where(x => filter.ToCreatedAt >= x.CreatedAt); - if (filter.FromCreatedAt is not null) query = query.Where(x => filter.FromCreatedAt <= x.CreatedAt); + if (filter.HasIncidents != null) query = filter.HasIncidents == true ? query.Where(x => x.IncidentCount > 0) : query.Where(x => x.IncidentCount == 0); + + if (TimestampFilters != null) + { + foreach (TimestampFilter timestampFilter in TimestampFilters) + { + var column = timestampFilter.Column; + var timestamp = timestampFilter.Timestamp; + var isZeroTime = timestamp.TimeOfDay == TimeSpan.Zero; + var startDay = new DateTimeOffset(timestamp.Date); + var endDay = startDay.AddDays(1); + + query = timestampFilter.Operator switch + { + TimestampFilterOperator.Is when isZeroTime => query.Where($"{column} >= @0 && {column} < @1", startDay, endDay), + TimestampFilterOperator.Is => query.Where($"{column} == @0", timestamp), + TimestampFilterOperator.IsNot when isZeroTime => query.Where($"{column} < @0 || {column} >= @1", startDay, endDay), + TimestampFilterOperator.IsNot => query.Where($"{column} != @0", timestamp), + TimestampFilterOperator.GreaterThan when isZeroTime => query.Where($"{column} > @0", endDay), + TimestampFilterOperator.GreaterThan => query.Where($"{column} > @0", timestamp), + TimestampFilterOperator.GreaterThanOrEqual when isZeroTime => query.Where($"{column} >= @0", startDay), + TimestampFilterOperator.GreaterThanOrEqual => query.Where($"{column} >= @0", timestamp), + TimestampFilterOperator.LessThan when isZeroTime => query.Where($"{column} < @0", startDay), + TimestampFilterOperator.LessThan => query.Where($"{column} < @0", timestamp), + TimestampFilterOperator.LessThanOrEqual when isZeroTime => query.Where($"{column} <= @0", endDay), + TimestampFilterOperator.LessThanOrEqual => query.Where($"{column} <= @0", timestamp), + _ => query + }; + } + } var searchTerm = filter.SearchTerm; if (!string.IsNullOrWhiteSpace(searchTerm)) @@ -115,10 +148,10 @@ public class WorkflowInstanceFilter query = from instance in query where instance.Name!.Contains(searchTerm) - || instance.DefinitionVersionId.Equals(searchTerm) - || instance.Id.Equals(searchTerm) - || instance.DefinitionId.Equals(searchTerm) - || instance.CorrelationId!.Equals(searchTerm) + || instance.DefinitionVersionId.Contains(searchTerm) + || instance.DefinitionId.Contains(searchTerm) + || instance.Id.Contains(searchTerm) + || instance.CorrelationId!.Contains(searchTerm) select instance; } diff --git a/src/modules/Elsa.Workflows.Management/Models/TimestampFilter.cs b/src/modules/Elsa.Workflows.Management/Models/TimestampFilter.cs new file mode 100644 index 000000000..1d02f2970 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Models/TimestampFilter.cs @@ -0,0 +1,26 @@ +using Elsa.Workflows.Management.Enums; +using JetBrains.Annotations; + +namespace Elsa.Workflows.Management.Models; + +/// +/// Represents a timestamp filter used for filtering data based on a specified timestamp column and operator. +/// +[UsedImplicitly] +public class TimestampFilter +{ + /// + /// Gets or sets the column to filter by. + /// + public required string Column { get; set; } = default!; + + /// + /// Gets or sets the operator to use for filtering. + /// + public required TimestampFilterOperator Operator { get; set; } + + /// + /// Gets or sets the timestamp to filter by. + /// + public required DateTimeOffset Timestamp { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/RequiredMembers.cs b/src/modules/Elsa.Workflows.Management/RequiredMembers.cs new file mode 100644 index 000000000..e1a4aad6d --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/RequiredMembers.cs @@ -0,0 +1,8 @@ +// See: https://github.com/dotnet/core/issues/8016 +namespace System.Runtime.CompilerServices; + +public class RequiredMemberAttribute : Attribute { } +public class CompilerFeatureRequiredAttribute : Attribute +{ + public CompilerFeatureRequiredAttribute(string name) { } +} \ No newline at end of file