diff --git a/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs b/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs new file mode 100644 index 0000000..7546af0 --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Posts.Dto; + +public record AddPostCommentRequest(Guid AuthorId, string Content); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs b/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs new file mode 100644 index 0000000..04e180a --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Posts.Dto; + +public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostDto.cs b/Femto.Api/Controllers/Posts/Dto/PostDto.cs index 2e6e827..c9af7c6 100644 --- a/Femto.Api/Controllers/Posts/Dto/PostDto.cs +++ b/Femto.Api/Controllers/Posts/Dto/PostDto.cs @@ -10,7 +10,8 @@ public record PostDto( IEnumerable Media, IEnumerable Reactions, DateTimeOffset CreatedAt, - IEnumerable PossibleReactions + IEnumerable PossibleReactions, + IEnumerable Comments ) { public static PostDto FromModel(Modules.Blog.Application.Queries.GetPosts.Dto.PostDto post) => @@ -21,6 +22,7 @@ public record PostDto( post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.ReactedOn)), post.CreatedAt, - post.PossibleReactions + post.PossibleReactions, + post.Comments.Select(c => new PostCommentDto(c.Author, c.Content, c.PostedOn)) ); -} +} \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index bb4bca7..ed882f7 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -1,6 +1,7 @@ using Femto.Api.Controllers.Posts.Dto; using Femto.Common; using Femto.Modules.Blog.Application; +using Femto.Modules.Blog.Application.Commands.AddPostComment; using Femto.Modules.Blog.Application.Commands.AddPostReaction; using Femto.Modules.Blog.Application.Commands.ClearPostReaction; using Femto.Modules.Blog.Application.Commands.CreatePost; @@ -13,7 +14,7 @@ namespace Femto.Api.Controllers.Posts; [ApiController] [Route("posts")] -public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) +public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext, IAuthorizationService auth) : ControllerBase { [HttpGet] @@ -131,4 +132,23 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current return this.Ok(); } + + [HttpPost("{postId}/comments")] + [Authorize] + public async Task AddPostComment( + Guid postId, + [FromBody] AddPostCommentRequest request, + CancellationToken cancellationToken + ) + { + if (currentUserContext.CurrentUser?.Id != request.AuthorId) + return this.BadRequest(); + + await blogModule.Command( + new AddPostCommentCommand(postId, request.AuthorId, request.Content), + cancellationToken + ); + + return this.Ok(); + } } diff --git a/Femto.Database/Seed/TestDataSeeder.cs b/Femto.Database/Seed/TestDataSeeder.cs index 433f73c..2c8efcc 100644 --- a/Femto.Database/Seed/TestDataSeeder.cs +++ b/Femto.Database/Seed/TestDataSeeder.cs @@ -43,6 +43,7 @@ public static class TestDataSeeder ('019691a0-4dd3-7e89-909e-94a6fd19a05e', @id, '["๐Ÿ†", "๐Ÿงข", "๐Ÿง‘๐Ÿพโ€๐ŸŽ“", "๐Ÿฅ•", "๐Ÿ•—"]', 'Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.') ; + INSERT INTO blog.post_media (id, post_id, url, ordering) VALUES @@ -63,6 +64,12 @@ public static class TestDataSeeder ('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '๐Ÿ•—') ; + INSERT INTO blog.post_comment + (id, post_id, author_id, content) + VALUES + ('9116da05-49eb-4053-9199-57f54f92e73a', '019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, 'this is a comment!') + ; + INSERT INTO authn.user_identity (id, username, password_hash, password_salt) VALUES diff --git a/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs new file mode 100644 index 0000000..445c59e --- /dev/null +++ b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs @@ -0,0 +1,5 @@ +using Femto.Common.Domain; + +namespace Femto.Modules.Blog.Application.Commands.AddPostComment; + +public record AddPostCommentCommand(Guid PostId, Guid AuthorId, string Content) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs new file mode 100644 index 0000000..6e52877 --- /dev/null +++ b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs @@ -0,0 +1,20 @@ +using Femto.Common.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Blog.Application.Commands.AddPostComment; + +internal class AddPostCommentCommandHandler(BlogContext context) : ICommandHandler +{ + public async Task Handle(AddPostCommentCommand request, CancellationToken cancellationToken) + { + var post = await context.Posts.SingleOrDefaultAsync( + p => p.Id == request.PostId, + cancellationToken + ); + + if (post is null) + return; + + post.AddComment(request.AuthorId, request.Content); + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs index 2d9c713..25bab45 100644 --- a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs +++ b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs @@ -24,11 +24,9 @@ internal class CreatePostCommandHandler(BlogContext context) media.Width, media.Height )) - .ToList() - ) - { - IsPublic = request.IsPublic is true - }; + .ToList(), + request.IsPublic is true + ); await context.AddAsync(post, cancellationToken); @@ -39,7 +37,8 @@ internal class CreatePostCommandHandler(BlogContext context) post.PostedOn, new PostAuthorDto(post.AuthorId, request.CurrentUser.Username), [], - post.PossibleReactions + post.PossibleReactions, + [] ); } } diff --git a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs index b1defec..630cbe2 100644 --- a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs +++ b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs @@ -24,6 +24,8 @@ internal class PostConfiguration : IEntityTypeConfiguration } ); + table.OwnsMany(p => p.Comments).WithOwner(); + table.Property("PossibleReactionsJson").HasColumnName("possible_reactions"); table.Ignore(e => e.PossibleReactions); diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs new file mode 100644 index 0000000..55ea5e8 --- /dev/null +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs @@ -0,0 +1,3 @@ +namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto; + +public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn); \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs index b8b6a3d..63efede 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs @@ -7,5 +7,6 @@ public record PostDto( DateTimeOffset CreatedAt, PostAuthorDto Author, IList Reactions, - IEnumerable PossibleReactions -); + IEnumerable PossibleReactions, + IList Comments +); \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs index 0af48ee..c57627f 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs @@ -68,7 +68,7 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) """, new { postIds } ); - + var media = loadMediaResult.ToList(); var loadReactionsResult = await conn.QueryAsync( @@ -87,16 +87,36 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) var reactions = loadReactionsResult.ToList(); + var loadCommentsResult = await conn.QueryAsync( + """ + select + pc.id as CommentId, + pc.post_id as PostId, + pc.content as Content, + pc.created_at as PostedOn, + a.username as AuthorName + from blog.post_comment pc + join blog.author a on pc.author_id = a.id + where pc.post_id = ANY (@postIds) + """, + new { postIds } + ); + + var comments = loadCommentsResult.ToList(); + return new GetPostsQueryResult( posts .Select(p => new PostDto( p.PostId, p.Content, - media.Where(m => m.PostId == p.PostId).Select(m => new PostMediaDto( - new Uri(m.MediaUrl), - m.MediaWidth, - m.MediaHeight - )).ToList(), + media + .Where(m => m.PostId == p.PostId) + .Select(m => new PostMediaDto( + new Uri(m.MediaUrl), + m.MediaWidth, + m.MediaHeight + )) + .ToList(), p.PostedOn, new PostAuthorDto(p.AuthorId, p.Username), reactions @@ -105,7 +125,11 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) .ToList(), !string.IsNullOrEmpty(p.PossibleReactions) ? JsonSerializer.Deserialize>(p.PossibleReactions)! - : [] + : [], + comments + .Where(c => c.PostId == p.PostId) + .Select(c => new PostCommentDto(c.AuthorName, c.Content, c.PostedOn)) + .ToList() )) .ToList() ); @@ -137,4 +161,13 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) public string Emoji { get; init; } public DateTimeOffset CreatedAt { get; init; } } + + internal record LoadCommentRow + { + public Guid CommentId { get; init; } + public Guid PostId { get; init; } + public string Content { get; init; } + public DateTimeOffset PostedOn { get; init; } + public string AuthorName { get; init; } + } } diff --git a/Femto.Modules.Blog/Domain/Posts/Post.cs b/Femto.Modules.Blog/Domain/Posts/Post.cs index dc4d937..b5a9b2d 100644 --- a/Femto.Modules.Blog/Domain/Posts/Post.cs +++ b/Femto.Modules.Blog/Domain/Posts/Post.cs @@ -13,7 +13,9 @@ internal class Post : Entity public IList Media { get; private set; } public IList Reactions { get; private set; } = []; - public bool IsPublic { get; set; } + + public IList Comments { get; private set; } = []; + public bool IsPublic { get; private set; } public DateTimeOffset PostedOn { get; private set; } @@ -27,7 +29,7 @@ internal class Post : Entity private Post() { } - public Post(Guid authorId, string content, IList media) + public Post(Guid authorId, string content, IList media, bool isPublic) { this.Id = Guid.CreateVersion7(); this.AuthorId = authorId; @@ -35,6 +37,7 @@ internal class Post : Entity this.Media = media; this.PossibleReactions = AllEmoji.GetRandomEmoji(5); this.PostedOn = DateTimeOffset.UtcNow; + this.IsPublic = isPublic; this.AddDomainEvent(new PostCreated(this)); } @@ -56,4 +59,14 @@ internal class Post : Entity .Reactions.Where(r => r.AuthorId != reactorId || r.Emoji != emoji) .ToList(); } + + public void AddComment(Guid authorId, string content) + { + // XXX just ignore empty comments for now. we may want to upgrade this to an error + // but it is probably suitable to just consider it a no-op + if (string.IsNullOrWhiteSpace(content)) + return; + + this.Comments.Add(new PostComment(authorId, content)); + } } diff --git a/Femto.Modules.Blog/Domain/Posts/PostComment.cs b/Femto.Modules.Blog/Domain/Posts/PostComment.cs new file mode 100644 index 0000000..6f658a8 --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/PostComment.cs @@ -0,0 +1,19 @@ +namespace Femto.Modules.Blog.Domain.Posts; + +internal class PostComment +{ + public Guid Id { get; private set; } + public Guid AuthorId { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + public string Content { get; private set; } + + private PostComment() {} + + public PostComment(Guid authorId, string content) + { + this.Id = Guid.CreateVersion7(); + this.AuthorId = authorId; + this.Content = content; + this.CreatedAt = TimeProvider.System.GetUtcNow(); + } +} \ No newline at end of file