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,18 @@
namespace Socialize.Modules.ContentItems.Data;
public class ContentItem
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ClientId { get; set; }
public Guid ProjectId { get; set; }
public required string Title { get; set; }
public required string PublicationMessage { get; set; }
public required string PublicationTargets { get; set; }
public string? Hashtags { get; set; }
public required string Status { get; set; }
public DateTimeOffset? DueDate { get; set; }
public required string CurrentRevisionLabel { get; set; }
public int CurrentRevisionNumber { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,16 @@
namespace Socialize.Modules.ContentItems.Data;
public class ContentItemRevision
{
public Guid Id { get; init; }
public Guid ContentItemId { get; set; }
public int RevisionNumber { get; set; }
public required string RevisionLabel { get; set; }
public required string Title { get; set; }
public required string PublicationMessage { get; set; }
public required string PublicationTargets { get; set; }
public string? Hashtags { get; set; }
public string? ChangeSummary { get; set; }
public Guid? CreatedByUserId { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

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

View File

@@ -0,0 +1,148 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.ContentItems.Handlers;
public record CreateContentItemRequest(
Guid WorkspaceId,
Guid ClientId,
Guid ProjectId,
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
DateTimeOffset? DueDate);
public class CreateContentItemRequestValidator
: Validator<CreateContentItemRequest>
{
public CreateContentItemRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ClientId).NotEmpty();
RuleFor(x => x.ProjectId).NotEmpty();
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000);
RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512);
RuleFor(x => x.Hashtags).MaximumLength(1024);
}
}
public class CreateContentItemHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateContentItemRequest, ContentItemDto>
{
public override void Configure()
{
Post("/api/content-items");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct)
{
if (!accessScopeService.CanContributeToProject(User, request.WorkspaceId, request.ClientId, request.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
bool workspaceExists = await dbContext.Workspaces
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
if (!workspaceExists)
{
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
bool clientExists = await dbContext.Clients
.AnyAsync(
client => client.Id == request.ClientId && client.WorkspaceId == request.WorkspaceId,
ct);
if (!clientExists)
{
AddError(request => request.ClientId, "The selected client does not belong to the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
bool projectExists = await dbContext.Projects
.AnyAsync(
project => project.Id == request.ProjectId &&
project.WorkspaceId == request.WorkspaceId &&
project.ClientId == request.ClientId,
ct);
if (!projectExists)
{
AddError(request => request.ProjectId, "The selected project does not belong to the selected client.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
ContentItem item = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ClientId = request.ClientId,
ProjectId = request.ProjectId,
Title = request.Title.Trim(),
PublicationMessage = request.PublicationMessage.Trim(),
PublicationTargets = request.PublicationTargets.Trim(),
Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim(),
Status = "Draft",
DueDate = request.DueDate,
CurrentRevisionLabel = "v1",
CurrentRevisionNumber = 1,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.ContentItems.Add(item);
dbContext.ContentItemRevisions.Add(new ContentItemRevision
{
Id = Guid.NewGuid(),
ContentItemId = item.Id,
RevisionNumber = 1,
RevisionLabel = "v1",
Title = item.Title,
PublicationMessage = item.PublicationMessage,
PublicationTargets = item.PublicationTargets,
Hashtags = item.Hashtags,
CreatedAt = DateTimeOffset.UtcNow,
});
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
item.WorkspaceId,
item.Id,
"content-item.created",
"ContentItem",
item.Id,
$"Content item {item.Title} was created.",
null,
null,
$$"""{"status":"{{item.Status}}","revisionLabel":"{{item.CurrentRevisionLabel}}"}"""),
ct);
ContentItemDto dto = new(
item.Id,
item.WorkspaceId,
item.ClientId,
item.ProjectId,
item.Title,
item.PublicationMessage,
item.PublicationTargets,
item.Hashtags,
item.Status,
item.DueDate,
item.CurrentRevisionLabel,
item.CurrentRevisionNumber);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,120 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.ContentItems.Handlers;
public record CreateContentItemRevisionRequest(
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
string? ChangeSummary);
public class CreateContentItemRevisionRequestValidator
: Validator<CreateContentItemRevisionRequest>
{
public CreateContentItemRevisionRequestValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000);
RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512);
RuleFor(x => x.Hashtags).MaximumLength(1024);
RuleFor(x => x.ChangeSummary).MaximumLength(1024);
}
}
public class CreateContentItemRevisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateContentItemRevisionRequest, ContentItemRevisionDto>
{
public override void Configure()
{
Post("/api/content-items/{id}/revisions");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CreateContentItemRevisionRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanContributeToProject(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
int revisionNumber = item.CurrentRevisionNumber + 1;
string revisionLabel = $"v{revisionNumber}";
item.Title = request.Title.Trim();
item.PublicationMessage = request.PublicationMessage.Trim();
item.PublicationTargets = request.PublicationTargets.Trim();
item.Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim();
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(),
ContentItemId = item.Id,
RevisionNumber = revisionNumber,
RevisionLabel = revisionLabel,
Title = item.Title,
PublicationMessage = item.PublicationMessage,
PublicationTargets = item.PublicationTargets,
Hashtags = item.Hashtags,
ChangeSummary = string.IsNullOrWhiteSpace(request.ChangeSummary) ? null : request.ChangeSummary.Trim(),
CreatedByUserId = User.GetUserId(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.ContentItemRevisions.Add(revision);
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
item.WorkspaceId,
item.Id,
"content-item.revision.created",
"ContentItemRevision",
revision.Id,
$"Revision {revisionLabel} was created for {item.Title}.",
User.GetUserId(),
User.GetEmail(),
$$"""{"revisionLabel":"{{revisionLabel}}","status":"{{item.Status}}"}"""),
ct);
ContentItemRevisionDto dto = new(
revision.Id,
revision.ContentItemId,
revision.RevisionNumber,
revision.RevisionLabel,
revision.Title,
revision.PublicationMessage,
revision.PublicationTargets,
revision.Hashtags,
revision.ChangeSummary,
revision.CreatedByUserId,
revision.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,68 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.ContentItems.Data;
namespace Socialize.Modules.ContentItems.Handlers;
public record ContentItemDetailDto(
Guid Id,
Guid WorkspaceId,
Guid ClientId,
Guid ProjectId,
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
string Status,
DateTimeOffset? DueDate,
string CurrentRevisionLabel,
int CurrentRevisionNumber,
DateTimeOffset CreatedAt);
public class GetContentItemHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<ContentItemDetailDto>
{
public override void Configure()
{
Get("/api/content-items/{id}");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItemDetailDto? item = await dbContext.ContentItems
.Where(candidate => candidate.Id == id)
.Select(candidate => new ContentItemDetailDto(
candidate.Id,
candidate.WorkspaceId,
candidate.ClientId,
candidate.ProjectId,
candidate.Title,
candidate.PublicationMessage,
candidate.PublicationTargets,
candidate.Hashtags,
candidate.Status,
candidate.DueDate,
candidate.CurrentRevisionLabel,
candidate.CurrentRevisionNumber,
candidate.CreatedAt))
.SingleOrDefaultAsync(ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
await SendOkAsync(item, ct);
}
}

View File

@@ -0,0 +1,64 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.ContentItems.Handlers;
public record ContentItemRevisionDto(
Guid Id,
Guid ContentItemId,
int RevisionNumber,
string RevisionLabel,
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
string? ChangeSummary,
Guid? CreatedByUserId,
DateTimeOffset CreatedAt);
public class GetContentItemRevisionsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<IReadOnlyCollection<ContentItemRevisionDto>>
{
public override void Configure()
{
Get("/api/content-items/{id}/revisions");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
List<ContentItemRevisionDto> revisions = await dbContext.ContentItemRevisions
.Where(revision => revision.ContentItemId == id)
.OrderByDescending(revision => revision.RevisionNumber)
.Select(revision => new ContentItemRevisionDto(
revision.Id,
revision.ContentItemId,
revision.RevisionNumber,
revision.RevisionLabel,
revision.Title,
revision.PublicationMessage,
revision.PublicationTargets,
revision.Hashtags,
revision.ChangeSummary,
revision.CreatedByUserId,
revision.CreatedAt))
.ToListAsync(ct);
await SendOkAsync(revisions, ct);
}
}

View File

@@ -0,0 +1,91 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.ContentItems.Data;
namespace Socialize.Modules.ContentItems.Handlers;
public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? ProjectId);
public record ContentItemDto(
Guid Id,
Guid WorkspaceId,
Guid ClientId,
Guid ProjectId,
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
string Status,
DateTimeOffset? DueDate,
string CurrentRevisionLabel,
int CurrentRevisionNumber);
public class GetContentItemsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetContentItemsRequest, IReadOnlyCollection<ContentItemDto>>
{
public override void Configure()
{
Get("/api/content-items");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(GetContentItemsRequest request, CancellationToken ct)
{
IQueryable<ContentItem> query = dbContext.ContentItems.AsQueryable();
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> projectScopeIds = User.GetProjectScopeIds();
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
if (clientScopeIds.Count > 0)
{
query = query.Where(item => clientScopeIds.Contains(item.ClientId));
}
if (projectScopeIds.Count > 0)
{
query = query.Where(item => projectScopeIds.Contains(item.ProjectId));
}
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(item => item.WorkspaceId == request.WorkspaceId.Value);
}
if (request.ProjectId.HasValue)
{
query = query.Where(item => item.ProjectId == request.ProjectId.Value);
}
if (request.ClientId.HasValue)
{
query = query.Where(item => item.ClientId == request.ClientId.Value);
}
List<ContentItemDto> items = await query
.OrderBy(item => item.DueDate)
.ThenBy(item => item.Title)
.Select(item => new ContentItemDto(
item.Id,
item.WorkspaceId,
item.ClientId,
item.ProjectId,
item.Title,
item.PublicationMessage,
item.PublicationTargets,
item.Hashtags,
item.Status,
item.DueDate,
item.CurrentRevisionLabel,
item.CurrentRevisionNumber))
.ToListAsync(ct);
await SendOkAsync(items, ct);
}
}

View File

@@ -0,0 +1,105 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.ContentItems.Data;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.ContentItems.Handlers;
public record UpdateContentItemStatusRequest(string Status);
public class UpdateContentItemStatusRequestValidator
: Validator<UpdateContentItemStatusRequest>
{
public UpdateContentItemStatusRequestValidator()
{
RuleFor(x => x.Status).NotEmpty().MaximumLength(64);
}
}
public class UpdateContentItemStatusHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
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",
"Approved",
"Rejected",
"Ready to publish",
"Published",
"Archived",
];
public override void Configure()
{
Post("/api/content-items/{id}/status");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(UpdateContentItemStatusRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, item.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
string normalizedStatus = request.Status.Trim();
if (!AllowedStatuses.Contains(normalizedStatus))
{
AddError(request => request.Status, "The requested status is not valid.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
item.Status = normalizedStatus;
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
item.WorkspaceId,
item.Id,
"content-item.status.updated",
"ContentItem",
item.Id,
$"Status changed to {item.Status} for {item.Title}.",
User.GetUserId(),
User.GetEmail(),
$$"""{"status":"{{item.Status}}"}"""),
ct);
ContentItemDetailDto dto = new(
item.Id,
item.WorkspaceId,
item.ClientId,
item.ProjectId,
item.Title,
item.PublicationMessage,
item.PublicationTargets,
item.Hashtags,
item.Status,
item.DueDate,
item.CurrentRevisionLabel,
item.CurrentRevisionNumber,
item.CreatedAt);
await SendOkAsync(dto, ct);
}
}