| | | 1 | | using System.Text; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using Elsa.UserTasks.Contracts; |
| | | 4 | | using Elsa.UserTasks.Models; |
| | | 5 | | |
| | | 6 | | namespace Elsa.UserTasks.Repositories; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// A deterministic repository for the Core module and development hosts. It uses a single lock to |
| | | 10 | | /// provide the same compare-and-swap and projection idempotency guarantees expected from durable stores. |
| | | 11 | | /// </summary> |
| | | 12 | | public sealed class InMemoryUserTaskRepository : IUserTaskRepository |
| | | 13 | | { |
| | 81 | 14 | | private readonly object _sync = new(); |
| | 81 | 15 | | private readonly Dictionary<string, UserTask> _tasks = new(StringComparer.Ordinal); |
| | | 16 | | |
| | | 17 | | public Task<UserTask?> GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default) |
| | | 18 | | { |
| | 246 | 19 | | lock (_sync) |
| | 246 | 20 | | return Task.FromResult(_tasks.TryGetValue(Key(tenantId, taskId), out var task) ? Clone(task) : null); |
| | 246 | 21 | | } |
| | | 22 | | |
| | | 23 | | public Task<UserTaskQueryResult> QueryAsync(UserTaskQuery query, CancellationToken cancellationToken = default) |
| | | 24 | | { |
| | 129 | 25 | | lock (_sync) |
| | | 26 | | { |
| | 129 | 27 | | var scope = query.Scope; |
| | 129 | 28 | | var items = _tasks.Values |
| | 4547 | 29 | | .Where(x => string.Equals(x.TenantId, query.TenantId, StringComparison.Ordinal)) |
| | 129 | 30 | | // Visibility is evaluated before counting, cursoring, and paging so an unauthorized row can |
| | 129 | 31 | | // never influence a total or push an authorized row off the page. |
| | 739 | 32 | | .Where(x => scope == null || IsVisible(x, scope)) |
| | 727 | 33 | | .Where(x => query.Statuses.Count == 0 || query.Statuses.Contains(x.Status)) |
| | 727 | 34 | | .Where(x => !query.OnlyOverdue || x.IsOverdue) |
| | 727 | 35 | | .Where(x => !query.OnlyWithoutDueDate || x.DueAt == null) |
| | 727 | 36 | | .Where(x => query.PriorityFrom == null || x.Priority >= query.PriorityFrom) |
| | 727 | 37 | | .Where(x => query.PriorityTo == null || x.Priority <= query.PriorityTo) |
| | 727 | 38 | | .Where(x => query.DueFrom == null || x.DueAt >= query.DueFrom) |
| | 727 | 39 | | .Where(x => query.DueTo == null || x.DueAt <= query.DueTo) |
| | 727 | 40 | | .Where(x => query.WorkflowDefinitionId == null || x.WorkflowDefinitionId == query.WorkflowDefinitionId) |
| | 727 | 41 | | .Where(x => query.WorkflowInstanceId == null || x.WorkflowInstanceId == query.WorkflowInstanceId) |
| | 727 | 42 | | .Where(x => query.Reference == null || string.Equals(x.Reference, query.Reference, StringComparison.Ordi |
| | 727 | 43 | | .Where(x => query.TaskType == null || string.Equals(x.TaskType, query.TaskType, StringComparison.Ordinal |
| | 856 | 44 | | .Where(x => MatchesSearch(x, query.Search)); |
| | | 45 | | |
| | 129 | 46 | | var filteredCount = query.IncludeTotalCount ? items.Count() : 0; |
| | 129 | 47 | | var materialized = Sort(items, query.Sort, query.Descending).ToList(); |
| | 129 | 48 | | if (!string.IsNullOrWhiteSpace(query.Cursor) && DecodeCursor(query.Cursor!) is { } cursor) |
| | 487 | 49 | | materialized = materialized.Where(x => IsAfterCursor(x, cursor, query.Descending, query.Sort)).ToList(); |
| | | 50 | | |
| | 129 | 51 | | int? total = query.IncludeTotalCount ? filteredCount : null; |
| | 129 | 52 | | var limit = Math.Clamp(query.Limit, 1, 200); |
| | 129 | 53 | | var page = materialized.Take(limit).Select(Clone).ToArray(); |
| | 129 | 54 | | var next = materialized.Count > limit ? EncodeCursor(page[^1], query.Sort) : null; |
| | 129 | 55 | | return Task.FromResult(new UserTaskQueryResult(page, next, total)); |
| | | 56 | | } |
| | 129 | 57 | | } |
| | | 58 | | |
| | | 59 | | public Task<UserTask?> FindByMaterializationKeyAsync(string tenantId, string key, CancellationToken cancellationToke |
| | | 60 | | { |
| | 88 | 61 | | lock (_sync) |
| | 271 | 62 | | return Task.FromResult(_tasks.Values.FirstOrDefault(x => x.TenantId == tenantId && x.MaterializationKey == k |
| | 88 | 63 | | } |
| | | 64 | | |
| | | 65 | | public Task<UserTask?> FindByBookmarkIdAsync(string tenantId, string bookmarkId, CancellationToken cancellationToken |
| | | 66 | | { |
| | 2 | 67 | | lock (_sync) |
| | 140 | 68 | | return Task.FromResult(_tasks.Values.FirstOrDefault(x => x.TenantId == tenantId && x.BookmarkId == bookmarkI |
| | 2 | 69 | | } |
| | | 70 | | |
| | | 71 | | public Task<(UserTask Task, UserTaskInvitation Invitation)?> FindByInvitationTokenHashAsync(string tokenHash, Cancel |
| | | 72 | | { |
| | 35 | 73 | | lock (_sync) |
| | | 74 | | { |
| | 397 | 75 | | foreach (var task in _tasks.Values) |
| | | 76 | | { |
| | | 77 | | // Ordinal comparison over a fixed-length hex hash; the secret itself is never stored. |
| | 227 | 78 | | var invitation = task.Invitations.FirstOrDefault(x => string.Equals(x.TokenHash, tokenHash, StringCompar |
| | 179 | 79 | | if (invitation != null) |
| | 31 | 80 | | return Task.FromResult<(UserTask, UserTaskInvitation)?>((Clone(task), invitation)); |
| | | 81 | | } |
| | 4 | 82 | | return Task.FromResult<(UserTask, UserTaskInvitation)?>(null); |
| | | 83 | | } |
| | 35 | 84 | | } |
| | | 85 | | |
| | | 86 | | public Task SaveAsync(UserTask task, int expectedRevision, CancellationToken cancellationToken = default) |
| | | 87 | | { |
| | 55 | 88 | | lock (_sync) |
| | | 89 | | { |
| | 55 | 90 | | var key = Key(task.TenantId, task.Id); |
| | 55 | 91 | | if (!_tasks.TryGetValue(key, out var current) || current.Revision != expectedRevision) |
| | 2 | 92 | | throw new UserTaskRevisionConflictException(task.Id, expectedRevision); |
| | | 93 | | |
| | 53 | 94 | | var copy = Clone(task); |
| | 53 | 95 | | copy.Revision = expectedRevision + 1; |
| | 53 | 96 | | copy.UpdatedAt = DateTimeOffset.UtcNow; |
| | 53 | 97 | | _tasks[key] = copy; |
| | 53 | 98 | | return Task.CompletedTask; |
| | | 99 | | } |
| | 53 | 100 | | } |
| | | 101 | | |
| | | 102 | | public Task AddProjectionAsync(UserTask task, CancellationToken cancellationToken = default) |
| | | 103 | | { |
| | 159 | 104 | | lock (_sync) |
| | | 105 | | { |
| | 4025 | 106 | | if (_tasks.Values.Any(x => x.TenantId == task.TenantId && |
| | 4025 | 107 | | ((!string.IsNullOrEmpty(task.MaterializationKey) && x.MaterializationKey == task.MaterializationKey) || |
| | 4025 | 108 | | (!string.IsNullOrEmpty(task.BookmarkId) && x.BookmarkId == task.BookmarkId)))) |
| | 1 | 109 | | return Task.CompletedTask; |
| | | 110 | | |
| | 158 | 111 | | _tasks[Key(task.TenantId, task.Id)] = Clone(task); |
| | 158 | 112 | | return Task.CompletedTask; |
| | | 113 | | } |
| | 159 | 114 | | } |
| | | 115 | | |
| | | 116 | | public Task AppendEventAsync(string tenantId, string taskId, UserTaskEvent @event, CancellationToken cancellationTok |
| | | 117 | | { |
| | 5 | 118 | | lock (_sync) |
| | | 119 | | { |
| | 5 | 120 | | if (_tasks.TryGetValue(Key(tenantId, taskId), out var current)) |
| | 4 | 121 | | current.Events.Add(@event); |
| | 5 | 122 | | return Task.CompletedTask; |
| | | 123 | | } |
| | 5 | 124 | | } |
| | | 125 | | |
| | | 126 | | public Task<bool> TryMutateAsync(string tenantId, string taskId, int expectedRevision, Func<UserTask, bool> mutation |
| | | 127 | | { |
| | 39 | 128 | | lock (_sync) |
| | | 129 | | { |
| | 39 | 130 | | var key = Key(tenantId, taskId); |
| | 39 | 131 | | if (!_tasks.TryGetValue(key, out var current) || current.Revision != expectedRevision) |
| | 1 | 132 | | return Task.FromResult(false); |
| | | 133 | | |
| | 38 | 134 | | var copy = Clone(current); |
| | 38 | 135 | | if (!mutation(copy)) |
| | 1 | 136 | | return Task.FromResult(false); |
| | | 137 | | |
| | 37 | 138 | | copy.Revision = expectedRevision + 1; |
| | 37 | 139 | | copy.UpdatedAt = DateTimeOffset.UtcNow; |
| | 37 | 140 | | _tasks[key] = copy; |
| | 37 | 141 | | return Task.FromResult(true); |
| | | 142 | | } |
| | 39 | 143 | | } |
| | | 144 | | |
| | | 145 | | private static bool IsVisible(UserTask task, UserTaskQueryScope scope) |
| | | 146 | | { |
| | 630 | 147 | | if (!string.Equals(task.TenantId, scope.TenantId, StringComparison.Ordinal)) |
| | 2 | 148 | | return false; |
| | 628 | 149 | | if (scope.ExcludeBlocking && task.HealthSeverity == UserTaskHealthSeverity.Blocking) |
| | 0 | 150 | | return false; |
| | | 151 | | // Manager-only scopes were already rejected by the policy for non-managers, so reaching them here |
| | | 152 | | // means the caller manages the tenant. |
| | 628 | 153 | | if (scope.RequiresManager) |
| | 0 | 154 | | return scope.IsManager && (scope.Kind != UserTaskQueryScopeKind.NeedsAttention || NeedsAttention(task)); |
| | | 155 | | |
| | 628 | 156 | | return scope.Kind switch |
| | 628 | 157 | | { |
| | 1 | 158 | | UserTaskQueryScopeKind.Assigned => task.Assignee?.Matches(scope.Subject) == true, |
| | 627 | 159 | | UserTaskQueryScopeKind.Available => task.IsOpen && task.Assignee == null && IsEligible(task, scope), |
| | 0 | 160 | | UserTaskQueryScopeKind.History => task.IsTerminal && |
| | 0 | 161 | | (task.CompletedBy?.Matches(scope.Subject) == true || task.Events.Any(x => |
| | 0 | 162 | | _ => false |
| | 628 | 163 | | }; |
| | | 164 | | } |
| | | 165 | | |
| | | 166 | | private static bool IsEligible(UserTask task, UserTaskQueryScope scope) |
| | | 167 | | { |
| | 632 | 168 | | if (task.ExcludedUsers.Any(x => x.Matches(scope.Subject))) |
| | 5 | 169 | | return false; |
| | 621 | 170 | | if (task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot) |
| | 0 | 171 | | return task.SnapshotMembers.Any(x => x.Matches(scope.Subject)); |
| | 1242 | 172 | | return task.CandidateUsers.Any(x => x.Matches(scope.Subject)) |
| | 621 | 173 | | || task.CandidateGroups.Any(candidate => scope.Groups.Any(candidate.Matches)); |
| | | 174 | | } |
| | | 175 | | |
| | | 176 | | private static bool NeedsAttention(UserTask task) => |
| | 0 | 177 | | task.HealthSeverity == UserTaskHealthSeverity.Blocking |
| | 0 | 178 | | || task.IsOverdue |
| | 0 | 179 | | || (task.IsOpen && task.Assignee == null) |
| | 0 | 180 | | || task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling; |
| | | 181 | | |
| | | 182 | | private static bool MatchesSearch(UserTask task, string? search) |
| | | 183 | | { |
| | 727 | 184 | | if (string.IsNullOrWhiteSpace(search)) |
| | 727 | 185 | | return true; |
| | 0 | 186 | | var value = search.Trim(); |
| | 0 | 187 | | return (task.Title?.Contains(value, StringComparison.OrdinalIgnoreCase) == true) |
| | 0 | 188 | | || (task.Summary?.Contains(value, StringComparison.OrdinalIgnoreCase) == true) |
| | 0 | 189 | | || (task.Reference?.Contains(value, StringComparison.OrdinalIgnoreCase) == true) |
| | 0 | 190 | | || (task.TaskType?.Contains(value, StringComparison.OrdinalIgnoreCase) == true) |
| | 0 | 191 | | || task.Tags.Any(x => x.Contains(value, StringComparison.OrdinalIgnoreCase)); |
| | | 192 | | } |
| | | 193 | | |
| | | 194 | | private static IEnumerable<UserTask> Sort(IEnumerable<UserTask> items, string sort, bool descending) |
| | | 195 | | { |
| | 129 | 196 | | var normalized = NormalizeSort(sort); |
| | 129 | 197 | | return normalized switch |
| | 129 | 198 | | { |
| | 31 | 199 | | "due" => descending |
| | 252 | 200 | | ? items.OrderByDescending(x => x.DueAt.HasValue).ThenByDescending(x => x.DueAt).ThenByDescending(x => x. |
| | 283 | 201 | | : items.OrderBy(x => x.DueAt.HasValue ? 0 : 1).ThenBy(x => x.DueAt).ThenBy(x => x.Id, StringComparer.Ord |
| | 28 | 202 | | "priority" => descending |
| | 156 | 203 | | ? items.OrderByDescending(x => x.Priority).ThenByDescending(x => x.Id, StringComparer.Ordinal) |
| | 184 | 204 | | : items.OrderBy(x => x.Priority).ThenBy(x => x.Id, StringComparer.Ordinal), |
| | 0 | 205 | | "updated" => descending |
| | 0 | 206 | | ? items.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.Id, StringComparer.Ordinal) |
| | 0 | 207 | | : items.OrderBy(x => x.UpdatedAt).ThenBy(x => x.Id, StringComparer.Ordinal), |
| | 0 | 208 | | "completed" => descending |
| | 0 | 209 | | ? items.OrderByDescending(x => x.CompletedAt.HasValue).ThenByDescending(x => x.CompletedAt).ThenByDescen |
| | 0 | 210 | | : items.OrderBy(x => x.CompletedAt.HasValue ? 0 : 1).ThenBy(x => x.CompletedAt).ThenBy(x => x.Id, String |
| | 28 | 211 | | "title" => descending |
| | 156 | 212 | | ? items.OrderByDescending(x => x.Title, StringComparer.OrdinalIgnoreCase).ThenByDescending(x => x.Id, St |
| | 184 | 213 | | : items.OrderBy(x => x.Title, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.Id, StringComparer.Ordinal |
| | 42 | 214 | | _ => descending |
| | 156 | 215 | | ? items.OrderByDescending(x => x.CreatedAt).ThenByDescending(x => x.Id, StringComparer.Ordinal) |
| | 258 | 216 | | : items.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id, StringComparer.Ordinal) |
| | 129 | 217 | | }; |
| | | 218 | | } |
| | | 219 | | |
| | | 220 | | private static bool IsAfterCursor(UserTask task, (string Kind, string Value, string Id) cursor, bool descending, str |
| | | 221 | | { |
| | 413 | 222 | | var normalized = NormalizeSort(sort); |
| | | 223 | | // Due/completed ordering keeps null values at the end in both directions. A generic numeric |
| | | 224 | | // comparison would incorrectly drop nulls after a descending non-null page (or reintroduce |
| | | 225 | | // non-null values after a descending null page). |
| | 413 | 226 | | if (descending && (normalized is "due" or "completed")) |
| | | 227 | | { |
| | 51 | 228 | | var valueIsNull = SortValue(task, normalized) == null; |
| | 51 | 229 | | var cursorIsNull = cursor.Value == "~"; |
| | 51 | 230 | | if (valueIsNull != cursorIsNull) |
| | 19 | 231 | | return valueIsNull; |
| | | 232 | | } |
| | | 233 | | |
| | 394 | 234 | | var comparison = CompareSortValue(SortKind(sort), SortValue(task, sort), cursor.Kind, cursor.Value); |
| | 394 | 235 | | if (comparison == 0) |
| | 84 | 236 | | comparison = StringComparer.Ordinal.Compare(task.Id, cursor.Id); |
| | 394 | 237 | | return descending ? comparison < 0 : comparison > 0; |
| | | 238 | | } |
| | | 239 | | |
| | | 240 | | private static string EncodeCursor(UserTask task, string sort) |
| | | 241 | | { |
| | 75 | 242 | | var kind = SortKind(sort); |
| | 75 | 243 | | var value = EncodeSortValue(SortValue(task, sort), kind); |
| | 75 | 244 | | return Convert.ToBase64String(Encoding.UTF8.GetBytes(kind + "|" + value + "|" + task.Id)); |
| | | 245 | | } |
| | | 246 | | |
| | | 247 | | private static (string Kind, string Value, string Id)? DecodeCursor(string cursor) |
| | | 248 | | { |
| | | 249 | | try |
| | | 250 | | { |
| | 75 | 251 | | var value = Encoding.UTF8.GetString(Convert.FromBase64String(cursor)); |
| | 74 | 252 | | var first = value.IndexOf('|'); |
| | 74 | 253 | | var last = value.LastIndexOf('|'); |
| | 74 | 254 | | return first <= 0 || last <= first ? null : (value[..first], value[(first + 1)..last], value[(last + 1)..]); |
| | | 255 | | } |
| | 1 | 256 | | catch (FormatException) |
| | | 257 | | { |
| | 1 | 258 | | return null; |
| | | 259 | | } |
| | 75 | 260 | | } |
| | | 261 | | |
| | 1531 | 262 | | private static string NormalizeSort(string sort) => sort.ToLowerInvariant() switch |
| | 1531 | 263 | | { |
| | 0 | 264 | | "dueat" => "due", |
| | 0 | 265 | | "completedat" => "completed", |
| | 1531 | 266 | | _ => sort.ToLowerInvariant() is "due" or "priority" or "updated" or "completed" or "title" ? sort.ToLowerInvaria |
| | 1531 | 267 | | }; |
| | | 268 | | |
| | 469 | 269 | | private static string SortKind(string sort) => NormalizeSort(sort) switch |
| | 469 | 270 | | { |
| | 120 | 271 | | "priority" => "i", |
| | 120 | 272 | | "title" => "s", |
| | 101 | 273 | | "due" or "completed" => "n", |
| | 128 | 274 | | _ => "n" |
| | 469 | 275 | | }; |
| | | 276 | | |
| | 520 | 277 | | private static object? SortValue(UserTask task, string sort) => NormalizeSort(sort) switch |
| | 520 | 278 | | { |
| | 152 | 279 | | "due" => task.DueAt, |
| | 120 | 280 | | "priority" => task.Priority, |
| | 0 | 281 | | "updated" => task.UpdatedAt, |
| | 0 | 282 | | "completed" => task.CompletedAt, |
| | 120 | 283 | | "title" => task.Title, |
| | 128 | 284 | | _ => task.CreatedAt |
| | 520 | 285 | | }; |
| | | 286 | | |
| | 75 | 287 | | private static string EncodeSortValue(object? value, string kind) => value switch |
| | 75 | 288 | | { |
| | 2 | 289 | | null => "~", |
| | 37 | 290 | | DateTimeOffset date => date.UtcTicks.ToString(System.Globalization.CultureInfo.InvariantCulture), |
| | 18 | 291 | | int number => number.ToString(System.Globalization.CultureInfo.InvariantCulture), |
| | 18 | 292 | | _ => Convert.ToBase64String(Encoding.UTF8.GetBytes(value.ToString() ?? "")) |
| | 75 | 293 | | }; |
| | | 294 | | |
| | | 295 | | private static int CompareSortValue(string kind, object? value, string cursorKind, string cursorValue) |
| | | 296 | | { |
| | 394 | 297 | | if (!string.Equals(kind, cursorKind, StringComparison.Ordinal)) |
| | 0 | 298 | | return 0; |
| | 394 | 299 | | if (value == null || cursorValue == "~") |
| | 23 | 300 | | return value == null && cursorValue == "~" ? 0 : value == null ? 1 : -1; |
| | 371 | 301 | | if (kind == "i") |
| | 102 | 302 | | return int.Parse(value.ToString()!, System.Globalization.CultureInfo.InvariantCulture).CompareTo(int.Parse(c |
| | 269 | 303 | | if (kind == "n") |
| | 334 | 304 | | return long.Parse(value switch { DateTimeOffset d => d.UtcTicks.ToString(System.Globalization.CultureInfo.In |
| | 167 | 305 | | .CompareTo(long.Parse(cursorValue, System.Globalization.CultureInfo.InvariantCulture)); |
| | 102 | 306 | | var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(cursorValue)); |
| | 102 | 307 | | return StringComparer.OrdinalIgnoreCase.Compare(value.ToString(), decoded); |
| | | 308 | | } |
| | | 309 | | |
| | 503 | 310 | | private static string Key(string tenantId, string taskId) => tenantId + "\0" + taskId; |
| | | 311 | | |
| | 821 | 312 | | private static UserTask Clone(UserTask task) => new() |
| | 821 | 313 | | { |
| | 821 | 314 | | Id = task.Id, |
| | 821 | 315 | | TenantId = task.TenantId, |
| | 821 | 316 | | WorkflowDefinitionId = task.WorkflowDefinitionId, |
| | 821 | 317 | | WorkflowDefinitionName = task.WorkflowDefinitionName, |
| | 821 | 318 | | WorkflowDefinitionVersion = task.WorkflowDefinitionVersion, |
| | 821 | 319 | | WorkflowInstanceId = task.WorkflowInstanceId, |
| | 821 | 320 | | WorkflowInstanceReference = task.WorkflowInstanceReference, |
| | 821 | 321 | | ActivityInstanceId = task.ActivityInstanceId, |
| | 821 | 322 | | BookmarkId = task.BookmarkId, |
| | 821 | 323 | | MaterializationKey = task.MaterializationKey, |
| | 821 | 324 | | Title = task.Title, |
| | 821 | 325 | | Summary = task.Summary, |
| | 821 | 326 | | Reference = task.Reference, |
| | 821 | 327 | | Tags = new HashSet<string>(task.Tags, StringComparer.OrdinalIgnoreCase), |
| | 821 | 328 | | TaskType = task.TaskType, |
| | 821 | 329 | | Requester = task.Requester, |
| | 821 | 330 | | Assignee = task.Assignee, |
| | 821 | 331 | | CandidateUsers = [..task.CandidateUsers], |
| | 821 | 332 | | CandidateGroups = [..task.CandidateGroups], |
| | 821 | 333 | | SnapshotMembers = [..task.SnapshotMembers], |
| | 821 | 334 | | SnapshotGroups = [..task.SnapshotGroups], |
| | 821 | 335 | | ExcludedUsers = [..task.ExcludedUsers], |
| | 821 | 336 | | MembershipResolutionMode = task.MembershipResolutionMode, |
| | 821 | 337 | | AllowManagerExclusionOverride = task.AllowManagerExclusionOverride, |
| | 821 | 338 | | Priority = task.Priority, |
| | 821 | 339 | | DueAt = task.DueAt, |
| | 821 | 340 | | IsOverdue = task.IsOverdue, |
| | 821 | 341 | | Instructions = task.Instructions, |
| | 821 | 342 | | TaskData = Clone(task.TaskData), |
| | 821 | 343 | | RequestedForm = task.RequestedForm, |
| | 821 | 344 | | PinnedForm = task.PinnedForm, |
| | 821 | 345 | | Actions = [..task.Actions], |
| | 821 | 346 | | InvitationDefinitions = [..task.InvitationDefinitions], |
| | 821 | 347 | | EnableTimeoutOutcome = task.EnableTimeoutOutcome, |
| | 821 | 348 | | EnableCancellationOutcome = task.EnableCancellationOutcome, |
| | 821 | 349 | | Status = task.Status, |
| | 821 | 350 | | HealthSeverity = task.HealthSeverity, |
| | 821 | 351 | | HealthCode = task.HealthCode, |
| | 821 | 352 | | HealthMessage = task.HealthMessage, |
| | 821 | 353 | | CompletionActionKey = task.CompletionActionKey, |
| | 821 | 354 | | CompletionData = Clone(task.CompletionData), |
| | 821 | 355 | | CompletedBy = task.CompletedBy, |
| | 821 | 356 | | CreatedAt = task.CreatedAt, |
| | 821 | 357 | | UpdatedAt = task.UpdatedAt, |
| | 821 | 358 | | AssignedAt = task.AssignedAt, |
| | 821 | 359 | | CompletedAt = task.CompletedAt, |
| | 821 | 360 | | Revision = task.Revision, |
| | 821 | 361 | | Events = [..task.Events], |
| | 821 | 362 | | Operations = [..task.Operations], |
| | 821 | 363 | | Invitations = [..task.Invitations] |
| | 821 | 364 | | }; |
| | | 365 | | |
| | 1642 | 366 | | private static JsonElement? Clone(JsonElement? value) => value is { } element ? element.Clone() : null; |
| | | 367 | | } |