236 lines
8.5 KiB
C#
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;
|
|
}
|
|
}
|