chore: add missing multi-level editor for approval workflow, rename projects to campaings.
This commit is contained in:
@@ -6,10 +6,28 @@ public static class ApprovalModelConfiguration
|
||||
{
|
||||
public static ModelBuilder ConfigureApprovalsModule(this ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ApprovalWorkflowInstance>(workflowInstance =>
|
||||
{
|
||||
workflowInstance.ToTable("ApprovalWorkflowInstances");
|
||||
workflowInstance.HasKey(x => x.Id);
|
||||
workflowInstance.Property(x => x.State).HasMaxLength(64).IsRequired();
|
||||
workflowInstance.Property(x => x.ApprovalMode).HasMaxLength(64).IsRequired();
|
||||
workflowInstance.Property(x => x.StartedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
workflowInstance.HasIndex(x => x.WorkspaceId);
|
||||
workflowInstance.HasIndex(x => x.ContentItemId);
|
||||
workflowInstance.HasIndex(x => new { x.ContentItemId, x.State })
|
||||
.IsUnique()
|
||||
.HasFilter("\"State\" = 'Pending'");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ApprovalRequest>(approvalRequest =>
|
||||
{
|
||||
approvalRequest.ToTable("ApprovalRequests");
|
||||
approvalRequest.HasKey(x => x.Id);
|
||||
approvalRequest.Property(x => x.WorkflowStepTargetType).HasMaxLength(32);
|
||||
approvalRequest.Property(x => x.WorkflowStepTargetValue).HasMaxLength(128);
|
||||
approvalRequest.Property(x => x.Stage).HasMaxLength(64).IsRequired();
|
||||
approvalRequest.Property(x => x.ReviewerName).HasMaxLength(256).IsRequired();
|
||||
approvalRequest.Property(x => x.ReviewerEmail).HasMaxLength(256).IsRequired();
|
||||
@@ -20,6 +38,7 @@ public static class ApprovalModelConfiguration
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
approvalRequest.HasIndex(x => x.WorkspaceId);
|
||||
approvalRequest.HasIndex(x => x.ContentItemId);
|
||||
approvalRequest.HasIndex(x => x.WorkflowInstanceId);
|
||||
approvalRequest.HasIndex(x => x.ReviewerEmail);
|
||||
});
|
||||
|
||||
@@ -37,6 +56,21 @@ public static class ApprovalModelConfiguration
|
||||
approvalDecision.HasIndex(x => x.ApprovalRequestId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<WorkspaceApprovalStepConfiguration>(approvalStep =>
|
||||
{
|
||||
approvalStep.ToTable("WorkspaceApprovalStepConfigurations");
|
||||
approvalStep.HasKey(x => x.Id);
|
||||
approvalStep.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||
approvalStep.Property(x => x.TargetType).HasMaxLength(32).IsRequired();
|
||||
approvalStep.Property(x => x.TargetValue).HasMaxLength(128).IsRequired();
|
||||
approvalStep.Property(x => x.RequiredApproverCount).HasDefaultValue(1);
|
||||
approvalStep.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
approvalStep.HasIndex(x => x.WorkspaceId);
|
||||
approvalStep.HasIndex(x => new { x.WorkspaceId, x.SortOrder }).IsUnique();
|
||||
});
|
||||
|
||||
return modelBuilder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ public class ApprovalRequest
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid ContentItemId { get; set; }
|
||||
public Guid? WorkflowInstanceId { get; set; }
|
||||
public int? WorkflowStepSortOrder { get; set; }
|
||||
public string? WorkflowStepTargetType { get; set; }
|
||||
public string? WorkflowStepTargetValue { get; set; }
|
||||
public int? WorkflowStepRequiredApproverCount { get; set; }
|
||||
public required string Stage { get; set; }
|
||||
public required string ReviewerName { get; set; }
|
||||
public required string ReviewerEmail { get; set; }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Socialize.Api.Modules.Approvals.Data;
|
||||
|
||||
public class ApprovalWorkflowInstance
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid ContentItemId { get; set; }
|
||||
public required string State { get; set; }
|
||||
public required string ApprovalMode { get; set; }
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Socialize.Api.Modules.Approvals.Data;
|
||||
|
||||
public class WorkspaceApprovalStepConfiguration
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public required string TargetType { get; set; }
|
||||
public required string TargetValue { get; set; }
|
||||
public int RequiredApproverCount { get; set; } = 1;
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Socialize.Api.Modules.Approvals.Data;
|
||||
using Socialize.Api.Modules.Approvals.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Approvals;
|
||||
|
||||
@@ -7,6 +7,8 @@ public static class DependencyInjection
|
||||
public static WebApplicationBuilder AddApprovalsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddScoped<ApprovalWorkflowRuntimeService>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ using System.Security.Cryptography;
|
||||
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.Notifications.Contracts;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Approvals.Handlers;
|
||||
|
||||
@@ -62,6 +64,22 @@ public class CreateApprovalRequestHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
|
||||
if (workspace is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ApprovalWorkflowRules.CanCreateSingleStepApprovalRequest(workspace.ApprovalMode))
|
||||
{
|
||||
AddError(request => request.WorkspaceId, workspace.ApprovalMode == ApprovalModes.None
|
||||
? "Approval workflow is disabled for this workspace."
|
||||
: "Move content to In approval to start the configured multi-level approval workflow.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var approval = new ApprovalRequest()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -79,14 +97,7 @@ public class CreateApprovalRequestHandler(
|
||||
|
||||
dbContext.ApprovalRequests.Add(approval);
|
||||
|
||||
if (approval.Stage == "Internal")
|
||||
{
|
||||
contentItem.Status = "In internal review";
|
||||
}
|
||||
else if (approval.Stage == "Client")
|
||||
{
|
||||
contentItem.Status = "In client review";
|
||||
}
|
||||
contentItem.Status = "In approval";
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
@@ -107,6 +118,11 @@ public class CreateApprovalRequestHandler(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.WorkflowInstanceId,
|
||||
approval.WorkflowStepSortOrder,
|
||||
approval.WorkflowStepTargetType,
|
||||
approval.WorkflowStepTargetValue,
|
||||
approval.WorkflowStepRequiredApproverCount,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
|
||||
@@ -24,6 +24,11 @@ public record ApprovalRequestDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
Guid? WorkflowInstanceId,
|
||||
int? WorkflowStepSortOrder,
|
||||
string? WorkflowStepTargetType,
|
||||
string? WorkflowStepTargetValue,
|
||||
int? WorkflowStepRequiredApproverCount,
|
||||
string Stage,
|
||||
string ReviewerName,
|
||||
string ReviewerEmail,
|
||||
@@ -56,7 +61,7 @@ public class GetApprovalsHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
@@ -65,6 +70,7 @@ public class GetApprovalsHandler(
|
||||
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
|
||||
.Where(approval => approval.ContentItemId == request.ContentItemId)
|
||||
.OrderByDescending(approval => approval.SentAt)
|
||||
.ThenBy(approval => approval.WorkflowStepSortOrder)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<Guid> approvalIds = approvals
|
||||
@@ -91,6 +97,11 @@ public class GetApprovalsHandler(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.WorkflowInstanceId,
|
||||
approval.WorkflowStepSortOrder,
|
||||
approval.WorkflowStepTargetType,
|
||||
approval.WorkflowStepTargetValue,
|
||||
approval.WorkflowStepRequiredApproverCount,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
|
||||
@@ -4,7 +4,9 @@ using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.ContentItems.Data;
|
||||
using Socialize.Api.Modules.Approvals.Data;
|
||||
using Socialize.Api.Modules.Approvals.Services;
|
||||
using Socialize.Api.Modules.Notifications.Contracts;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Approvals.Handlers;
|
||||
|
||||
@@ -19,7 +21,10 @@ public class SubmitApprovalDecisionRequestValidator
|
||||
{
|
||||
public SubmitApprovalDecisionRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Decision).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.Decision)
|
||||
.NotEmpty()
|
||||
.Equal("Approved")
|
||||
.WithMessage("Only approved decisions are supported.");
|
||||
RuleFor(x => x.Comment).MaximumLength(2048);
|
||||
RuleFor(x => x.ReviewerName).MaximumLength(256);
|
||||
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
|
||||
@@ -29,6 +34,7 @@ public class SubmitApprovalDecisionRequestValidator
|
||||
public class SubmitApprovalDecisionHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
||||
{
|
||||
@@ -58,12 +64,19 @@ public class SubmitApprovalDecisionHandler(
|
||||
}
|
||||
|
||||
if (User?.Identity?.IsAuthenticated == true &&
|
||||
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
|
||||
if (workspace is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedDecision = request.Decision.Trim();
|
||||
string decidedByName = User?.Identity?.IsAuthenticated == true
|
||||
? User.GetAlias() ?? User.GetName()
|
||||
@@ -84,45 +97,44 @@ public class SubmitApprovalDecisionHandler(
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
approval.State = normalizedDecision;
|
||||
approval.CompletedAt = DateTimeOffset.UtcNow;
|
||||
ApprovalWorkflowDecisionResult workflowDecisionResult = await approvalWorkflowRuntimeService
|
||||
.ApplyWorkflowStepDecisionAsync(approval, contentItem, workspace, User!, decision, ct);
|
||||
|
||||
if (approval.Stage == "Internal")
|
||||
if (!workflowDecisionResult.Succeeded)
|
||||
{
|
||||
contentItem.Status = normalizedDecision switch
|
||||
{
|
||||
"Approved" => "Ready for client review",
|
||||
"Changes requested" => "Changes requested internally",
|
||||
"Rejected" => "Rejected",
|
||||
_ => contentItem.Status,
|
||||
};
|
||||
}
|
||||
else if (approval.Stage == "Client")
|
||||
{
|
||||
contentItem.Status = normalizedDecision switch
|
||||
{
|
||||
"Approved" => "Approved",
|
||||
"Changes requested" => "Changes requested by client",
|
||||
"Rejected" => "Rejected",
|
||||
_ => contentItem.Status,
|
||||
};
|
||||
AddError(request => request.Decision, workflowDecisionResult.ErrorMessage ?? "The approval decision could not be recorded.");
|
||||
await SendErrorsAsync(workflowDecisionResult.StatusCode, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
dbContext.ApprovalDecisions.Add(decision);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
if (!workflowDecisionResult.IsWorkflowStep)
|
||||
{
|
||||
approval.State = normalizedDecision;
|
||||
approval.CompletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
"approval.decision.recorded",
|
||||
"ApprovalDecision",
|
||||
decision.Id,
|
||||
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
|
||||
null,
|
||||
decidedByEmail,
|
||||
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
|
||||
ct);
|
||||
if (normalizedDecision == "Approved")
|
||||
{
|
||||
contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
|
||||
workspace.SchedulePostsAutomaticallyOnApproval,
|
||||
contentItem.DueDate);
|
||||
}
|
||||
|
||||
dbContext.ApprovalDecisions.Add(decision);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
"approval.decision.recorded",
|
||||
"ApprovalDecision",
|
||||
decision.Id,
|
||||
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
|
||||
null,
|
||||
decidedByEmail,
|
||||
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
|
||||
ct);
|
||||
}
|
||||
|
||||
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
||||
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
|
||||
@@ -158,6 +170,11 @@ public class SubmitApprovalDecisionHandler(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.WorkflowInstanceId,
|
||||
approval.WorkflowStepSortOrder,
|
||||
approval.WorkflowStepTargetType,
|
||||
approval.WorkflowStepTargetValue,
|
||||
approval.WorkflowStepRequiredApproverCount,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Approvals.Services;
|
||||
|
||||
public static class ApprovalStepTargetTypes
|
||||
{
|
||||
public const string Role = "Role";
|
||||
public const string Membership = "Membership";
|
||||
public const string Member = "Member";
|
||||
}
|
||||
|
||||
public static class ApprovalMembershipTargets
|
||||
{
|
||||
public const string Team = "Team";
|
||||
public const string Client = "Client";
|
||||
}
|
||||
|
||||
public static class ApprovalStepConfigurationRules
|
||||
{
|
||||
public static readonly IReadOnlySet<string> AllowedTargetTypes = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
ApprovalStepTargetTypes.Role,
|
||||
ApprovalStepTargetTypes.Membership,
|
||||
ApprovalStepTargetTypes.Member,
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> AllowedRoleTargets = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
KnownRoles.Administrator,
|
||||
KnownRoles.Manager,
|
||||
KnownRoles.WorkspaceMember,
|
||||
KnownRoles.Client,
|
||||
KnownRoles.Provider,
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> AllowedMembershipTargets = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
ApprovalMembershipTargets.Team,
|
||||
ApprovalMembershipTargets.Client,
|
||||
};
|
||||
|
||||
public static bool IsValidTargetType(string? targetType)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(targetType) && AllowedTargetTypes.Contains(targetType.Trim());
|
||||
}
|
||||
|
||||
public static bool IsValidRoleTarget(string? targetValue)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(targetValue) && AllowedRoleTargets.Contains(targetValue.Trim());
|
||||
}
|
||||
|
||||
public static bool IsValidMembershipTarget(string? targetValue)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(targetValue) && AllowedMembershipTargets.Contains(targetValue.Trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Approvals.Services;
|
||||
|
||||
public static class ApprovalModes
|
||||
{
|
||||
public const string None = "None";
|
||||
public const string Optional = "Optional";
|
||||
public const string Required = "Required";
|
||||
public const string MultiLevel = "Multi-level";
|
||||
}
|
||||
|
||||
public static class ApprovalWorkflowRules
|
||||
{
|
||||
public static bool CanCreateSingleStepApprovalRequest(string approvalMode)
|
||||
{
|
||||
return approvalMode is ApprovalModes.Optional or ApprovalModes.Required;
|
||||
}
|
||||
|
||||
public static bool BlocksManualApprovedOrScheduledStatus(string approvalMode)
|
||||
{
|
||||
return approvalMode is ApprovalModes.Required or ApprovalModes.MultiLevel;
|
||||
}
|
||||
|
||||
public static bool IsApprovalCompletionStatus(string status)
|
||||
{
|
||||
return status is "Approved" or "Scheduled";
|
||||
}
|
||||
|
||||
public static string GetFinalApprovalStatus(bool schedulePostsAutomaticallyOnApproval, DateTimeOffset? plannedPublishDate)
|
||||
{
|
||||
return schedulePostsAutomaticallyOnApproval && plannedPublishDate.HasValue
|
||||
? "Scheduled"
|
||||
: "Approved";
|
||||
}
|
||||
|
||||
public static bool HasRequiredStepApprovals(int approvedDecisionCount, int requiredApproverCount)
|
||||
{
|
||||
return approvedDecisionCount >= Math.Max(1, requiredApproverCount);
|
||||
}
|
||||
|
||||
public static bool CanApproveWorkflowStep(
|
||||
bool isAdministrator,
|
||||
bool hasWorkspaceAccess,
|
||||
IReadOnlyCollection<string> userRoles,
|
||||
Guid userId,
|
||||
string? targetType,
|
||||
string? targetValue)
|
||||
{
|
||||
if (isAdministrator)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!hasWorkspaceAccess ||
|
||||
string.IsNullOrWhiteSpace(targetType) ||
|
||||
string.IsNullOrWhiteSpace(targetValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return targetType switch
|
||||
{
|
||||
ApprovalStepTargetTypes.Role => userRoles.Contains(targetValue),
|
||||
ApprovalStepTargetTypes.Membership => MatchesMembershipTarget(userRoles, targetValue),
|
||||
ApprovalStepTargetTypes.Member => ParseMemberTargetIds(targetValue).Contains(userId),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<Guid> ParseMemberTargetIds(string? targetValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetValue))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return targetValue
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(value => Guid.TryParse(value, out Guid memberUserId) ? memberUserId : Guid.Empty)
|
||||
.Where(memberUserId => memberUserId != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static string FormatMemberTargetValue(IEnumerable<Guid> memberUserIds)
|
||||
{
|
||||
return string.Join(",", memberUserIds.Distinct().OrderBy(memberUserId => memberUserId));
|
||||
}
|
||||
|
||||
private static bool MatchesMembershipTarget(
|
||||
IReadOnlyCollection<string> userRoles,
|
||||
string targetValue)
|
||||
{
|
||||
return targetValue switch
|
||||
{
|
||||
ApprovalMembershipTargets.Client => userRoles.Contains(KnownRoles.Client),
|
||||
ApprovalMembershipTargets.Team => !userRoles.Contains(KnownRoles.Client),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
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);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ public class CreateAssetRevisionHandler(
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
|
||||
|
||||
if (contentItem is not null &&
|
||||
!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
!accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
|
||||
@@ -58,7 +58,7 @@ public class CreateGoogleDriveAssetHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
if (!accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
|
||||
@@ -52,7 +52,7 @@ public class GetAssetsHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Socialize.Api.Modules.Projects.Data;
|
||||
namespace Socialize.Api.Modules.Campaigns.Data;
|
||||
|
||||
public class Project
|
||||
public class Campaign
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Socialize.Api.Modules.Campaigns.Data;
|
||||
|
||||
public static class CampaignModelConfiguration
|
||||
{
|
||||
public static ModelBuilder ConfigureCampaignsModule(this ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Campaign>(campaign =>
|
||||
{
|
||||
campaign.ToTable("Campaigns");
|
||||
campaign.HasKey(x => x.Id);
|
||||
campaign.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||
campaign.Property(x => x.Description).HasMaxLength(4000);
|
||||
campaign.Property(x => x.Notes).HasMaxLength(4000);
|
||||
campaign.Property(x => x.Status).HasMaxLength(64).IsRequired();
|
||||
campaign.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
campaign.HasIndex(x => new { x.ClientId, x.Name }).IsUnique();
|
||||
campaign.HasIndex(x => x.WorkspaceId);
|
||||
campaign.HasIndex(x => x.ClientId);
|
||||
});
|
||||
|
||||
return modelBuilder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Socialize.Api.Modules.Campaigns.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Campaigns;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddCampaignsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@ using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Projects.Data;
|
||||
using Socialize.Api.Modules.Campaigns.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Projects.Handlers;
|
||||
namespace Socialize.Api.Modules.Campaigns.Handlers;
|
||||
|
||||
public record CreateProjectRequest(
|
||||
public record CreateCampaignRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
string Name,
|
||||
@@ -15,10 +15,10 @@ public record CreateProjectRequest(
|
||||
string? Description,
|
||||
string? Notes);
|
||||
|
||||
public class CreateProjectRequestValidator
|
||||
: Validator<CreateProjectRequest>
|
||||
public class CreateCampaignRequestValidator
|
||||
: Validator<CreateCampaignRequest>
|
||||
{
|
||||
public CreateProjectRequestValidator()
|
||||
public CreateCampaignRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ClientId).NotEmpty();
|
||||
@@ -32,18 +32,18 @@ public class CreateProjectRequestValidator
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateProjectHandler(
|
||||
public class CreateCampaignHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<CreateProjectRequest, ProjectDto>
|
||||
: Endpoint<CreateCampaignRequest, CampaignDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/projects");
|
||||
Options(o => o.WithTags("Projects"));
|
||||
Post("/api/campaigns");
|
||||
Options(o => o.WithTags("Campaigns"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateProjectRequest request, CancellationToken ct)
|
||||
public override async Task HandleAsync(CreateCampaignRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId))
|
||||
{
|
||||
@@ -75,19 +75,19 @@ public class CreateProjectHandler(
|
||||
|
||||
string normalizedName = request.Name.Trim();
|
||||
|
||||
bool duplicateProject = await dbContext.Projects
|
||||
bool duplicateCampaign = await dbContext.Campaigns
|
||||
.AnyAsync(
|
||||
project => project.ClientId == request.ClientId && project.Name == normalizedName,
|
||||
campaign => campaign.ClientId == request.ClientId && campaign.Name == normalizedName,
|
||||
ct);
|
||||
|
||||
if (duplicateProject)
|
||||
if (duplicateCampaign)
|
||||
{
|
||||
AddError(request => request.Name, "A project with this name already exists for the selected client.");
|
||||
AddError(request => request.Name, "A campaign with this name already exists for the selected client.");
|
||||
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Project project = new()
|
||||
Campaign campaign = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
@@ -101,19 +101,19 @@ public class CreateProjectHandler(
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.Projects.Add(project);
|
||||
dbContext.Campaigns.Add(campaign);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
ProjectDto dto = new(
|
||||
project.Id,
|
||||
project.WorkspaceId,
|
||||
project.ClientId,
|
||||
project.Name,
|
||||
project.Description,
|
||||
project.Notes,
|
||||
project.Status,
|
||||
project.StartDate,
|
||||
project.EndDate);
|
||||
CampaignDto dto = new(
|
||||
campaign.Id,
|
||||
campaign.WorkspaceId,
|
||||
campaign.ClientId,
|
||||
campaign.Name,
|
||||
campaign.Description,
|
||||
campaign.Notes,
|
||||
campaign.Status,
|
||||
campaign.StartDate,
|
||||
campaign.EndDate);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Campaigns.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Campaigns.Handlers;
|
||||
|
||||
public record GetCampaignsRequest(Guid? WorkspaceId, Guid? ClientId);
|
||||
|
||||
public record CampaignDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? Notes,
|
||||
string Status,
|
||||
DateTimeOffset StartDate,
|
||||
DateTimeOffset EndDate);
|
||||
|
||||
public class GetCampaignsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetCampaignsRequest, IReadOnlyCollection<CampaignDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/campaigns");
|
||||
Options(o => o.WithTags("Campaigns"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetCampaignsRequest request, CancellationToken ct)
|
||||
{
|
||||
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
|
||||
|
||||
if (accessScopeService.IsManager(User))
|
||||
{
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(campaign => campaign.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
||||
|
||||
query = query.Where(campaign => workspaceScopeIds.Contains(campaign.WorkspaceId));
|
||||
|
||||
if (clientScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(campaign => clientScopeIds.Contains(campaign.ClientId));
|
||||
}
|
||||
|
||||
if (campaignScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(campaign => campaignScopeIds.Contains(campaign.Id));
|
||||
}
|
||||
}
|
||||
|
||||
if (request.ClientId.HasValue)
|
||||
{
|
||||
query = query.Where(campaign => campaign.ClientId == request.ClientId.Value);
|
||||
}
|
||||
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(campaign => campaign.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
|
||||
List<CampaignDto> campaigns = await query
|
||||
.OrderBy(campaign => campaign.Name)
|
||||
.Select(campaign => new CampaignDto(
|
||||
campaign.Id,
|
||||
campaign.WorkspaceId,
|
||||
campaign.ClientId,
|
||||
campaign.Name,
|
||||
campaign.Description,
|
||||
campaign.Notes,
|
||||
campaign.Status,
|
||||
campaign.StartDate,
|
||||
campaign.EndDate))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(campaigns, ct);
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ public class CreateCommentHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
|
||||
@@ -44,7 +44,7 @@ public class GetCommentsHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
|
||||
@@ -40,7 +40,7 @@ public class ResolveCommentHandler(
|
||||
}
|
||||
|
||||
bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId)
|
||||
|| accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId);
|
||||
|| accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId);
|
||||
|
||||
if (!canResolve)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ public class ContentItem
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid ClientId { get; set; }
|
||||
public Guid ProjectId { get; set; }
|
||||
public Guid CampaignId { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string PublicationMessage { get; set; }
|
||||
public required string PublicationTargets { get; set; }
|
||||
|
||||
@@ -21,7 +21,7 @@ public static class ContentItemModelConfiguration
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
contentItem.HasIndex(x => x.WorkspaceId);
|
||||
contentItem.HasIndex(x => x.ClientId);
|
||||
contentItem.HasIndex(x => x.ProjectId);
|
||||
contentItem.HasIndex(x => x.CampaignId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ContentItemRevision>(revision =>
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Socialize.Api.Modules.ContentItems.Handlers;
|
||||
public record CreateContentItemRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
Guid ProjectId,
|
||||
Guid CampaignId,
|
||||
string Title,
|
||||
string PublicationMessage,
|
||||
string PublicationTargets,
|
||||
@@ -25,7 +25,7 @@ public class CreateContentItemRequestValidator
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ClientId).NotEmpty();
|
||||
RuleFor(x => x.ProjectId).NotEmpty();
|
||||
RuleFor(x => x.CampaignId).NotEmpty();
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000);
|
||||
RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512);
|
||||
@@ -47,7 +47,7 @@ public class CreateContentItemHandler(
|
||||
|
||||
public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!accessScopeService.CanContributeToProject(User, request.WorkspaceId, request.ClientId, request.ProjectId))
|
||||
if (!accessScopeService.CanContributeToCampaign(User, request.WorkspaceId, request.ClientId, request.CampaignId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
@@ -75,16 +75,16 @@ public class CreateContentItemHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
bool projectExists = await dbContext.Projects
|
||||
bool campaignExists = await dbContext.Campaigns
|
||||
.AnyAsync(
|
||||
project => project.Id == request.ProjectId &&
|
||||
project.WorkspaceId == request.WorkspaceId &&
|
||||
project.ClientId == request.ClientId,
|
||||
campaign => campaign.Id == request.CampaignId &&
|
||||
campaign.WorkspaceId == request.WorkspaceId &&
|
||||
campaign.ClientId == request.ClientId,
|
||||
ct);
|
||||
|
||||
if (!projectExists)
|
||||
if (!campaignExists)
|
||||
{
|
||||
AddError(request => request.ProjectId, "The selected project does not belong to the selected client.");
|
||||
AddError(request => request.CampaignId, "The selected campaign does not belong to the selected client.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ public class CreateContentItemHandler(
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
ClientId = request.ClientId,
|
||||
ProjectId = request.ProjectId,
|
||||
CampaignId = request.CampaignId,
|
||||
Title = request.Title.Trim(),
|
||||
PublicationMessage = request.PublicationMessage.Trim(),
|
||||
PublicationTargets = request.PublicationTargets.Trim(),
|
||||
@@ -138,7 +138,7 @@ public class CreateContentItemHandler(
|
||||
item.Id,
|
||||
item.WorkspaceId,
|
||||
item.ClientId,
|
||||
item.ProjectId,
|
||||
item.CampaignId,
|
||||
item.Title,
|
||||
item.PublicationMessage,
|
||||
item.PublicationTargets,
|
||||
|
||||
@@ -50,7 +50,7 @@ public class CreateContentItemRevisionHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanContributeToProject(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
if (!accessScopeService.CanContributeToCampaign(User, item.WorkspaceId, item.ClientId, item.CampaignId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
@@ -66,15 +66,6 @@ public class CreateContentItemRevisionHandler(
|
||||
item.CurrentRevisionNumber = revisionNumber;
|
||||
item.CurrentRevisionLabel = revisionLabel;
|
||||
|
||||
if (item.Status == "Changes requested internally")
|
||||
{
|
||||
item.Status = "Internal changes in progress";
|
||||
}
|
||||
else if (item.Status == "Changes requested by client")
|
||||
{
|
||||
item.Status = "Client changes in progress";
|
||||
}
|
||||
|
||||
ContentItemRevision revision = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
|
||||
@@ -10,7 +10,7 @@ public record ContentItemDetailDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
Guid ProjectId,
|
||||
Guid CampaignId,
|
||||
string Title,
|
||||
string PublicationMessage,
|
||||
string PublicationTargets,
|
||||
@@ -42,7 +42,7 @@ public class GetContentItemHandler(
|
||||
candidate.Id,
|
||||
candidate.WorkspaceId,
|
||||
candidate.ClientId,
|
||||
candidate.ProjectId,
|
||||
candidate.CampaignId,
|
||||
candidate.Title,
|
||||
candidate.PublicationMessage,
|
||||
candidate.PublicationTargets,
|
||||
@@ -60,7 +60,7 @@ public class GetContentItemHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
|
||||
@@ -41,7 +41,7 @@ public class GetContentItemRevisionsHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
|
||||
@@ -6,13 +6,13 @@ using Socialize.Api.Modules.ContentItems.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.ContentItems.Handlers;
|
||||
|
||||
public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? ProjectId);
|
||||
public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? CampaignId);
|
||||
|
||||
public record ContentItemDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
Guid ProjectId,
|
||||
Guid CampaignId,
|
||||
string Title,
|
||||
string PublicationMessage,
|
||||
string PublicationTargets,
|
||||
@@ -41,7 +41,7 @@ public class GetContentItemsHandler(
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||
IReadOnlyCollection<Guid> projectScopeIds = User.GetProjectScopeIds();
|
||||
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
||||
|
||||
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
|
||||
|
||||
@@ -50,9 +50,9 @@ public class GetContentItemsHandler(
|
||||
query = query.Where(item => clientScopeIds.Contains(item.ClientId));
|
||||
}
|
||||
|
||||
if (projectScopeIds.Count > 0)
|
||||
if (campaignScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(item => projectScopeIds.Contains(item.ProjectId));
|
||||
query = query.Where(item => campaignScopeIds.Contains(item.CampaignId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +61,9 @@ public class GetContentItemsHandler(
|
||||
query = query.Where(item => item.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
|
||||
if (request.ProjectId.HasValue)
|
||||
if (request.CampaignId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ProjectId == request.ProjectId.Value);
|
||||
query = query.Where(item => item.CampaignId == request.CampaignId.Value);
|
||||
}
|
||||
|
||||
if (request.ClientId.HasValue)
|
||||
@@ -78,7 +78,7 @@ public class GetContentItemsHandler(
|
||||
item.Id,
|
||||
item.WorkspaceId,
|
||||
item.ClientId,
|
||||
item.ProjectId,
|
||||
item.CampaignId,
|
||||
item.Title,
|
||||
item.PublicationMessage,
|
||||
item.PublicationTargets,
|
||||
|
||||
@@ -2,8 +2,10 @@ using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Approvals.Services;
|
||||
using Socialize.Api.Modules.ContentItems.Data;
|
||||
using Socialize.Api.Modules.Notifications.Contracts;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.ContentItems.Handlers;
|
||||
|
||||
@@ -21,24 +23,18 @@ public class UpdateContentItemStatusRequestValidator
|
||||
public class UpdateContentItemStatusHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<UpdateContentItemStatusRequest, ContentItemDetailDto>
|
||||
{
|
||||
private static readonly HashSet<string> AllowedStatuses =
|
||||
[
|
||||
"Draft",
|
||||
"In internal review",
|
||||
"Changes requested internally",
|
||||
"Internal changes in progress",
|
||||
"Ready for client review",
|
||||
"In client review",
|
||||
"Changes requested by client",
|
||||
"Client changes in progress",
|
||||
"In production",
|
||||
"In approval",
|
||||
"Approved",
|
||||
"Rejected",
|
||||
"Ready to publish",
|
||||
"Scheduled",
|
||||
"Published",
|
||||
"Archived",
|
||||
];
|
||||
|
||||
public override void Configure()
|
||||
@@ -72,7 +68,64 @@ public class UpdateContentItemStatusHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
item.Status = normalizedStatus;
|
||||
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == item.WorkspaceId, ct);
|
||||
if (workspace is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedStatus == "In approval" && workspace.ApprovalMode == ApprovalModes.MultiLevel)
|
||||
{
|
||||
ApprovalWorkflowStartResult startResult = await approvalWorkflowRuntimeService.StartMultiLevelWorkflowAsync(
|
||||
item,
|
||||
workspace,
|
||||
User.GetUserId(),
|
||||
ct);
|
||||
|
||||
if (!startResult.Succeeded)
|
||||
{
|
||||
AddError(request => request.Status, startResult.ErrorMessage ?? "The approval workflow could not be started.");
|
||||
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (ApprovalWorkflowRules.IsApprovalCompletionStatus(normalizedStatus) &&
|
||||
ApprovalWorkflowRules.BlocksManualApprovedOrScheduledStatus(workspace.ApprovalMode))
|
||||
{
|
||||
if (workspace.ApprovalMode == ApprovalModes.MultiLevel)
|
||||
{
|
||||
bool hasCompletedWorkflow = await approvalWorkflowRuntimeService.HasCompletedMultiLevelWorkflowAsync(item.Id, ct);
|
||||
|
||||
if (!hasCompletedWorkflow)
|
||||
{
|
||||
AddError(request => request.Status, "This workspace requires the multi-level approval workflow to complete before content can be approved or scheduled.");
|
||||
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
bool hasApprovedDecision = await dbContext.ApprovalRequests.AnyAsync(
|
||||
approval => approval.ContentItemId == item.Id &&
|
||||
approval.WorkspaceId == item.WorkspaceId &&
|
||||
approval.State == "Approved" &&
|
||||
approval.CompletedAt.HasValue,
|
||||
ct);
|
||||
|
||||
if (!hasApprovedDecision)
|
||||
{
|
||||
AddError(request => request.Status, "This workspace requires approval before content can be approved or scheduled.");
|
||||
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (item.Status != "In approval" || normalizedStatus != "In approval")
|
||||
{
|
||||
item.Status = normalizedStatus;
|
||||
}
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
@@ -92,7 +145,7 @@ public class UpdateContentItemStatusHandler(
|
||||
item.Id,
|
||||
item.WorkspaceId,
|
||||
item.ClientId,
|
||||
item.ProjectId,
|
||||
item.CampaignId,
|
||||
item.Title,
|
||||
item.PublicationMessage,
|
||||
item.PublicationTargets,
|
||||
|
||||
@@ -7,8 +7,8 @@ public record FeedbackContextDto(
|
||||
string? WorkspaceName,
|
||||
Guid? ClientId,
|
||||
string? ClientName,
|
||||
Guid? ProjectId,
|
||||
string? ProjectName,
|
||||
Guid? CampaignId,
|
||||
string? CampaignName,
|
||||
Guid? ContentItemId,
|
||||
string? ContentItemTitle);
|
||||
|
||||
@@ -82,8 +82,8 @@ public static class FeedbackDtoMapper
|
||||
report.WorkspaceName,
|
||||
report.ClientId,
|
||||
report.ClientName,
|
||||
report.ProjectId,
|
||||
report.ProjectName,
|
||||
report.CampaignId,
|
||||
report.CampaignName,
|
||||
report.ContentItemId,
|
||||
report.ContentItemTitle),
|
||||
report.Screenshot is null
|
||||
|
||||
@@ -20,7 +20,7 @@ public static class FeedbackModelConfiguration
|
||||
feedback.Property(x => x.AppVersion).HasMaxLength(128);
|
||||
feedback.Property(x => x.WorkspaceName).HasMaxLength(256);
|
||||
feedback.Property(x => x.ClientName).HasMaxLength(256);
|
||||
feedback.Property(x => x.ProjectName).HasMaxLength(256);
|
||||
feedback.Property(x => x.CampaignName).HasMaxLength(256);
|
||||
feedback.Property(x => x.ContentItemTitle).HasMaxLength(256);
|
||||
feedback.Property(x => x.CancellationReason).HasMaxLength(2000);
|
||||
feedback.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
@@ -18,8 +18,8 @@ public class FeedbackReport
|
||||
public string? WorkspaceName { get; set; }
|
||||
public Guid? ClientId { get; set; }
|
||||
public string? ClientName { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public Guid? CampaignId { get; set; }
|
||||
public string? CampaignName { get; set; }
|
||||
public Guid? ContentItemId { get; set; }
|
||||
public string? ContentItemTitle { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
@@ -19,8 +19,8 @@ public record SubmitFeedbackRequest(
|
||||
string? WorkspaceName,
|
||||
Guid? ClientId,
|
||||
string? ClientName,
|
||||
Guid? ProjectId,
|
||||
string? ProjectName,
|
||||
Guid? CampaignId,
|
||||
string? CampaignName,
|
||||
Guid? ContentItemId,
|
||||
string? ContentItemTitle);
|
||||
|
||||
@@ -36,7 +36,7 @@ public class SubmitFeedbackRequestValidator
|
||||
RuleFor(x => x.AppVersion).MaximumLength(128);
|
||||
RuleFor(x => x.WorkspaceName).MaximumLength(256);
|
||||
RuleFor(x => x.ClientName).MaximumLength(256);
|
||||
RuleFor(x => x.ProjectName).MaximumLength(256);
|
||||
RuleFor(x => x.CampaignName).MaximumLength(256);
|
||||
RuleFor(x => x.ContentItemTitle).MaximumLength(256);
|
||||
RuleFor(x => x.ViewportWidth).GreaterThan(0).When(x => x.ViewportWidth.HasValue);
|
||||
RuleFor(x => x.ViewportHeight).GreaterThan(0).When(x => x.ViewportHeight.HasValue);
|
||||
@@ -82,8 +82,8 @@ public class SubmitFeedbackHandler(
|
||||
WorkspaceName = NormalizeOptional(request.WorkspaceName),
|
||||
ClientId = request.ClientId,
|
||||
ClientName = NormalizeOptional(request.ClientName),
|
||||
ProjectId = request.ProjectId,
|
||||
ProjectName = NormalizeOptional(request.ProjectName),
|
||||
CampaignId = request.CampaignId,
|
||||
CampaignName = NormalizeOptional(request.CampaignName),
|
||||
ContentItemId = request.ContentItemId,
|
||||
ContentItemTitle = NormalizeOptional(request.ContentItemTitle),
|
||||
CreatedAt = now,
|
||||
|
||||
@@ -50,8 +50,8 @@ public class GetCurrentUserQueryHandler(
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
List<Guid> projectIds = claims
|
||||
.Where(claim => claim.Type == KnownClaims.ProjectScope)
|
||||
List<Guid> campaignIds = claims
|
||||
.Where(claim => claim.Type == KnownClaims.CampaignScope)
|
||||
.Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
@@ -64,7 +64,7 @@ public class GetCurrentUserQueryHandler(
|
||||
Persona = persona,
|
||||
AuthorizedWorkspaceIds = workspaceIds,
|
||||
AuthorizedClientIds = clientIds,
|
||||
AuthorizedProjectIds = projectIds,
|
||||
AuthorizedCampaignIds = campaignIds,
|
||||
Alias = userModel.Alias,
|
||||
PortraitUrl = userModel.PortraitUrl,
|
||||
Firstname = userModel.Firstname,
|
||||
|
||||
@@ -7,7 +7,7 @@ public class UserDto
|
||||
public string? Persona { get; init; }
|
||||
public IList<Guid> AuthorizedWorkspaceIds { get; init; } = [];
|
||||
public IList<Guid> AuthorizedClientIds { get; init; } = [];
|
||||
public IList<Guid> AuthorizedProjectIds { get; init; } = [];
|
||||
public IList<Guid> AuthorizedCampaignIds { get; init; } = [];
|
||||
public string Username { get; init; } = null!;
|
||||
public string? Alias { get; init; }
|
||||
public string? PortraitUrl { get; init; }
|
||||
|
||||
@@ -46,7 +46,7 @@ public class GetNotificationsHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Socialize.Api.Modules.Projects.Data;
|
||||
|
||||
public static class ProjectModelConfiguration
|
||||
{
|
||||
public static ModelBuilder ConfigureProjectsModule(this ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Project>(project =>
|
||||
{
|
||||
project.ToTable("Projects");
|
||||
project.HasKey(x => x.Id);
|
||||
project.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||
project.Property(x => x.Description).HasMaxLength(4000);
|
||||
project.Property(x => x.Notes).HasMaxLength(4000);
|
||||
project.Property(x => x.Status).HasMaxLength(64).IsRequired();
|
||||
project.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
project.HasIndex(x => new { x.ClientId, x.Name }).IsUnique();
|
||||
project.HasIndex(x => x.WorkspaceId);
|
||||
project.HasIndex(x => x.ClientId);
|
||||
});
|
||||
|
||||
return modelBuilder;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using Socialize.Api.Modules.Projects.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Projects;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddProjectsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Projects.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Projects.Handlers;
|
||||
|
||||
public record GetProjectsRequest(Guid? WorkspaceId, Guid? ClientId);
|
||||
|
||||
public record ProjectDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? Notes,
|
||||
string Status,
|
||||
DateTimeOffset StartDate,
|
||||
DateTimeOffset EndDate);
|
||||
|
||||
public class GetProjectsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetProjectsRequest, IReadOnlyCollection<ProjectDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/projects");
|
||||
Options(o => o.WithTags("Projects"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetProjectsRequest request, CancellationToken ct)
|
||||
{
|
||||
IQueryable<Project> query = dbContext.Projects.AsQueryable();
|
||||
|
||||
if (accessScopeService.IsManager(User))
|
||||
{
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(project => project.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||
IReadOnlyCollection<Guid> projectScopeIds = User.GetProjectScopeIds();
|
||||
|
||||
query = query.Where(project => workspaceScopeIds.Contains(project.WorkspaceId));
|
||||
|
||||
if (clientScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(project => clientScopeIds.Contains(project.ClientId));
|
||||
}
|
||||
|
||||
if (projectScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(project => projectScopeIds.Contains(project.Id));
|
||||
}
|
||||
}
|
||||
|
||||
if (request.ClientId.HasValue)
|
||||
{
|
||||
query = query.Where(project => project.ClientId == request.ClientId.Value);
|
||||
}
|
||||
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(project => project.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
|
||||
List<ProjectDto> projects = await query
|
||||
.OrderBy(project => project.Name)
|
||||
.Select(project => new ProjectDto(
|
||||
project.Id,
|
||||
project.WorkspaceId,
|
||||
project.ClientId,
|
||||
project.Name,
|
||||
project.Description,
|
||||
project.Notes,
|
||||
project.Status,
|
||||
project.StartDate,
|
||||
project.EndDate))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(projects, ct);
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,9 @@ public class Workspace
|
||||
public string? LogoUrl { get; set; }
|
||||
public Guid OwnerUserId { get; set; }
|
||||
public required string TimeZone { get; set; }
|
||||
public string ApprovalMode { get; set; } = "Required";
|
||||
public bool SchedulePostsAutomaticallyOnApproval { get; set; }
|
||||
public bool LockContentAfterApproval { get; set; }
|
||||
public bool SendAutomaticApprovalReminders { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ public static class WorkspaceModelConfiguration
|
||||
workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired();
|
||||
workspace.Property(x => x.LogoUrl).HasMaxLength(2048);
|
||||
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
|
||||
workspace.Property(x => x.ApprovalMode).HasMaxLength(32).IsRequired().HasDefaultValue("Required");
|
||||
workspace.Property(x => x.SchedulePostsAutomaticallyOnApproval).HasDefaultValue(false);
|
||||
workspace.Property(x => x.LockContentAfterApproval).HasDefaultValue(false);
|
||||
workspace.Property(x => x.SendAutomaticApprovalReminders).HasDefaultValue(false);
|
||||
workspace.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
@@ -77,6 +77,11 @@ public class CreateWorkspaceHandler(
|
||||
workspace.Slug,
|
||||
workspace.LogoUrl,
|
||||
workspace.TimeZone,
|
||||
workspace.ApprovalMode,
|
||||
workspace.SchedulePostsAutomaticallyOnApproval,
|
||||
workspace.LockContentAfterApproval,
|
||||
workspace.SendAutomaticApprovalReminders,
|
||||
[],
|
||||
workspace.CreatedAt);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
|
||||
@@ -2,16 +2,32 @@ using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Approvals.Data;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
||||
|
||||
public record ApprovalStepConfigurationDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
string Name,
|
||||
int SortOrder,
|
||||
string TargetType,
|
||||
string TargetValue,
|
||||
int RequiredApproverCount,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public record WorkspaceDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Slug,
|
||||
string? LogoUrl,
|
||||
string TimeZone,
|
||||
string ApprovalMode,
|
||||
bool SchedulePostsAutomaticallyOnApproval,
|
||||
bool LockContentAfterApproval,
|
||||
bool SendAutomaticApprovalReminders,
|
||||
IReadOnlyCollection<ApprovalStepConfigurationDto> ApprovalSteps,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
internal class GetWorkspacesHandler(
|
||||
@@ -35,17 +51,53 @@ internal class GetWorkspacesHandler(
|
||||
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
|
||||
}
|
||||
|
||||
var workspaces = await query
|
||||
var workspaceRows = await query
|
||||
.OrderBy(workspace => workspace.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var workspaceIds = workspaceRows.Select(workspace => workspace.Id).ToList();
|
||||
List<WorkspaceApprovalStepConfiguration> approvalStepRows = await dbContext.WorkspaceApprovalStepConfigurations
|
||||
.Where(step => workspaceIds.Contains(step.WorkspaceId))
|
||||
.OrderBy(step => step.SortOrder)
|
||||
.ThenBy(step => step.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var approvalStepsByWorkspaceId = approvalStepRows
|
||||
.GroupBy(step => step.WorkspaceId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => (IReadOnlyCollection<ApprovalStepConfigurationDto>)group
|
||||
.Select(ToApprovalStepConfigurationDto)
|
||||
.ToArray());
|
||||
|
||||
var workspaces = workspaceRows
|
||||
.Select(workspace => new WorkspaceDto(
|
||||
workspace.Id,
|
||||
workspace.Name,
|
||||
workspace.Slug,
|
||||
workspace.LogoUrl,
|
||||
workspace.TimeZone,
|
||||
workspace.ApprovalMode,
|
||||
workspace.SchedulePostsAutomaticallyOnApproval,
|
||||
workspace.LockContentAfterApproval,
|
||||
workspace.SendAutomaticApprovalReminders,
|
||||
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>(),
|
||||
workspace.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
.ToList();
|
||||
|
||||
await SendOkAsync(workspaces, ct);
|
||||
}
|
||||
|
||||
public static ApprovalStepConfigurationDto ToApprovalStepConfigurationDto(WorkspaceApprovalStepConfiguration step)
|
||||
{
|
||||
return new ApprovalStepConfigurationDto(
|
||||
step.Id,
|
||||
step.WorkspaceId,
|
||||
step.Name,
|
||||
step.SortOrder,
|
||||
step.TargetType,
|
||||
step.TargetValue,
|
||||
step.RequiredApproverCount,
|
||||
step.CreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user