Files
social-media/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs

236 lines
8.5 KiB
C#

using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.Notifications.Contracts;
using System.Text.Json;
namespace Socialize.Api.Modules.Comments.Handlers;
public record CreateCommentRequest(
Guid WorkspaceId,
Guid ContentItemId,
Guid? ParentCommentId,
string Body,
IFormFile? Attachment);
public class CreateCommentRequestValidator
: Validator<CreateCommentRequest>
{
public CreateCommentRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.Body).MaximumLength(4000);
}
}
public class CreateCommentHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
IBlobStorage blobStorage,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateCommentRequest, CommentDto>
{
public override void Configure()
{
Post("/api/comments");
Options(o => o.WithTags("Comments"));
AllowFileUploads();
}
public override async Task HandleAsync(CreateCommentRequest request, CancellationToken ct)
{
string body = request.Body?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(body) && request.Attachment is null)
{
AddError(request => request.Body, "A comment body or attachment is required.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
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 (!await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
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;
}
}
Guid commentId = Guid.NewGuid();
string? attachmentFileName = null;
string? attachmentContentType = null;
long? attachmentSizeBytes = null;
string? attachmentBlobName = null;
string? attachmentBlobUrl = null;
if (request.Attachment is not null)
{
string normalizedContentType = request.Attachment.ContentType.Trim().ToLowerInvariant();
if (request.Attachment.Length <= 0)
{
AddError(request => request.Attachment, "The attachment must not be empty.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!IsInlineAttachmentContentType(normalizedContentType))
{
AddError(request => request.Attachment, "The attachment must be a PNG or JPEG image.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
attachmentFileName = NormalizeFileName(request.Attachment.FileName, normalizedContentType);
attachmentContentType = normalizedContentType;
attachmentSizeBytes = request.Attachment.Length;
attachmentBlobName =
$"{contentItem.WorkspaceId}/{SubDirectoryNames.Contents}/{contentItem.Id}/comments/{commentId}/{attachmentFileName}";
try
{
attachmentBlobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Workspaces,
attachmentBlobName,
request.Attachment.OpenReadStream(),
normalizedContentType,
ct);
}
catch (InvalidOperationException)
{
AddError(request => request.Attachment, "The attachment file is invalid or unsupported.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
}
Comment comment = new()
{
Id = commentId,
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
ParentCommentId = request.ParentCommentId,
AuthorUserId = User.GetUserId(),
AuthorDisplayName = User.GetAlias() ?? User.GetName(),
AuthorEmail = User.GetEmail(),
Body = body,
AttachmentFileName = attachmentFileName,
AttachmentContentType = attachmentContentType,
AttachmentSizeBytes = attachmentSizeBytes,
AttachmentBlobContainerName = attachmentBlobName is not null ? ContainerNames.Workspaces : null,
AttachmentBlobName = attachmentBlobName,
AttachmentBlobUrl = attachmentBlobUrl,
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 activityWriter.WriteAsync(
new ContentItemActivityWriteModel(
comment.WorkspaceId,
comment.ContentItemId,
"comment.created",
"Comment",
comment.Id,
$"{comment.AuthorDisplayName} commented on {contentItem.Title}.",
comment.AuthorUserId,
comment.AuthorEmail,
JsonSerializer.Serialize(new
{
parentCommentId = comment.ParentCommentId,
attachmentFileName = comment.AttachmentFileName,
})),
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.AttachmentFileName,
comment.AttachmentContentType,
comment.AttachmentSizeBytes,
comment.AttachmentBlobUrl,
comment.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
private static bool IsInlineAttachmentContentType(string contentType)
{
return contentType.Trim().ToLowerInvariant() is "image/png" or "image/jpeg" or "image/jpg";
}
private static string NormalizeFileName(string? fileName, string contentType)
{
string extension = contentType.Trim().ToLowerInvariant() switch
{
"image/png" => ".png",
"image/jpeg" or "image/jpg" => ".jpg",
_ => string.Empty,
};
string normalized = Path.GetFileName(fileName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return $"comment-attachment{extension}";
}
return normalized.Length > 256 ? normalized[..256] : normalized;
}
}