wip
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Approvals.Data;
|
||||
using Socialize.Api.Modules.ContentItems.Data;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
using Socialize.Api.Modules.Notifications.Contracts;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Approvals.Services;
|
||||
|
||||
public record ApprovalWorkflowStartResult(bool Succeeded, string? ErrorMessage);
|
||||
|
||||
public record ApprovalWorkflowDecisionResult(
|
||||
bool Succeeded,
|
||||
string? ErrorMessage,
|
||||
int StatusCode,
|
||||
bool IsWorkflowStep);
|
||||
|
||||
public class ApprovalWorkflowRuntimeService(
|
||||
AppDbContext dbContext,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
{
|
||||
private const string PendingState = "Pending";
|
||||
private const string ApprovedState = "Approved";
|
||||
|
||||
public async Task<ApprovalWorkflowStartResult> StartMultiLevelWorkflowAsync(
|
||||
ContentItem contentItem,
|
||||
Workspace workspace,
|
||||
Guid requestedByUserId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (workspace.ApprovalMode != ApprovalModes.MultiLevel)
|
||||
{
|
||||
return new ApprovalWorkflowStartResult(false, "The workspace is not configured for multi-level approval.");
|
||||
}
|
||||
|
||||
ApprovalWorkflowInstance? activeWorkflow = await dbContext.ApprovalWorkflowInstances
|
||||
.SingleOrDefaultAsync(
|
||||
workflow => workflow.ContentItemId == contentItem.Id && workflow.State == PendingState,
|
||||
ct);
|
||||
if (activeWorkflow is not null)
|
||||
{
|
||||
contentItem.Status = "In approval";
|
||||
return new ApprovalWorkflowStartResult(true, null);
|
||||
}
|
||||
|
||||
List<WorkspaceApprovalStepConfiguration> configuredSteps = await dbContext.WorkspaceApprovalStepConfigurations
|
||||
.Where(step => step.WorkspaceId == workspace.Id)
|
||||
.OrderBy(step => step.SortOrder)
|
||||
.ThenBy(step => step.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (configuredSteps.Count == 0)
|
||||
{
|
||||
return new ApprovalWorkflowStartResult(false, "Multi-level approval requires at least one configured approval step.");
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
var workflowInstance = new ApprovalWorkflowInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = workspace.Id,
|
||||
ContentItemId = contentItem.Id,
|
||||
State = PendingState,
|
||||
ApprovalMode = workspace.ApprovalMode,
|
||||
StartedAt = now,
|
||||
};
|
||||
|
||||
List<ApprovalRequest> workflowSteps = configuredSteps
|
||||
.Select((step, index) => new ApprovalRequest
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = workspace.Id,
|
||||
ContentItemId = contentItem.Id,
|
||||
WorkflowInstanceId = workflowInstance.Id,
|
||||
WorkflowStepSortOrder = index,
|
||||
WorkflowStepTargetType = step.TargetType,
|
||||
WorkflowStepTargetValue = step.TargetValue,
|
||||
WorkflowStepRequiredApproverCount = step.RequiredApproverCount,
|
||||
Stage = step.Name,
|
||||
ReviewerName = FormatStepTarget(step),
|
||||
ReviewerEmail = string.Empty,
|
||||
RequestedByUserId = requestedByUserId,
|
||||
DueAt = contentItem.DueDate,
|
||||
State = PendingState,
|
||||
AccessToken = CreateAccessToken(),
|
||||
SentAt = now,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
dbContext.ApprovalWorkflowInstances.Add(workflowInstance);
|
||||
dbContext.ApprovalRequests.AddRange(workflowSteps);
|
||||
contentItem.Status = "In approval";
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
await NotifyCurrentStepApproversAsync(workflowSteps[0], contentItem, ct);
|
||||
|
||||
return new ApprovalWorkflowStartResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<ApprovalWorkflowDecisionResult> ApplyWorkflowStepDecisionAsync(
|
||||
ApprovalRequest approval,
|
||||
ContentItem contentItem,
|
||||
Workspace workspace,
|
||||
ClaimsPrincipal user,
|
||||
ApprovalDecision decision,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!approval.WorkflowInstanceId.HasValue)
|
||||
{
|
||||
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, false);
|
||||
}
|
||||
|
||||
if (user.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return new ApprovalWorkflowDecisionResult(false, "Multi-level approval steps require an authenticated approver.", StatusCodes.Status401Unauthorized, true);
|
||||
}
|
||||
|
||||
if (!await CanApproveStepAsync(user, approval, workspace.Id, ct))
|
||||
{
|
||||
return new ApprovalWorkflowDecisionResult(false, "You cannot approve the current workflow step.", StatusCodes.Status403Forbidden, true);
|
||||
}
|
||||
|
||||
ApprovalRequest? currentStep = await GetCurrentPendingStepAsync(approval.WorkflowInstanceId.Value, ct);
|
||||
if (currentStep?.Id != approval.Id)
|
||||
{
|
||||
return new ApprovalWorkflowDecisionResult(false, "Only the current pending approval step can be approved.", StatusCodes.Status409Conflict, true);
|
||||
}
|
||||
|
||||
Guid currentUserId = user.GetUserId();
|
||||
bool alreadyApproved = await dbContext.ApprovalDecisions.AnyAsync(
|
||||
candidate => candidate.ApprovalRequestId == approval.Id &&
|
||||
candidate.DecidedByUserId == currentUserId &&
|
||||
candidate.Decision == ApprovedState,
|
||||
ct);
|
||||
|
||||
if (alreadyApproved)
|
||||
{
|
||||
return new ApprovalWorkflowDecisionResult(false, "You have already approved this workflow step.", StatusCodes.Status409Conflict, true);
|
||||
}
|
||||
|
||||
dbContext.ApprovalDecisions.Add(decision);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
int approvedCount = await dbContext.ApprovalDecisions
|
||||
.Where(candidate => candidate.ApprovalRequestId == approval.Id && candidate.Decision == ApprovedState)
|
||||
.Select(candidate => candidate.DecidedByUserId.HasValue
|
||||
? candidate.DecidedByUserId.Value.ToString()
|
||||
: candidate.DecidedByEmail.ToLower())
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
|
||||
int requiredApproverCount = approval.WorkflowStepRequiredApproverCount ?? 1;
|
||||
if (!ApprovalWorkflowRules.HasRequiredStepApprovals(approvedCount, requiredApproverCount))
|
||||
{
|
||||
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, true);
|
||||
}
|
||||
|
||||
approval.State = ApprovedState;
|
||||
approval.CompletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
ApprovalRequest? nextStep = await dbContext.ApprovalRequests
|
||||
.Where(candidate => candidate.WorkflowInstanceId == approval.WorkflowInstanceId &&
|
||||
candidate.State == PendingState &&
|
||||
candidate.Id != approval.Id)
|
||||
.OrderBy(candidate => candidate.WorkflowStepSortOrder)
|
||||
.ThenBy(candidate => candidate.SentAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (nextStep is null)
|
||||
{
|
||||
ApprovalWorkflowInstance? workflowInstance = await dbContext.ApprovalWorkflowInstances
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == approval.WorkflowInstanceId.Value, ct);
|
||||
if (workflowInstance is null)
|
||||
{
|
||||
return new ApprovalWorkflowDecisionResult(false, "The approval workflow instance could not be found.", StatusCodes.Status404NotFound, true);
|
||||
}
|
||||
|
||||
workflowInstance.State = ApprovedState;
|
||||
workflowInstance.CompletedAt = DateTimeOffset.UtcNow;
|
||||
contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
|
||||
workspace.SchedulePostsAutomaticallyOnApproval,
|
||||
contentItem.DueDate);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
if (nextStep is null)
|
||||
{
|
||||
await NotifyPublishUsersAsync(approval, contentItem, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await NotifyCurrentStepApproversAsync(nextStep, contentItem, ct);
|
||||
}
|
||||
|
||||
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, true);
|
||||
}
|
||||
|
||||
public async Task<bool> HasCompletedMultiLevelWorkflowAsync(Guid contentItemId, CancellationToken ct)
|
||||
{
|
||||
return await dbContext.ApprovalWorkflowInstances.AnyAsync(
|
||||
workflow => workflow.ContentItemId == contentItemId && workflow.State == ApprovedState,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task<ApprovalRequest?> GetCurrentPendingStepAsync(Guid workflowInstanceId, CancellationToken ct)
|
||||
{
|
||||
return await dbContext.ApprovalRequests
|
||||
.Where(candidate => candidate.WorkflowInstanceId == workflowInstanceId && candidate.State == PendingState)
|
||||
.OrderBy(candidate => candidate.WorkflowStepSortOrder)
|
||||
.ThenBy(candidate => candidate.SentAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<bool> CanApproveStepAsync(
|
||||
ClaimsPrincipal user,
|
||||
ApprovalRequest approval,
|
||||
Guid workspaceId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid userId = user.GetUserId();
|
||||
bool hasWorkspaceAccess = await UserHasWorkspaceAccessAsync(userId, workspaceId, ct);
|
||||
string[] userRoles = ApprovalStepConfigurationRules.AllowedRoleTargets
|
||||
.Where(user.IsInRole)
|
||||
.ToArray();
|
||||
|
||||
return ApprovalWorkflowRules.CanApproveWorkflowStep(
|
||||
user.IsInRole(KnownRoles.Administrator),
|
||||
hasWorkspaceAccess,
|
||||
userRoles,
|
||||
userId,
|
||||
approval.WorkflowStepTargetType,
|
||||
approval.WorkflowStepTargetValue);
|
||||
}
|
||||
|
||||
private async Task<bool> UserHasWorkspaceAccessAsync(Guid userId, Guid workspaceId, CancellationToken ct)
|
||||
{
|
||||
string workspaceClaimValue = workspaceId.ToString();
|
||||
return await dbContext.UserClaims.AnyAsync(
|
||||
claim => claim.UserId == userId &&
|
||||
claim.ClaimType == KnownClaims.WorkspaceScope &&
|
||||
claim.ClaimValue == workspaceClaimValue,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task NotifyCurrentStepApproversAsync(
|
||||
ApprovalRequest approval,
|
||||
ContentItem contentItem,
|
||||
CancellationToken ct)
|
||||
{
|
||||
List<ApprovalNotificationRecipient> recipients = await GetStepApproverRecipientsAsync(approval, ct);
|
||||
|
||||
foreach (ApprovalNotificationRecipient recipient in recipients)
|
||||
{
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
"approval.step.current",
|
||||
"ApprovalRequest",
|
||||
approval.Id,
|
||||
$"{approval.Stage} approval is ready for {contentItem.Title}.",
|
||||
recipient.UserId,
|
||||
recipient.Email,
|
||||
$$"""{"stage":"{{approval.Stage}}","requiredApproverCount":{{approval.WorkflowStepRequiredApproverCount ?? 1}}}"""),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NotifyPublishUsersAsync(
|
||||
ApprovalRequest approval,
|
||||
ContentItem contentItem,
|
||||
CancellationToken ct)
|
||||
{
|
||||
List<ApprovalNotificationRecipient> recipients = await GetPublishRecipientUsersAsync(approval.WorkspaceId, ct);
|
||||
|
||||
foreach (ApprovalNotificationRecipient recipient in recipients)
|
||||
{
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
"approval.workflow.completed",
|
||||
"ApprovalWorkflowInstance",
|
||||
approval.WorkflowInstanceId!.Value,
|
||||
$"Final approval completed for {contentItem.Title}.",
|
||||
recipient.UserId,
|
||||
recipient.Email,
|
||||
$$"""{"status":"{{contentItem.Status}}"}"""),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<ApprovalNotificationRecipient>> GetStepApproverRecipientsAsync(
|
||||
ApprovalRequest approval,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string? targetType = approval.WorkflowStepTargetType;
|
||||
string? targetValue = approval.WorkflowStepTargetValue;
|
||||
if (string.IsNullOrWhiteSpace(targetType) || string.IsNullOrWhiteSpace(targetValue))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return targetType switch
|
||||
{
|
||||
ApprovalStepTargetTypes.Member => await GetMemberRecipientsAsync(targetValue, ct),
|
||||
ApprovalStepTargetTypes.Role => await GetRoleRecipientsAsync(approval.WorkspaceId, [targetValue], ct),
|
||||
ApprovalStepTargetTypes.Membership => await GetMembershipRecipientsAsync(approval.WorkspaceId, targetValue, ct),
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<ApprovalNotificationRecipient>> GetMemberRecipientsAsync(string targetValue, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyCollection<Guid> userIds = ApprovalWorkflowRules.ParseMemberTargetIds(targetValue);
|
||||
if (userIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.Users
|
||||
.Where(user => userIds.Contains(user.Id))
|
||||
.Select(user => new ApprovalNotificationRecipient(user.Id, user.Email))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<List<ApprovalNotificationRecipient>> GetMembershipRecipientsAsync(
|
||||
Guid workspaceId,
|
||||
string targetValue,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string[] roles = targetValue switch
|
||||
{
|
||||
ApprovalMembershipTargets.Client => [KnownRoles.Client],
|
||||
ApprovalMembershipTargets.Team => [KnownRoles.Administrator, KnownRoles.Manager, KnownRoles.WorkspaceMember, KnownRoles.Provider],
|
||||
_ => [],
|
||||
};
|
||||
|
||||
return roles.Length == 0
|
||||
? []
|
||||
: await GetRoleRecipientsAsync(workspaceId, roles, ct);
|
||||
}
|
||||
|
||||
private async Task<List<ApprovalNotificationRecipient>> GetPublishRecipientUsersAsync(Guid workspaceId, CancellationToken ct)
|
||||
{
|
||||
return await GetRoleRecipientsAsync(workspaceId, [KnownRoles.Administrator, KnownRoles.Manager], ct);
|
||||
}
|
||||
|
||||
private async Task<List<ApprovalNotificationRecipient>> GetRoleRecipientsAsync(
|
||||
Guid workspaceId,
|
||||
IReadOnlyCollection<string> roles,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string workspaceClaimValue = workspaceId.ToString();
|
||||
|
||||
return await dbContext.UserRoles
|
||||
.Join(
|
||||
dbContext.Roles,
|
||||
userRole => userRole.RoleId,
|
||||
role => role.Id,
|
||||
(userRole, role) => new { userRole.UserId, RoleName = role.Name })
|
||||
.Where(candidate => candidate.RoleName != null && roles.Contains(candidate.RoleName))
|
||||
.Join(
|
||||
dbContext.UserClaims.Where(claim =>
|
||||
claim.ClaimType == KnownClaims.WorkspaceScope &&
|
||||
claim.ClaimValue == workspaceClaimValue),
|
||||
candidate => candidate.UserId,
|
||||
claim => claim.UserId,
|
||||
(candidate, _) => candidate.UserId)
|
||||
.Distinct()
|
||||
.Join(
|
||||
dbContext.Users,
|
||||
userId => userId,
|
||||
user => user.Id,
|
||||
(_, user) => new ApprovalNotificationRecipient(user.Id, user.Email))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private static string FormatStepTarget(WorkspaceApprovalStepConfiguration step)
|
||||
{
|
||||
return step.TargetType switch
|
||||
{
|
||||
ApprovalStepTargetTypes.Role => $"Role: {step.TargetValue}",
|
||||
ApprovalStepTargetTypes.Membership => $"Membership: {step.TargetValue}",
|
||||
ApprovalStepTargetTypes.Member => "Assigned members",
|
||||
_ => step.TargetValue,
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateAccessToken()
|
||||
{
|
||||
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record ApprovalNotificationRecipient(Guid UserId, string? Email);
|
||||
}
|
||||
Reference in New Issue
Block a user