chore: moving towards agentic development
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 21:12:26 -04:00
parent df3e602015
commit b6eb692c27
179 changed files with 2880 additions and 866 deletions

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