* fix(user-tasks): let managers revoke a consumed guest invitation Verification marks the winning invitation Consumed, which is what issues the guest session — but RevokeAsync rejected Consumed and never touched sessions at all. A manager therefore could not withdraw a live guest credential: it stayed authorized until its TTL elapsed or the task closed. The invitations contract specifies a revocable, task-scoped session, so this was a real gap. RevokeAsync now accepts a consumed invitation, rejecting only the already terminal Revoked and Expired states, and revokes the sessions that invitation issued. Revocation is scoped to one invitation rather than the whole task, so other guests keep working: UserTaskGuestSession carries its InvitationId and IUserTaskGuestSessionIssuer gains RevokeForInvitationAsync, implemented for both the in-memory and EF Core stores. Reassignment already cut a guest off, because the policy requires the guest to still be the assignee. That remains the recovery path for abandoned guest work; this restores the documented direct revocation alongside it. Adds three tests. The first fails against the previous behavior. Reported by Greptile on #7955. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(user-tasks): make guest-session revocation fail closed and retryable Greptile review of the previous commit found three real problems with it. Revocation committed the invitation as Revoked before revoking its sessions, so a session-store failure left a live credential behind a guard that rejected the retry. Sessions are now swept before the terminal state is committed: a failure commits nothing, leaves the invitation revocable, and a retry repairs it. A retry against an already-revoked invitation is idempotently successful and re-runs the sweep, so a caller repairing a partial failure is never told no. Verification could also hand back a credential that outlived a concurrent revoke: the manager's sweep ran before the session reached the store and found nothing. VerifyAsync now re-reads the committed invitation after issuing and withdraws the credential unless it is still the consumed one it verified. Invitation-scoped revocation queried an unindexed column, so every revoke scanned a growing tenant partition of retained session rows. Adds the (TenantId, InvitationId) index to the EF model and migration, and advertises the same index from the VNext schema provider. Adds three tests covering the injected store failure, the idempotent retry, and the revoke-during-verify race. RevokingAnAlreadyRevokedInvitationIsRefused asserted the behavior this commit deliberately changes, so it is repurposed to cover the refusal that remains: an unknown invitation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(user-tasks): sweep guest sessions on both sides of the revoke commit Moving the sweep before the commit closed the fail-open failure path but opened its mirror: a concurrent verification can issue a session after the sweep, still read Consumed at its settled-state check because the revoke has not committed yet, and hand back a credential that outlives a successful revoke. Revocation now sweeps after the commit as well. Anything issued in that window is caught by the second sweep, and any verification that issues after the commit sees the revoked state at its own settled-state check and withdraws its own credential. The first sweep still runs before the commit, so a session-store failure commits nothing and stays retryable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
674 lines
27 KiB
C#
674 lines
27 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Elsa.Mediator.Contracts;
|
|
|
|
namespace Elsa.UserTasks.Models;
|
|
|
|
public enum UserTaskParticipantType
|
|
{
|
|
User,
|
|
Group
|
|
}
|
|
|
|
public enum UserTaskMembershipResolutionMode
|
|
{
|
|
Live,
|
|
Snapshot
|
|
}
|
|
|
|
public enum UserTaskStatus
|
|
{
|
|
Unassigned,
|
|
Available,
|
|
Assigned,
|
|
Completing,
|
|
TimingOut,
|
|
Cancelling,
|
|
Completed,
|
|
TimedOut,
|
|
Cancelled
|
|
}
|
|
|
|
public enum UserTaskHealthSeverity
|
|
{
|
|
Advisory,
|
|
Blocking
|
|
}
|
|
|
|
public enum UserTaskAccessOperation
|
|
{
|
|
ReadSummary,
|
|
ReadProtected,
|
|
Claim,
|
|
Release,
|
|
Assign,
|
|
UpdateScheduling,
|
|
Complete,
|
|
Cancel,
|
|
Manage,
|
|
IssueInvitation,
|
|
RetryResolution
|
|
}
|
|
|
|
public enum UserTaskOperationKind
|
|
{
|
|
Claim,
|
|
Release,
|
|
Assign,
|
|
ScheduleUpdate,
|
|
Complete,
|
|
Timeout,
|
|
Cancel,
|
|
RetryResolution,
|
|
InvitationVerification
|
|
}
|
|
|
|
public enum UserTaskOperationStatus
|
|
{
|
|
Accepted,
|
|
Completed,
|
|
Failed
|
|
}
|
|
|
|
public enum UserTaskInvitationStatus
|
|
{
|
|
Pending,
|
|
Dispatched,
|
|
Verified,
|
|
Consumed,
|
|
Revoked,
|
|
Expired
|
|
}
|
|
|
|
public sealed record ParticipantReference(
|
|
string TenantId,
|
|
string Provider,
|
|
UserTaskParticipantType Type,
|
|
string Id,
|
|
string? DisplayName = null)
|
|
{
|
|
public bool Matches(ParticipantReference? other) => other != null
|
|
&& string.Equals(TenantId, other.TenantId, StringComparison.Ordinal)
|
|
&& string.Equals(Provider, other.Provider, StringComparison.Ordinal)
|
|
&& Type == other.Type
|
|
&& string.Equals(Id, other.Id, StringComparison.Ordinal);
|
|
}
|
|
|
|
public sealed record UserTaskActor(
|
|
ParticipantReference Subject,
|
|
IReadOnlyCollection<ParticipantReference> Groups,
|
|
string? DisplayName = null)
|
|
{
|
|
/// <summary>Indicates that the host has granted tenant-scoped manager access.</summary>
|
|
public bool IsManager { get; init; }
|
|
public IReadOnlySet<string> Permissions { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>
|
|
/// Set when the caller authenticated with a guest invitation session. A guest is scoped to exactly
|
|
/// one task; every policy decision for any other task must deny.
|
|
/// </summary>
|
|
public string? GuestTaskId { get; init; }
|
|
|
|
/// <summary>The completion action keys a guest session was issued for. Empty for ordinary actors.</summary>
|
|
public IReadOnlySet<string> GuestAllowedActions { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public bool IsGuest => GuestTaskId != null;
|
|
|
|
public bool HasPermission(string permission) => Permissions.Contains(permission) || Permissions.Contains("*");
|
|
}
|
|
|
|
public sealed record UserTaskAction(
|
|
string Key,
|
|
string Label,
|
|
IReadOnlyDictionary<string, object?>? Metadata = null);
|
|
|
|
/// <summary>
|
|
/// Describes a guest invitation that is materialized with a task. The secret itself is created only
|
|
/// by Core at runtime and is never part of this definition.
|
|
/// </summary>
|
|
public sealed record UserTaskInvitationDefinition(
|
|
string VerifierName,
|
|
IReadOnlyCollection<string> AllowedActions,
|
|
TimeSpan? Lifetime = null,
|
|
bool BearerOnly = false,
|
|
string? Recipient = null,
|
|
IReadOnlyDictionary<string, object?>? Configuration = null);
|
|
|
|
public sealed record UserTaskFormReference(
|
|
string ProviderName,
|
|
string Key,
|
|
string? Binding = null,
|
|
string? Version = null);
|
|
|
|
public sealed record ResolvedUserTaskForm(
|
|
UserTaskFormReference Requested,
|
|
string PinnedVersion,
|
|
IReadOnlyDictionary<string, object?> Metadata)
|
|
{
|
|
/// <summary>
|
|
/// Provider-neutral field descriptors used to render the response surface. Descriptors carry shape and
|
|
/// disclosure flags only; values are read from the task payload under the protected-access decision.
|
|
/// </summary>
|
|
public IReadOnlyCollection<UserTaskFormFieldDescriptor> Fields { get; init; } = [];
|
|
}
|
|
|
|
public sealed record UserTaskFormFieldDescriptor(
|
|
string Key,
|
|
string Label,
|
|
string Type = "text",
|
|
bool Required = false,
|
|
bool Masked = false,
|
|
bool CanReveal = false);
|
|
|
|
public sealed record UserTaskDefinitionSnapshot
|
|
{
|
|
public string Title { get; init; } = "";
|
|
public string? Summary { get; init; }
|
|
public string? Reference { get; init; }
|
|
public IReadOnlyCollection<string> Tags { get; init; } = [];
|
|
public string? TaskType { get; init; }
|
|
public ParticipantReference? Requester { get; init; }
|
|
public ParticipantReference? Assignee { get; init; }
|
|
public IReadOnlyCollection<ParticipantReference> CandidateUsers { get; init; } = [];
|
|
public IReadOnlyCollection<ParticipantReference> CandidateGroups { get; init; } = [];
|
|
public IReadOnlyCollection<ParticipantReference> ExcludedUsers { get; init; } = [];
|
|
public UserTaskMembershipResolutionMode MembershipResolutionMode { get; init; } = UserTaskMembershipResolutionMode.Live;
|
|
public bool AllowManagerExclusionOverride { get; init; }
|
|
public int Priority { get; init; } = 50;
|
|
public DateTimeOffset? DueAt { get; init; }
|
|
public string? Instructions { get; init; }
|
|
public JsonElement? TaskData { get; init; }
|
|
public UserTaskFormReference? FormReference { get; init; }
|
|
public IReadOnlyCollection<UserTaskAction> Actions { get; init; } = [];
|
|
public IReadOnlyCollection<UserTaskInvitationDefinition> Invitations { get; init; } = [];
|
|
public bool EnableTimeoutOutcome { get; init; }
|
|
public bool EnableCancellationOutcome { get; init; }
|
|
|
|
public UserTaskDefinitionSnapshot Normalize()
|
|
{
|
|
var actions = Actions.Count == 0
|
|
? [new UserTaskAction("Complete", "Complete")]
|
|
: Actions;
|
|
|
|
if (string.IsNullOrWhiteSpace(Title))
|
|
throw new ArgumentException("A User Task title is required.", nameof(Title));
|
|
if (actions.Any(x => string.IsNullOrWhiteSpace(x.Key) || string.IsNullOrWhiteSpace(x.Label)))
|
|
throw new ArgumentException("User Task action keys and labels are required.", nameof(Actions));
|
|
if (actions.Any(x => string.Equals(x.Key, "Timeout", StringComparison.OrdinalIgnoreCase) || string.Equals(x.Key, "Cancelled", StringComparison.OrdinalIgnoreCase)))
|
|
throw new ArgumentException("Timeout and Cancelled are reserved User Task action keys.");
|
|
if (actions.Select(x => x.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count() != actions.Count)
|
|
throw new ArgumentException("User Task action keys must be unique.");
|
|
if (Priority is < 0 or > 100)
|
|
throw new ArgumentOutOfRangeException(nameof(Priority), "Priority must be between 0 and 100.");
|
|
if (Invitations.Any(x => string.IsNullOrWhiteSpace(x.VerifierName) || x.AllowedActions.Count == 0 || x.AllowedActions.Any(string.IsNullOrWhiteSpace)))
|
|
throw new ArgumentException("Invitation verifier names and allowed actions are required.", nameof(Invitations));
|
|
if (Invitations.Any(invitation => invitation.AllowedActions.Any(allowed => !actions.Any(action => string.Equals(action.Key, allowed, StringComparison.OrdinalIgnoreCase)))))
|
|
throw new ArgumentException("Invitation actions must be configured User Task actions.", nameof(Invitations));
|
|
|
|
return this with { Actions = actions };
|
|
}
|
|
}
|
|
|
|
public sealed class UserTask
|
|
{
|
|
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
|
public string TenantId { get; set; } = "";
|
|
public string WorkflowDefinitionId { get; set; } = "";
|
|
public string? WorkflowDefinitionName { get; set; }
|
|
public int? WorkflowDefinitionVersion { get; set; }
|
|
public string WorkflowInstanceId { get; set; } = "";
|
|
/// <summary>A safe, host-authored instance reference (correlation ID or instance name). Never a bookmark or token.</summary>
|
|
public string? WorkflowInstanceReference { get; set; }
|
|
public string ActivityInstanceId { get; set; } = "";
|
|
public string BookmarkId { get; set; } = "";
|
|
public string MaterializationKey { get; set; } = "";
|
|
public string Title { get; set; } = "User task";
|
|
public string? Summary { get; set; }
|
|
public string? Reference { get; set; }
|
|
public HashSet<string> Tags { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
|
public string? TaskType { get; set; }
|
|
public ParticipantReference? Requester { get; set; }
|
|
public ParticipantReference? Assignee { get; set; }
|
|
public List<ParticipantReference> CandidateUsers { get; set; } = [];
|
|
public List<ParticipantReference> CandidateGroups { get; set; } = [];
|
|
public List<ParticipantReference> SnapshotMembers { get; set; } = [];
|
|
public List<ParticipantReference> SnapshotGroups { get; set; } = [];
|
|
public List<ParticipantReference> ExcludedUsers { get; set; } = [];
|
|
public UserTaskMembershipResolutionMode MembershipResolutionMode { get; set; } = UserTaskMembershipResolutionMode.Live;
|
|
public bool AllowManagerExclusionOverride { get; set; }
|
|
public int Priority { get; set; } = 50;
|
|
public DateTimeOffset? DueAt { get; set; }
|
|
public bool IsOverdue { get; set; }
|
|
public string? Instructions { get; set; }
|
|
public JsonElement? TaskData { get; set; }
|
|
public UserTaskFormReference? RequestedForm { get; set; }
|
|
public ResolvedUserTaskForm? PinnedForm { get; set; }
|
|
public List<UserTaskAction> Actions { get; set; } = [new("Complete", "Complete")];
|
|
public List<UserTaskInvitationDefinition> InvitationDefinitions { get; set; } = [];
|
|
public bool EnableTimeoutOutcome { get; set; }
|
|
public bool EnableCancellationOutcome { get; set; }
|
|
public UserTaskStatus Status { get; set; } = UserTaskStatus.Available;
|
|
public UserTaskHealthSeverity? HealthSeverity { get; set; }
|
|
public string? HealthCode { get; set; }
|
|
public string? HealthMessage { get; set; }
|
|
public string? CompletionActionKey { get; set; }
|
|
public JsonElement? CompletionData { get; set; }
|
|
public ParticipantReference? CompletedBy { get; set; }
|
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
|
public DateTimeOffset? AssignedAt { get; set; }
|
|
public DateTimeOffset? CompletedAt { get; set; }
|
|
public int Revision { get; set; } = 1;
|
|
public List<UserTaskEvent> Events { get; set; } = [];
|
|
public List<UserTaskOperation> Operations { get; set; } = [];
|
|
public List<UserTaskInvitation> Invitations { get; set; } = [];
|
|
|
|
public bool IsTerminal => Status is UserTaskStatus.Completed or UserTaskStatus.TimedOut or UserTaskStatus.Cancelled;
|
|
|
|
public bool IsOpen => !IsTerminal;
|
|
}
|
|
|
|
public sealed record UserTaskEvent(
|
|
string Id,
|
|
string TenantId,
|
|
string TaskId,
|
|
int Revision,
|
|
string EventType,
|
|
DateTimeOffset OccurredAt,
|
|
ParticipantReference? Actor = null,
|
|
string? OperationId = null,
|
|
string? Reason = null,
|
|
IReadOnlyDictionary<string, object?>? Metadata = null);
|
|
|
|
public sealed record UserTaskOperation(
|
|
string Id,
|
|
string TenantId,
|
|
string TaskId,
|
|
string OperationId,
|
|
UserTaskOperationKind Kind,
|
|
int ExpectedRevision,
|
|
string RequestHash,
|
|
UserTaskOperationStatus Status,
|
|
DateTimeOffset CreatedAt,
|
|
DateTimeOffset UpdatedAt,
|
|
string? ActionKey = null,
|
|
JsonElement? Data = null,
|
|
string? ErrorCode = null);
|
|
|
|
public sealed record UserTaskInvitation(
|
|
string Id,
|
|
string TenantId,
|
|
string TaskId,
|
|
string? Recipient,
|
|
string TokenHash,
|
|
UserTaskInvitationStatus Status,
|
|
DateTimeOffset IssuedAt,
|
|
DateTimeOffset ExpiresAt,
|
|
string? VerifierName = null,
|
|
DateTimeOffset? VerifiedAt = null,
|
|
DateTimeOffset? ConsumedAt = null,
|
|
DateTimeOffset? RevokedAt = null,
|
|
string? SiblingGroupId = null)
|
|
{
|
|
/// <summary>
|
|
/// The completion actions this invitation was issued for. It is copied from the activity's invitation
|
|
/// definition at issuance so a later definition change cannot widen an outstanding guest link.
|
|
/// </summary>
|
|
public IReadOnlyCollection<string> AllowedActions { get; init; } = [];
|
|
}
|
|
|
|
public sealed record UserTaskInvitationDelivery(
|
|
string Id,
|
|
string TenantId,
|
|
string TaskId,
|
|
string InvitationId,
|
|
string DispatcherName,
|
|
string Token,
|
|
DateTimeOffset ExpiresAt)
|
|
{
|
|
public string? Recipient { get; init; }
|
|
public int Attempt { get; init; }
|
|
public DateTimeOffset? NotBefore { get; init; }
|
|
}
|
|
|
|
public sealed record UserTaskInvitationChallenge(string Token, string? Code = null, string? State = null);
|
|
|
|
/// <summary>
|
|
/// A deliberately uniform challenge descriptor. The same shape is returned for valid and invalid tokens so
|
|
/// the anonymous surface never becomes an invitation-existence oracle.
|
|
/// </summary>
|
|
public sealed record UserTaskInvitationChallengeDescriptor(string ChallengeType, string Prompt, bool RequiresCode);
|
|
|
|
public sealed record UserTaskInvitationVerificationResult(bool Succeeded, string? FailureCode = null, string? Subject = null);
|
|
|
|
public sealed record GuestSessionResult(bool Succeeded, string? Token = null, DateTimeOffset? ExpiresAt = null, string? FailureCode = null, string? TaskId = null);
|
|
|
|
/// <summary>A resolved guest session. It authorizes exactly one task and one set of completion actions.</summary>
|
|
public sealed record UserTaskGuestSession(
|
|
string TenantId,
|
|
string TaskId,
|
|
string InvitationId,
|
|
ParticipantReference Subject,
|
|
IReadOnlyCollection<string> AllowedActions,
|
|
DateTimeOffset ExpiresAt);
|
|
|
|
public sealed record UserTaskResult(
|
|
string ActionKey,
|
|
JsonElement? Data,
|
|
ParticipantReference? CompletedBy,
|
|
DateTimeOffset CompletedAt);
|
|
|
|
public sealed record UserTaskInvitationSummary(
|
|
string Id,
|
|
string TaskId,
|
|
string? Recipient,
|
|
UserTaskInvitationStatus Status,
|
|
DateTimeOffset IssuedAt,
|
|
DateTimeOffset ExpiresAt,
|
|
string? VerifierName);
|
|
|
|
public sealed record UserTaskInvitationIssueRequest(
|
|
int ExpectedRevision,
|
|
string VerifierName,
|
|
IReadOnlyCollection<string> AllowedActions,
|
|
string? Recipient = null,
|
|
TimeSpan? Lifetime = null,
|
|
string? OperationId = null);
|
|
|
|
public sealed record UserTaskInvitationIssueResult(
|
|
UserTaskInvitationSummary Invitation,
|
|
string? OperationId = null);
|
|
|
|
public sealed record UserTaskInvitationVerificationResultWithSession(
|
|
bool Succeeded,
|
|
string? TaskId = null,
|
|
string? SessionToken = null,
|
|
DateTimeOffset? ExpiresAt = null,
|
|
string? FailureCode = null);
|
|
|
|
public sealed record UserTaskMaterialization(
|
|
string TenantId,
|
|
string WorkflowDefinitionId,
|
|
string WorkflowInstanceId,
|
|
string ActivityInstanceId,
|
|
string BookmarkId,
|
|
UserTaskDefinitionSnapshot Definition,
|
|
IReadOnlyCollection<ParticipantReference> SnapshotMembers,
|
|
IReadOnlyCollection<ParticipantReference> SnapshotGroups,
|
|
DateTimeOffset CreatedAt,
|
|
string? TaskId = null,
|
|
string? WorkflowDefinitionName = null,
|
|
int? WorkflowDefinitionVersion = null,
|
|
string? WorkflowInstanceReference = null);
|
|
|
|
public sealed record UserTaskStimulus(
|
|
string TenantId,
|
|
string TaskId,
|
|
string OperationId,
|
|
string ActionKey,
|
|
JsonElement? CompletionData,
|
|
ParticipantReference? CompletedBy = null,
|
|
DateTimeOffset? CompletedAt = null,
|
|
string? BookmarkId = null);
|
|
|
|
public sealed record UserTaskBookmarkRemoval(
|
|
string TenantId,
|
|
string TaskId,
|
|
string BookmarkId,
|
|
DateTimeOffset RemovedAt);
|
|
|
|
public sealed record UserTaskMutationRequest(int ExpectedRevision, string? OperationId = null);
|
|
|
|
public sealed record UserTaskAssignRequest(
|
|
int ExpectedRevision,
|
|
ParticipantReference Assignee,
|
|
string? Reason = null,
|
|
string? OperationId = null);
|
|
|
|
public sealed record UserTaskSchedulingUpdate(
|
|
int ExpectedRevision,
|
|
int? Priority = null,
|
|
DateTimeOffset? DueAt = null,
|
|
string? OperationId = null);
|
|
|
|
public sealed record UserTaskCompletionRequest(
|
|
int ExpectedRevision,
|
|
string OperationId,
|
|
string ActionKey,
|
|
JsonElement? Data = null);
|
|
|
|
public sealed record UserTaskCancelRequest(int ExpectedRevision, string OperationId, string Reason);
|
|
|
|
/// <summary>
|
|
/// The caller-selected list scope. It is part of the authorization predicate, not a display filter:
|
|
/// <see cref="All"/> and <see cref="NeedsAttention"/> require tenant-scoped manager access.
|
|
/// </summary>
|
|
public enum UserTaskQueryScopeKind
|
|
{
|
|
Assigned,
|
|
Available,
|
|
History,
|
|
All,
|
|
NeedsAttention
|
|
}
|
|
|
|
public sealed record UserTaskQueryScope(
|
|
string TenantId,
|
|
ParticipantReference Subject,
|
|
IReadOnlyCollection<ParticipantReference> Groups,
|
|
bool IsManager = false,
|
|
UserTaskQueryScopeKind Kind = UserTaskQueryScopeKind.Assigned,
|
|
bool ExcludeBlocking = true)
|
|
{
|
|
public bool IncludeAssigned => Kind is UserTaskQueryScopeKind.Assigned;
|
|
public bool IncludeCandidates => Kind is UserTaskQueryScopeKind.Available;
|
|
public bool IncludeHistory => Kind is UserTaskQueryScopeKind.History;
|
|
public bool RequiresManager => Kind is UserTaskQueryScopeKind.All or UserTaskQueryScopeKind.NeedsAttention;
|
|
}
|
|
|
|
public sealed record UserTaskQuery
|
|
{
|
|
public string TenantId { get; init; } = "";
|
|
public UserTaskQueryScope? Scope { get; init; }
|
|
public string? Cursor { get; init; }
|
|
public int Limit { get; init; } = 50;
|
|
public string? Search { get; init; }
|
|
public IReadOnlyCollection<UserTaskStatus> Statuses { get; init; } = [];
|
|
/// <summary>Restricts the page to tasks whose due date has already elapsed.</summary>
|
|
public bool OnlyOverdue { get; init; }
|
|
/// <summary>Restricts the page to tasks that carry no due date at all.</summary>
|
|
public bool OnlyWithoutDueDate { get; init; }
|
|
public string? TaskType { get; init; }
|
|
public int? PriorityFrom { get; init; }
|
|
public int? PriorityTo { get; init; }
|
|
public DateTimeOffset? DueFrom { get; init; }
|
|
public DateTimeOffset? DueTo { get; init; }
|
|
public string? WorkflowDefinitionId { get; init; }
|
|
public string? WorkflowInstanceId { get; init; }
|
|
public string? Reference { get; init; }
|
|
public string Sort { get; init; } = "created";
|
|
public bool Descending { get; init; }
|
|
public bool IncludeTotalCount { get; init; }
|
|
}
|
|
|
|
public sealed record UserTaskQueryResult(
|
|
IReadOnlyCollection<UserTask> Items,
|
|
string? NextCursor,
|
|
int? TotalCount);
|
|
|
|
public sealed record UserTaskProjectionResult(UserTask Task, bool Created);
|
|
|
|
public sealed record UserTaskReconciliationRequest(int PageSize = 100, DateTimeOffset? OlderThan = null)
|
|
{
|
|
/// <summary>Runs a tenant-scoped pass. Hosts with a tenant catalog can invoke one pass per tenant.</summary>
|
|
public string TenantId { get; init; } = "";
|
|
}
|
|
|
|
public sealed record UserTaskReconciliationResult(int Recreated, int Requeued, int Finalized, int Ambiguous);
|
|
|
|
public sealed record UserTaskOperationResult(
|
|
UserTask Task,
|
|
UserTaskOperation Operation,
|
|
bool Accepted,
|
|
string? ConflictCode = null);
|
|
|
|
public sealed record UserTaskQueryResultDto(
|
|
IReadOnlyCollection<UserTaskSummary> Items,
|
|
string? NextCursor,
|
|
int? TotalCount);
|
|
|
|
/// <summary>
|
|
/// Wire projection of a participant. The tenant is implicit in the caller's own scope and is deliberately
|
|
/// never emitted, so a response can never be used to enumerate foreign tenants.
|
|
/// </summary>
|
|
public sealed record UserTaskParticipantSummary(string Kind, string? Provider, string Id, string? DisplayName)
|
|
{
|
|
public const string UserKind = "user";
|
|
public const string GroupKind = "group";
|
|
|
|
public static UserTaskParticipantSummary? From(ParticipantReference? reference) => reference == null
|
|
? null
|
|
: new(reference.Type == UserTaskParticipantType.Group ? GroupKind : UserKind, reference.Provider, reference.Id, reference.DisplayName);
|
|
}
|
|
|
|
/// <summary>The safe summary every authorized caller receives. Protected fields live on <see cref="UserTaskDetail"/>.</summary>
|
|
public record UserTaskSummary
|
|
{
|
|
public string Id { get; init; } = "";
|
|
public string Title { get; init; } = "";
|
|
public string? Summary { get; init; }
|
|
public string? Reference { get; init; }
|
|
public IReadOnlyCollection<string> Tags { get; init; } = [];
|
|
public string? TaskType { get; init; }
|
|
public string Status { get; init; } = nameof(UserTaskStatus.Available);
|
|
public int Priority { get; init; } = 50;
|
|
public UserTaskParticipantSummary? Assignee { get; init; }
|
|
/// <summary>A count-only candidate description. Candidate identities are never disclosed to peers.</summary>
|
|
public string? CandidateSummary { get; init; }
|
|
public DateTimeOffset? DueAt { get; init; }
|
|
public bool IsOverdue { get; init; }
|
|
public DateTimeOffset CreatedAt { get; init; }
|
|
public DateTimeOffset UpdatedAt { get; init; }
|
|
public DateTimeOffset? AssignedAt { get; init; }
|
|
public DateTimeOffset? CompletedAt { get; init; }
|
|
public string? WorkflowDefinitionId { get; init; }
|
|
public string? WorkflowDefinitionName { get; init; }
|
|
public int? WorkflowDefinitionVersion { get; init; }
|
|
public string? WorkflowInstanceId { get; init; }
|
|
public string? WorkflowInstanceReference { get; init; }
|
|
public string? HealthSeverity { get; init; }
|
|
public string? HealthCode { get; init; }
|
|
public IReadOnlyCollection<string> AllowedActions { get; init; } = [];
|
|
public int Revision { get; init; }
|
|
}
|
|
|
|
public sealed record UserTaskDetail : UserTaskSummary
|
|
{
|
|
public string? Instructions { get; init; }
|
|
/// <summary>The protected task payload. Present only when <see cref="UserTaskDisclosure.CanViewProtected"/> is true.</summary>
|
|
public JsonElement? Data { get; init; }
|
|
public UserTaskDisclosure Disclosure { get; init; } = new();
|
|
public UserTaskWorkflowContext? Workflow { get; init; }
|
|
public UserTaskFormProjection? Form { get; init; }
|
|
public IReadOnlyCollection<UserTaskFormAction> Actions { get; init; } = [];
|
|
/// <summary>The recorded completion action key, when the task has reached a terminal or transitional outcome.</summary>
|
|
public string? Outcome { get; init; }
|
|
public JsonElement? Response { get; init; }
|
|
public UserTaskParticipantSummary? CompletedBy { get; init; }
|
|
}
|
|
|
|
public sealed record UserTaskDisclosure
|
|
{
|
|
public bool CanViewProtected { get; init; }
|
|
public bool CanViewWorkflow { get; init; }
|
|
public bool CanViewHistory { get; init; }
|
|
/// <summary>True when the projection was produced for a guest session and is therefore already narrowed.</summary>
|
|
public bool GuestVisible { get; init; }
|
|
}
|
|
|
|
public sealed record UserTaskWorkflowContext
|
|
{
|
|
public string? DefinitionId { get; init; }
|
|
public string? DefinitionName { get; init; }
|
|
public int? DefinitionVersion { get; init; }
|
|
public string? InstanceId { get; init; }
|
|
public string? InstanceReference { get; init; }
|
|
}
|
|
|
|
public sealed record UserTaskFormProjection
|
|
{
|
|
public string Provider { get; init; } = "";
|
|
public string Key { get; init; } = "";
|
|
public string? Version { get; init; }
|
|
public IReadOnlyCollection<UserTaskFormField> Fields { get; init; } = [];
|
|
public IReadOnlyCollection<UserTaskFormAction> Actions { get; init; } = [];
|
|
}
|
|
|
|
public sealed record UserTaskFormField
|
|
{
|
|
public string Key { get; init; } = "";
|
|
public string Label { get; init; } = "";
|
|
public string Type { get; init; } = "text";
|
|
public bool Required { get; init; }
|
|
public bool Masked { get; init; }
|
|
public bool CanReveal { get; init; }
|
|
/// <summary>Always null for masked fields; those are read through the explicit reveal command.</summary>
|
|
public JsonElement? Value { get; init; }
|
|
}
|
|
|
|
public sealed record UserTaskFormAction(string Key, string Label);
|
|
|
|
/// <summary>Safe audit projection. Actor identifiers, reasons containing input, and metadata are not disclosed.</summary>
|
|
public sealed record UserTaskEventSummary(string Id, string Kind, string? Summary, DateTimeOffset OccurredAt, string? ActorDisplayName);
|
|
|
|
public sealed record UserTaskEventsResult(IReadOnlyCollection<UserTaskEventSummary> Items, string? NextCursor);
|
|
|
|
/// <summary>Per-task capability and concurrency projection.</summary>
|
|
public sealed record UserTaskCapabilities(
|
|
string TaskId,
|
|
int Revision,
|
|
IReadOnlyCollection<string> AllowedActions,
|
|
bool CanReadProtected,
|
|
bool CanManage);
|
|
|
|
/// <summary>
|
|
/// Tenant- and actor-scoped feature descriptor. Studio reads this before rendering navigation; it is
|
|
/// advisory only and never a substitute for per-request authorization.
|
|
/// </summary>
|
|
public sealed record UserTaskFeatureCapabilities
|
|
{
|
|
public bool Enabled { get; init; }
|
|
public bool CanList { get; init; }
|
|
public bool CanRead { get; init; }
|
|
public bool CanReadAll { get; init; }
|
|
public bool CanClaim { get; init; }
|
|
public bool CanComplete { get; init; }
|
|
public bool CanRelease { get; init; }
|
|
public bool CanAssign { get; init; }
|
|
public bool CanUpdate { get; init; }
|
|
public bool CanCancel { get; init; }
|
|
public bool CanCreateGuestLinks { get; init; }
|
|
public bool CanViewProtected { get; init; }
|
|
public bool ParticipantPicker { get; init; }
|
|
public bool Realtime { get; init; }
|
|
public int PollingIntervalSeconds { get; init; } = 30;
|
|
}
|
|
|
|
public sealed record UserTaskParticipantQuery(string TenantId, string? Search = null, UserTaskParticipantType? Type = null, string? Cursor = null, int Limit = 50);
|
|
|
|
public sealed record ParticipantSearchResult(IReadOnlyCollection<ParticipantReference> Items, string? NextCursor, int? TotalCount);
|
|
|
|
public sealed record UserTaskFormValidationResult(bool Succeeded, JsonElement? NormalizedData = null, IReadOnlyCollection<string>? Errors = null);
|
|
|
|
public abstract record UserTaskLifecycleNotification(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : INotification;
|
|
|
|
public sealed record UserTaskCreated(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision);
|
|
public sealed record UserTaskChanged(string TenantId, string TaskId, UserTaskStatus Status, int Revision, IReadOnlyCollection<string> ChangedFields) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision);
|
|
public sealed record UserTaskCompletionAccepted(string TenantId, string TaskId, UserTaskStatus Status, int Revision, string OperationId) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision);
|
|
public sealed record UserTaskCompleted(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision);
|
|
public sealed record UserTaskTimedOut(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision);
|
|
public sealed record UserTaskCancelled(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision);
|
|
public sealed record UserTaskOverdue(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision);
|
|
public sealed record UserTaskInvitationChanged(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision);
|
|
public sealed record UserTaskHealthChanged(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision);
|