feat: pivot to social media workflow app
This commit is contained in:
118
backend/Modules/Approvals/Handlers/CreateApprovalRequest.cs
Normal file
118
backend/Modules/Approvals/Handlers/CreateApprovalRequest.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.Security.Cryptography;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Approvals.Handlers;
|
||||
|
||||
public record CreateApprovalRequestRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string Stage,
|
||||
string ReviewerName,
|
||||
string ReviewerEmail,
|
||||
DateTimeOffset? DueAt);
|
||||
|
||||
public class CreateApprovalRequestRequestValidator
|
||||
: Validator<CreateApprovalRequestRequest>
|
||||
{
|
||||
public CreateApprovalRequestRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ContentItemId).NotEmpty();
|
||||
RuleFor(x => x.Stage).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.ReviewerName).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.ReviewerEmail).NotEmpty().MaximumLength(256).EmailAddress();
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateApprovalRequestHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateApprovalRequestRequest, ApprovalRequestDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/approvals");
|
||||
Options(o => o.WithTags("Approvals"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? contentItem = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
|
||||
ct);
|
||||
|
||||
if (contentItem is null)
|
||||
{
|
||||
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, contentItem.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ApprovalRequest approval = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
ContentItemId = request.ContentItemId,
|
||||
Stage = request.Stage.Trim(),
|
||||
ReviewerName = request.ReviewerName.Trim(),
|
||||
ReviewerEmail = request.ReviewerEmail.Trim(),
|
||||
RequestedByUserId = User.GetUserId(),
|
||||
DueAt = request.DueAt,
|
||||
State = "Pending",
|
||||
AccessToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.ApprovalRequests.Add(approval);
|
||||
|
||||
if (approval.Stage == "Internal")
|
||||
{
|
||||
contentItem.Status = "In internal review";
|
||||
}
|
||||
else if (approval.Stage == "Client")
|
||||
{
|
||||
contentItem.Status = "In client review";
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
"approval.requested",
|
||||
"ApprovalRequest",
|
||||
approval.Id,
|
||||
$"Approval requested from {approval.ReviewerName} for {contentItem.Title}.",
|
||||
null,
|
||||
approval.ReviewerEmail,
|
||||
$$"""{"stage":"{{approval.Stage}}","accessToken":"{{approval.AccessToken}}"}"""),
|
||||
ct);
|
||||
|
||||
ApprovalRequestDto dto = new(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
approval.RequestedByUserId,
|
||||
approval.DueAt,
|
||||
approval.State,
|
||||
approval.AccessToken,
|
||||
approval.SentAt,
|
||||
approval.CompletedAt,
|
||||
[]);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
117
backend/Modules/Approvals/Handlers/GetApprovals.cs
Normal file
117
backend/Modules/Approvals/Handlers/GetApprovals.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
|
||||
namespace Socialize.Modules.Approvals.Handlers;
|
||||
|
||||
public record GetApprovalsRequest(Guid ContentItemId);
|
||||
|
||||
public record ApprovalDecisionDto(
|
||||
Guid Id,
|
||||
Guid ApprovalRequestId,
|
||||
string Decision,
|
||||
string? Comment,
|
||||
Guid? DecidedByUserId,
|
||||
string DecidedByName,
|
||||
string DecidedByEmail,
|
||||
string? DecidedByPortraitUrl,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public record ApprovalRequestDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string Stage,
|
||||
string ReviewerName,
|
||||
string ReviewerEmail,
|
||||
Guid RequestedByUserId,
|
||||
DateTimeOffset? DueAt,
|
||||
string State,
|
||||
string AccessToken,
|
||||
DateTimeOffset SentAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
IReadOnlyCollection<ApprovalDecisionDto> Decisions);
|
||||
|
||||
public class GetApprovalsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetApprovalsRequest, IReadOnlyCollection<ApprovalRequestDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/approvals");
|
||||
Options(o => o.WithTags("Approvals"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetApprovalsRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? item = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
|
||||
.Where(approval => approval.ContentItemId == request.ContentItemId)
|
||||
.OrderByDescending(approval => approval.SentAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<Guid> approvalIds = approvals
|
||||
.Select(approval => approval.Id)
|
||||
.ToList();
|
||||
|
||||
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
||||
.Where(decision => approvalIds.Contains(decision.ApprovalRequestId))
|
||||
.OrderByDescending(decision => decision.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<Guid> decidedByUserIds = decisions
|
||||
.Where(decision => decision.DecidedByUserId.HasValue)
|
||||
.Select(decision => decision.DecidedByUserId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Dictionary<Guid, string?> decisionPortraits = await dbContext.Users
|
||||
.Where(user => decidedByUserIds.Contains(user.Id))
|
||||
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
|
||||
|
||||
List<ApprovalRequestDto> dtos = approvals
|
||||
.Select(approval => new ApprovalRequestDto(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
approval.RequestedByUserId,
|
||||
approval.DueAt,
|
||||
approval.State,
|
||||
approval.AccessToken,
|
||||
approval.SentAt,
|
||||
approval.CompletedAt,
|
||||
decisions
|
||||
.Where(decision => decision.ApprovalRequestId == approval.Id)
|
||||
.Select(decision => new ApprovalDecisionDto(
|
||||
decision.Id,
|
||||
decision.ApprovalRequestId,
|
||||
decision.Decision,
|
||||
decision.Comment,
|
||||
decision.DecidedByUserId,
|
||||
decision.DecidedByName,
|
||||
decision.DecidedByEmail,
|
||||
decision.DecidedByUserId.HasValue
|
||||
? decisionPortraits.GetValueOrDefault(decision.DecidedByUserId.Value)
|
||||
: null,
|
||||
decision.CreatedAt))
|
||||
.ToList()))
|
||||
.ToList();
|
||||
|
||||
await SendOkAsync(dtos, ct);
|
||||
}
|
||||
}
|
||||
169
backend/Modules/Approvals/Handlers/SubmitApprovalDecision.cs
Normal file
169
backend/Modules/Approvals/Handlers/SubmitApprovalDecision.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Approvals.Handlers;
|
||||
|
||||
public record SubmitApprovalDecisionRequest(
|
||||
string Decision,
|
||||
string? Comment,
|
||||
string? ReviewerName,
|
||||
string? ReviewerEmail);
|
||||
|
||||
public class SubmitApprovalDecisionRequestValidator
|
||||
: Validator<SubmitApprovalDecisionRequest>
|
||||
{
|
||||
public SubmitApprovalDecisionRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Decision).NotEmpty().MaximumLength(64);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
public class SubmitApprovalDecisionHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/approvals/{id}/decisions");
|
||||
AllowAnonymous();
|
||||
Options(o => o.WithTags("Approvals"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SubmitApprovalDecisionRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
ApprovalRequest? approval = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (approval is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ContentItem? contentItem = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == approval.ContentItemId, ct);
|
||||
if (contentItem is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (User?.Identity?.IsAuthenticated == true &&
|
||||
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedDecision = request.Decision.Trim();
|
||||
string decidedByName = User?.Identity?.IsAuthenticated == true
|
||||
? User.GetAlias() ?? User.GetName()
|
||||
: string.IsNullOrWhiteSpace(request.ReviewerName) ? approval.ReviewerName : request.ReviewerName.Trim();
|
||||
string decidedByEmail = User?.Identity?.IsAuthenticated == true
|
||||
? User.GetEmail()
|
||||
: string.IsNullOrWhiteSpace(request.ReviewerEmail) ? approval.ReviewerEmail : request.ReviewerEmail.Trim();
|
||||
|
||||
ApprovalDecision decision = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalRequestId = approval.Id,
|
||||
Decision = normalizedDecision,
|
||||
Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(),
|
||||
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
|
||||
DecidedByName = decidedByName,
|
||||
DecidedByEmail = decidedByEmail,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
approval.State = normalizedDecision;
|
||||
approval.CompletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (approval.Stage == "Internal")
|
||||
{
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
.OrderByDescending(candidate => candidate.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<Guid> decidedByUserIds = decisions
|
||||
.Where(candidate => candidate.DecidedByUserId.HasValue)
|
||||
.Select(candidate => candidate.DecidedByUserId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Dictionary<Guid, string?> decisionPortraits = await dbContext.Users
|
||||
.Where(user => decidedByUserIds.Contains(user.Id))
|
||||
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
|
||||
|
||||
List<ApprovalDecisionDto> decisionDtos = decisions
|
||||
.Select(candidate => new ApprovalDecisionDto(
|
||||
candidate.Id,
|
||||
candidate.ApprovalRequestId,
|
||||
candidate.Decision,
|
||||
candidate.Comment,
|
||||
candidate.DecidedByUserId,
|
||||
candidate.DecidedByName,
|
||||
candidate.DecidedByEmail,
|
||||
candidate.DecidedByUserId.HasValue
|
||||
? decisionPortraits.GetValueOrDefault(candidate.DecidedByUserId.Value)
|
||||
: null,
|
||||
candidate.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
ApprovalRequestDto dto = new(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
approval.RequestedByUserId,
|
||||
approval.DueAt,
|
||||
approval.State,
|
||||
approval.AccessToken,
|
||||
approval.SentAt,
|
||||
approval.CompletedAt,
|
||||
decisionDtos);
|
||||
|
||||
await SendOkAsync(dto, ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user