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? ApprovalSteps); public class UpdateWorkspaceRequestValidator : Validator { 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 { 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("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? 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 existingSteps = await dbContext.WorkspaceApprovalStepConfigurations .Where(step => step.WorkspaceId == workspace.Id) .ToListAsync(ct); dbContext.WorkspaceApprovalStepConfigurations.RemoveRange(existingSteps); List 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 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 ValidateApprovalStepsAsync( Guid workspaceId, IReadOnlyCollection 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 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; } }