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,120 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Comments.Handlers;
public record CreateCommentRequest(
Guid WorkspaceId,
Guid ContentItemId,
Guid? ParentCommentId,
string Body);
public class CreateCommentRequestValidator
: Validator<CreateCommentRequest>
{
public CreateCommentRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.Body).NotEmpty().MaximumLength(4000);
}
}
public class CreateCommentHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateCommentRequest, CommentDto>
{
public override void Configure()
{
Post("/api/comments");
Options(o => o.WithTags("Comments"));
}
public override async Task HandleAsync(CreateCommentRequest 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.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
if (request.ParentCommentId.HasValue)
{
bool parentExists = await dbContext.Comments
.AnyAsync(
comment => comment.Id == request.ParentCommentId.Value && comment.ContentItemId == request.ContentItemId,
ct);
if (!parentExists)
{
AddError(request => request.ParentCommentId, "The selected parent comment does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
}
Comment comment = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
ParentCommentId = request.ParentCommentId,
AuthorUserId = User.GetUserId(),
AuthorDisplayName = User.GetAlias() ?? User.GetName(),
AuthorEmail = User.GetEmail(),
Body = request.Body.Trim(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Comments.Add(comment);
await dbContext.SaveChangesAsync(ct);
string? authorPortraitUrl = await dbContext.Users
.Where(candidate => candidate.Id == comment.AuthorUserId)
.Select(candidate => candidate.PortraitUrl)
.SingleOrDefaultAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
comment.WorkspaceId,
comment.ContentItemId,
"comment.created",
"Comment",
comment.Id,
$"{comment.AuthorDisplayName} commented on {contentItem.Title}.",
null,
null,
$$"""{"parentCommentId":"{{comment.ParentCommentId}}"}"""),
ct);
CommentDto dto = new(
comment.Id,
comment.WorkspaceId,
comment.ContentItemId,
comment.ParentCommentId,
comment.AuthorUserId,
comment.AuthorDisplayName,
comment.AuthorEmail,
authorPortraitUrl,
comment.Body,
comment.IsResolved,
comment.CreatedAt,
comment.ResolvedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,80 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Comments.Handlers;
public record GetCommentsRequest(Guid ContentItemId);
public record CommentDto(
Guid Id,
Guid WorkspaceId,
Guid ContentItemId,
Guid? ParentCommentId,
Guid AuthorUserId,
string AuthorDisplayName,
string AuthorEmail,
string? AuthorPortraitUrl,
string Body,
bool IsResolved,
DateTimeOffset CreatedAt,
DateTimeOffset? ResolvedAt);
public class GetCommentsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetCommentsRequest, IReadOnlyCollection<CommentDto>>
{
public override void Configure()
{
Get("/api/comments");
Options(o => o.WithTags("Comments"));
}
public override async Task HandleAsync(GetCommentsRequest 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<Comment> comments = await dbContext.Comments
.Where(comment => comment.ContentItemId == request.ContentItemId)
.OrderBy(comment => comment.CreatedAt)
.ToListAsync(ct);
List<Guid> authorIds = comments
.Select(comment => comment.AuthorUserId)
.Distinct()
.ToList();
Dictionary<Guid, string?> authorPortraits = await dbContext.Users
.Where(user => authorIds.Contains(user.Id))
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
List<CommentDto> dtos = comments
.Select(comment => new CommentDto(
comment.Id,
comment.WorkspaceId,
comment.ContentItemId,
comment.ParentCommentId,
comment.AuthorUserId,
comment.AuthorDisplayName,
comment.AuthorEmail,
authorPortraits.GetValueOrDefault(comment.AuthorUserId),
comment.Body,
comment.IsResolved,
comment.CreatedAt,
comment.ResolvedAt))
.ToList();
await SendOkAsync(dtos, ct);
}
}

View File

@@ -0,0 +1,84 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Comments.Handlers;
public class ResolveCommentHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: EndpointWithoutRequest<CommentDto>
{
public override void Configure()
{
Post("/api/comments/{id}/resolve");
Options(o => o.WithTags("Comments"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
Comment? comment = await dbContext.Comments.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (comment is null)
{
await SendNotFoundAsync(ct);
return;
}
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == comment.ContentItemId, ct);
if (contentItem is null)
{
await SendNotFoundAsync(ct);
return;
}
bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId)
|| accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId);
if (!canResolve)
{
await SendForbiddenAsync(ct);
return;
}
comment.IsResolved = true;
comment.ResolvedAt = comment.ResolvedAt ?? DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct);
string? authorPortraitUrl = await dbContext.Users
.Where(candidate => candidate.Id == comment.AuthorUserId)
.Select(candidate => candidate.PortraitUrl)
.SingleOrDefaultAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
comment.WorkspaceId,
comment.ContentItemId,
"comment.resolved",
"Comment",
comment.Id,
$"{User.GetAlias() ?? User.GetName()} resolved a comment.",
null,
null,
null),
ct);
CommentDto dto = new(
comment.Id,
comment.WorkspaceId,
comment.ContentItemId,
comment.ParentCommentId,
comment.AuthorUserId,
comment.AuthorDisplayName,
comment.AuthorEmail,
authorPortraitUrl,
comment.Body,
comment.IsResolved,
comment.CreatedAt,
comment.ResolvedAt);
await SendOkAsync(dto, ct);
}
}