402 lines
16 KiB
C#
402 lines
16 KiB
C#
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);
|
|
}
|