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 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 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 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 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 HasCompletedMultiLevelWorkflowAsync(Guid contentItemId, CancellationToken ct) { return await dbContext.ApprovalWorkflowInstances.AnyAsync( workflow => workflow.ContentItemId == contentItemId && workflow.State == ApprovedState, ct); } private async Task 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 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 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 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 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> 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> GetMemberRecipientsAsync(string targetValue, CancellationToken ct) { IReadOnlyCollection 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> 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> GetPublishRecipientUsersAsync(Guid workspaceId, CancellationToken ct) { return await GetRoleRecipientsAsync(workspaceId, [KnownRoles.Administrator, KnownRoles.Manager], ct); } private async Task> GetRoleRecipientsAsync( Guid workspaceId, IReadOnlyCollection 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); }