feat: refine content calendar experience

This commit is contained in:
2026-05-05 23:25:58 -04:00
parent b66c10b681
commit a7535d460d
72 changed files with 3233 additions and 1310 deletions

View File

@@ -1,6 +1,7 @@
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;
@@ -14,7 +15,8 @@ public record CreateCommentRequest(
Guid WorkspaceId,
Guid ContentItemId,
Guid? ParentCommentId,
string Body);
string Body,
IFormFile? Attachment);
public class CreateCommentRequestValidator
: Validator<CreateCommentRequest>
@@ -23,13 +25,14 @@ public class CreateCommentRequestValidator
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.Body).NotEmpty().MaximumLength(4000);
RuleFor(x => x.Body).MaximumLength(4000);
}
}
public class CreateCommentHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
IBlobStorage blobStorage,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateCommentRequest, CommentDto>
@@ -38,10 +41,19 @@ public class CreateCommentHandler(
{
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,
@@ -75,16 +87,70 @@ public class CreateCommentHandler(
}
}
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 = Guid.NewGuid(),
Id = commentId,
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
ParentCommentId = request.ParentCommentId,
AuthorUserId = User.GetUserId(),
AuthorDisplayName = User.GetAlias() ?? User.GetName(),
AuthorEmail = User.GetEmail(),
Body = request.Body.Trim(),
Body = body,
AttachmentFileName = attachmentFileName,
AttachmentContentType = attachmentContentType,
AttachmentSizeBytes = attachmentSizeBytes,
AttachmentBlobContainerName = attachmentBlobName is not null ? ContainerNames.Workspaces : null,
AttachmentBlobName = attachmentBlobName,
AttachmentBlobUrl = attachmentBlobUrl,
CreatedAt = DateTimeOffset.UtcNow,
};
@@ -109,6 +175,7 @@ public class CreateCommentHandler(
JsonSerializer.Serialize(new
{
parentCommentId = comment.ParentCommentId,
attachmentFileName = comment.AttachmentFileName,
})),
ct);
@@ -135,8 +202,34 @@ public class CreateCommentHandler(
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;
}
}