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