230 lines
9.4 KiB
C#
230 lines
9.4 KiB
C#
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? 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);
|
|
});
|
|
}
|
|
}
|
|
|
|
public class UpdateWorkspaceHandler(
|
|
AppDbContext dbContext,
|
|
AccessScopeService accessScopeService)
|
|
: Endpoint<UpdateWorkspaceRequest, WorkspaceDto>
|
|
{
|
|
public override void Configure()
|
|
{
|
|
Put("/api/workspaces/{id}");
|
|
Options(o => o.WithTags("Workspaces"));
|
|
}
|
|
|
|
public override async Task HandleAsync(UpdateWorkspaceRequest request, CancellationToken ct)
|
|
{
|
|
Guid id = Route<Guid>("id");
|
|
|
|
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
|
if (workspace is null)
|
|
{
|
|
await SendNotFoundAsync(ct);
|
|
return;
|
|
}
|
|
|
|
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct))
|
|
{
|
|
await SendForbiddenAsync(ct);
|
|
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 = WorkspaceDto.FromWorkspace(workspace, approvalSteps);
|
|
|
|
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;
|
|
}
|
|
}
|