This commit is contained in:
2026-05-01 14:23:37 -04:00
parent 5077f557f4
commit df0409d7f6
47 changed files with 7800 additions and 194 deletions

View File

@@ -2,21 +2,52 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record UpdateApprovalStepConfigurationRequest(
string Name,
int SortOrder,
string TargetType,
string TargetValue,
int RequiredApproverCount);
public record UpdateWorkspaceRequest(
string Name,
string TimeZone);
string TimeZone,
string? ApprovalMode,
bool? SchedulePostsAutomaticallyOnApproval,
bool? LockContentAfterApproval,
bool? SendAutomaticApprovalReminders,
IReadOnlyCollection<UpdateApprovalStepConfigurationRequest>? ApprovalSteps);
public class UpdateWorkspaceRequestValidator
: Validator<UpdateWorkspaceRequest>
{
private static readonly string[] AllowedApprovalModes = ["None", "Optional", "Required", "Multi-level"];
public UpdateWorkspaceRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
RuleFor(x => x.ApprovalMode)
.Must(mode => string.IsNullOrWhiteSpace(mode) || AllowedApprovalModes.Contains(mode.Trim()))
.WithMessage("A valid approval mode should be specified.");
RuleFor(x => x.ApprovalSteps)
.Must(steps => steps is null || steps.Select(step => step.SortOrder).Distinct().Count() == steps.Count)
.WithMessage("Approval step sort orders must be unique.");
RuleForEach(x => x.ApprovalSteps).ChildRules(step =>
{
step.RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
step.RuleFor(x => x.TargetType)
.Must(ApprovalStepConfigurationRules.IsValidTargetType)
.WithMessage("A valid approval step target type should be specified.");
step.RuleFor(x => x.TargetValue).NotEmpty().MaximumLength(128);
step.RuleFor(x => x.RequiredApproverCount).GreaterThanOrEqualTo(1);
});
}
}
@@ -48,19 +79,162 @@ public class UpdateWorkspaceHandler(
return;
}
string nextApprovalMode = string.IsNullOrWhiteSpace(request.ApprovalMode)
? workspace.ApprovalMode
: request.ApprovalMode.Trim();
List<UpdateApprovalStepConfigurationRequest>? requestedApprovalSteps = request.ApprovalSteps?.ToList();
if (nextApprovalMode == ApprovalModes.MultiLevel)
{
bool hasConfiguredSteps = requestedApprovalSteps is null
? await dbContext.WorkspaceApprovalStepConfigurations.AnyAsync(step => step.WorkspaceId == workspace.Id, ct)
: requestedApprovalSteps.Count > 0;
if (!hasConfiguredSteps)
{
AddError(request => request.ApprovalSteps, "Multi-level approval requires at least one approval step.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
}
if (requestedApprovalSteps is not null &&
!await ValidateApprovalStepsAsync(workspace.Id, requestedApprovalSteps, ct))
{
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
workspace.Name = request.Name.Trim();
workspace.TimeZone = request.TimeZone.Trim();
workspace.ApprovalMode = nextApprovalMode;
workspace.SchedulePostsAutomaticallyOnApproval = request.SchedulePostsAutomaticallyOnApproval ?? workspace.SchedulePostsAutomaticallyOnApproval;
workspace.LockContentAfterApproval = request.LockContentAfterApproval ?? workspace.LockContentAfterApproval;
workspace.SendAutomaticApprovalReminders = request.SendAutomaticApprovalReminders ?? workspace.SendAutomaticApprovalReminders;
if (requestedApprovalSteps is not null)
{
List<WorkspaceApprovalStepConfiguration> existingSteps = await dbContext.WorkspaceApprovalStepConfigurations
.Where(step => step.WorkspaceId == workspace.Id)
.ToListAsync(ct);
dbContext.WorkspaceApprovalStepConfigurations.RemoveRange(existingSteps);
List<WorkspaceApprovalStepConfiguration> replacementSteps = requestedApprovalSteps
.OrderBy(step => step.SortOrder)
.Select(step => new WorkspaceApprovalStepConfiguration
{
Id = Guid.NewGuid(),
WorkspaceId = workspace.Id,
Name = step.Name.Trim(),
SortOrder = step.SortOrder,
TargetType = step.TargetType.Trim(),
TargetValue = NormalizeTargetValue(step),
RequiredApproverCount = step.RequiredApproverCount,
CreatedAt = DateTimeOffset.UtcNow,
})
.ToList();
dbContext.WorkspaceApprovalStepConfigurations.AddRange(replacementSteps);
}
await dbContext.SaveChangesAsync(ct);
List<ApprovalStepConfigurationDto> approvalSteps = await dbContext.WorkspaceApprovalStepConfigurations
.Where(step => step.WorkspaceId == workspace.Id)
.OrderBy(step => step.SortOrder)
.ThenBy(step => step.Name)
.Select(step => new ApprovalStepConfigurationDto(
step.Id,
step.WorkspaceId,
step.Name,
step.SortOrder,
step.TargetType,
step.TargetValue,
step.RequiredApproverCount,
step.CreatedAt))
.ToListAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalSteps,
workspace.CreatedAt);
await SendOkAsync(dto, ct);
}
private async Task<bool> ValidateApprovalStepsAsync(
Guid workspaceId,
IReadOnlyCollection<UpdateApprovalStepConfigurationRequest> steps,
CancellationToken ct)
{
foreach (UpdateApprovalStepConfigurationRequest step in steps)
{
string targetType = step.TargetType.Trim();
string targetValue = step.TargetValue.Trim();
if (targetType == ApprovalStepTargetTypes.Role &&
!ApprovalStepConfigurationRules.IsValidRoleTarget(targetValue))
{
AddError(request => request.ApprovalSteps, $"'{targetValue}' is not a supported approval role target.");
return false;
}
if (targetType == ApprovalStepTargetTypes.Membership &&
!ApprovalStepConfigurationRules.IsValidMembershipTarget(targetValue))
{
AddError(request => request.ApprovalSteps, $"'{targetValue}' is not a supported approval membership target.");
return false;
}
if (targetType == ApprovalStepTargetTypes.Member)
{
IReadOnlyCollection<Guid> memberUserIds = ApprovalWorkflowRules.ParseMemberTargetIds(targetValue);
if (memberUserIds.Count == 0)
{
AddError(request => request.ApprovalSteps, "Member approval step targets must reference at least one valid user id.");
return false;
}
if (memberUserIds.Count < step.RequiredApproverCount)
{
AddError(request => request.ApprovalSteps, "Member approval step targets must include at least as many members as required approvers.");
return false;
}
string workspaceClaimValue = workspaceId.ToString();
int workspaceMemberCount = await dbContext.UserClaims
.Where(claim => memberUserIds.Contains(claim.UserId) &&
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue)
.Select(claim => claim.UserId)
.Distinct()
.CountAsync(ct);
if (workspaceMemberCount != memberUserIds.Count)
{
AddError(request => request.ApprovalSteps, "Member approval step targets must reference users with access to the workspace.");
return false;
}
}
}
return true;
}
private static string NormalizeTargetValue(UpdateApprovalStepConfigurationRequest step)
{
string targetValue = step.TargetValue.Trim();
return step.TargetType.Trim() == ApprovalStepTargetTypes.Member
? ApprovalWorkflowRules.FormatMemberTargetValue(ApprovalWorkflowRules.ParseMemberTargetIds(targetValue))
: targetValue;
}
}