feat: pivot to social media workflow app
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-04-24 12:58:35 -04:00
parent 0f4acc1b6d
commit df3e602015
349 changed files with 18685 additions and 16010 deletions

View File

@@ -0,0 +1,13 @@
namespace Socialize.Modules.Approvals.Data;
public class ApprovalDecision
{
public Guid Id { get; init; }
public Guid ApprovalRequestId { get; set; }
public required string Decision { get; set; }
public string? Comment { get; set; }
public Guid? DecidedByUserId { get; set; }
public required string DecidedByName { get; set; }
public required string DecidedByEmail { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace Socialize.Modules.Approvals.Data;
public class ApprovalRequest
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public required string Stage { get; set; }
public required string ReviewerName { get; set; }
public required string ReviewerEmail { get; set; }
public Guid RequestedByUserId { get; set; }
public DateTimeOffset? DueAt { get; set; }
public required string State { get; set; }
public required string AccessToken { get; set; }
public DateTimeOffset SentAt { get; init; }
public DateTimeOffset? CompletedAt { get; set; }
}

View File

@@ -0,0 +1,12 @@
using Socialize.Modules.Approvals.Data;
namespace Socialize.Modules.Approvals;
public static class DependencyInjection
{
public static WebApplicationBuilder AddApprovalsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}