diff --git a/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs b/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs index a03dd93..1cec414 100644 --- a/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs +++ b/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs @@ -1,3 +1,3 @@ namespace Femto.Api.Controllers.Posts.Dto; -public record CreatePostResponse(Guid PostId); \ No newline at end of file +public record CreatePostResponse(PostDto Post); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/GetAllPublicPostsResponse.cs b/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs similarity index 100% rename from Femto.Api/Controllers/Posts/Dto/GetAllPublicPostsResponse.cs rename to Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs diff --git a/Femto.Api/Controllers/Posts/Dto/PostDto.cs b/Femto.Api/Controllers/Posts/Dto/PostDto.cs index 75ec296..a00c8c1 100644 --- a/Femto.Api/Controllers/Posts/Dto/PostDto.cs +++ b/Femto.Api/Controllers/Posts/Dto/PostDto.cs @@ -9,5 +9,6 @@ public record PostDto( string Content, IEnumerable Media, IEnumerable Reactions, - DateTimeOffset CreatedAt + DateTimeOffset CreatedAt, + IEnumerable PossibleReactions ); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs index 98a19b1..81e3a95 100644 --- a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs +++ b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs @@ -1,3 +1,3 @@ namespace Femto.Api.Controllers.Posts.Dto; -public record PostReactionDto(Guid ReactionId, string Emoji, int Count, bool DidReact); \ No newline at end of file +public record PostReactionDto(string Emoji, int Count, bool DidReact); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index 99da1ad..b24fc39 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -11,7 +11,8 @@ namespace Femto.Api.Controllers.Posts; [ApiController] [Route("posts")] -public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) : ControllerBase +public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) + : ControllerBase { [HttpGet] public async Task> LoadPosts( @@ -36,8 +37,9 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current p.PostId, p.Text, p.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), - p.Reactions?.Select(r => new PostReactionDto(r.ReactionId, r.Emoji, r.Count, r.DidReact)), - p.CreatedAt + p.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)), + p.CreatedAt, + p.PossibleReactions )), res.Next ); @@ -50,7 +52,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current CancellationToken cancellationToken ) { - var guid = await blogModule.Command( + var post = await blogModule.Command( new CreatePostCommand( req.AuthorId, req.Content, @@ -65,18 +67,32 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current media.Height ) ), - req.IsPublic + req.IsPublic, + currentUserContext.CurrentUser! ), cancellationToken ); - return new CreatePostResponse(guid); + return new CreatePostResponse( + new PostDto( + new PostAuthorDto(post.Author.AuthorId, post.Author.Username), + post.PostId, + post.Text, + post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), + post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)).ToList(), + post.CreatedAt, + post.PossibleReactions + ) + ); } [HttpDelete("{postId}")] [Authorize] public async Task DeletePost(Guid postId, CancellationToken cancellationToken) { - await blogModule.Command(new DeletePostCommand(postId, currentUserContext.CurrentUser!.Id), cancellationToken); + await blogModule.Command( + new DeletePostCommand(postId, currentUserContext.CurrentUser!.Id), + cancellationToken + ); } } diff --git a/Femto.Database/Migrations/20250518193113_AddUserRole.sql b/Femto.Database/Migrations/20250518193113_AddUserRole.sql index 8199a10..9febe66 100644 --- a/Femto.Database/Migrations/20250518193113_AddUserRole.sql +++ b/Femto.Database/Migrations/20250518193113_AddUserRole.sql @@ -6,4 +6,4 @@ CREATE TABLE authn.user_role user_id uuid REFERENCES authn.user_identity(id), role int, primary key (user_id, role) -); \ No newline at end of file +) \ No newline at end of file diff --git a/Femto.Database/Migrations/20250526220032_AddReactions.sql b/Femto.Database/Migrations/20250526220032_AddReactions.sql index 8c18e2e..a99bbae 100644 --- a/Femto.Database/Migrations/20250526220032_AddReactions.sql +++ b/Femto.Database/Migrations/20250526220032_AddReactions.sql @@ -8,5 +8,6 @@ CREATE TABLE blog.post_reaction ( post_id uuid REFERENCES blog.post(id), author_id uuid REFERENCES blog.author(id), - emoji text not null + emoji text not null, + primary key (post_id, author_id, emoji) ); \ No newline at end of file diff --git a/Femto.Database/Seed/TestDataSeeder.cs b/Femto.Database/Seed/TestDataSeeder.cs index 47d22aa..433f73c 100644 --- a/Femto.Database/Seed/TestDataSeeder.cs +++ b/Femto.Database/Seed/TestDataSeeder.cs @@ -35,12 +35,12 @@ public static class TestDataSeeder ; INSERT INTO blog.post - (id, author_id, content) + (id, author_id, possible_reactions, content) VALUES - ('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'), - ('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'), - ('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id,'Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'), - ('019691a0-4dd3-7e89-909e-94a6fd19a05e', @id,'Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.') + ('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, '["๐Ÿ†", "๐Ÿงข", "๐Ÿง‘๐Ÿพโ€๐ŸŽ“", "๐Ÿฅ•", "๐Ÿ•—"]', 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'), + ('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, '["๐Ÿ†", "๐Ÿงข", "๐Ÿง‘๐Ÿพโ€๐ŸŽ“", "๐Ÿฅ•", "๐Ÿ•—"]', 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'), + ('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '["๐Ÿ†", "๐Ÿงข", "๐Ÿง‘๐Ÿพโ€๐ŸŽ“", "๐Ÿฅ•", "๐Ÿ•—"]', 'Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'), + ('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 @@ -54,6 +54,15 @@ public static class TestDataSeeder ('019691b6-2608-7088-8110-f0f6e35fa633', '019691a0-4dd3-7e89-909e-94a6fd19a05e', 'https://www.pinclipart.com/picdir/big/535-5356059_big-transparent-chungus-png-background-big-chungus-clipart.png', 0) ; + INSERT INTO blog.post_reaction + (post_id, author_id, emoji) + VALUES + ('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, '๐Ÿ†'), + ('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, '๐Ÿ†'), + ('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '๐Ÿง‘๐Ÿพโ€'), + ('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '๐Ÿ•—') + ; + INSERT INTO authn.user_identity (id, username, password_hash, password_salt) VALUES diff --git a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommand.cs b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommand.cs index d10c496..30cc602 100644 --- a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommand.cs +++ b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommand.cs @@ -1,8 +1,22 @@ +using Femto.Common; using Femto.Common.Domain; +using Femto.Modules.Blog.Application.Queries.GetPosts.Dto; namespace Femto.Modules.Blog.Application.Commands.CreatePost; -public record CreatePostCommand(Guid AuthorId, string Content, IEnumerable Media, bool? IsPublic) - : ICommand; +public record CreatePostCommand( + Guid AuthorId, + string Content, + IEnumerable Media, + bool? IsPublic, + CurrentUser CurrentUser +) : ICommand; -public record CreatePostMedia(Guid MediaId, Uri Url, string? Type, int Order, int? Width, int? Height); +public record CreatePostMedia( + Guid MediaId, + Uri Url, + string? Type, + int Order, + int? Width, + int? Height +); diff --git a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs index cda4b2d..bfa6d31 100644 --- a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs +++ b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs @@ -1,12 +1,16 @@ +using Femto.Modules.Blog.Application.Queries.GetPosts.Dto; using Femto.Modules.Blog.Domain.Posts; using MediatR; namespace Femto.Modules.Blog.Application.Commands.CreatePost; internal class CreatePostCommandHandler(BlogContext context) - : IRequestHandler + : IRequestHandler { - public async Task Handle(CreatePostCommand request, CancellationToken cancellationToken) + public async Task Handle( + CreatePostCommand request, + CancellationToken cancellationToken + ) { var post = new Post( request.AuthorId, @@ -22,11 +26,19 @@ internal class CreatePostCommandHandler(BlogContext context) )) .ToList() ); - + post.IsPublic = request.IsPublic is true; await context.AddAsync(post, cancellationToken); - return post.Id; + return new PostDto( + post.Id, + post.Content, + post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)).ToList(), + post.PostedOn, + new PostAuthorDto(post.AuthorId, request.CurrentUser.Username), + [], + post.PossibleReactions + ); } } diff --git a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs index 936c10e..40fbdbf 100644 --- a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs +++ b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs @@ -11,5 +11,10 @@ internal class PostConfiguration : IEntityTypeConfiguration table.ToTable("post"); table.OwnsMany(post => post.Media).WithOwner(); table.OwnsMany(post => post.Reactions).WithOwner(); + + table.Property("PossibleReactionsJson") + .HasColumnName("possible_reactions"); + + table.Ignore(e => e.PossibleReactions); } } diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs index 3f20873..b8b6a3d 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs @@ -6,4 +6,6 @@ public record PostDto( IList Media, DateTimeOffset CreatedAt, PostAuthorDto Author, - IList Reactions); \ No newline at end of file + IList Reactions, + IEnumerable PossibleReactions +); diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs index 7ace8a6..26ae43a 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Dapper; using Femto.Common.Infrastructure.DbConnection; using Femto.Modules.Blog.Application.Queries.GetPosts.Dto; @@ -15,42 +16,29 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) { using var conn = connectionFactory.GetConnection(); - var orderBy = query.Direction is GetPostsDirection.Backward ? "desc" : "asc"; - var pageFilter = query.Direction is GetPostsDirection.Backward ? "<=" : ">="; var username = query.Author; var authorGuid = query.AuthorId; var cursor = query.From; var showPrivate = query.CurrentUserId is not null; - // lang=sql - var sql = $$""" - with page as ( - select blog.post.*, blog.author.username as Username, blog.author.id as AuthorId - from blog.post - inner join blog.author on blog.author.id = blog.post.author_id - where (@username is null or blog.author.username = @username) - and (@showPrivate or blog.post.is_public = true) - and (@authorGuid is null or blog.author.id = @authorGuid) - and (@cursor is null or blog.post.id {{pageFilter}} @cursor) - order by blog.post.id {{orderBy}} - limit @amount - ) - select - page.id as PostId, - page.content as Content, - blog.post_media.url as MediaUrl, - blog.post_media.width as MediaWidth, - blog.post_media.height as MediaHeight, - page.posted_on as PostedOn, - page.Username, - page.AuthorId - from page - left join blog.post_media on blog.post_media.post_id = page.id - order by page.id {{orderBy}} - """; - - var result = await conn.QueryAsync( - sql, + var loadPostsResult = await conn.QueryAsync( + """ + select + blog.post.id as PostId, + blog.post.content as Content, + blog.post.posted_on as PostedOn, + blog.author.username as Username, + blog.author.id as AuthorId, + blog.post.possible_reactions as PossibleReactions + from blog.post + inner join blog.author on blog.author.id = blog.post.author_id + where (@username is null or blog.author.username = @username) + and (@showPrivate or blog.post.is_public = true) + and (@authorGuid is null or blog.author.id = @authorGuid) + and (@cursor is null or blog.post.id <= @cursor) + order by blog.post.id desc + limit @amount + """, new { username, @@ -62,54 +50,115 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) } ); - var rows = result.ToList(); + var loadedPosts = loadPostsResult.ToList(); + var posts = loadedPosts.Take(query.Amount).ToList(); + var next = loadedPosts.LastOrDefault()?.PostId; - var posts = rows.GroupBy(row => row.PostId) - .Select(group => - { - var postId = group.Key; - var post = group.First(); - var media = group - .Select(row => - { - if (row.MediaUrl is not null) - { - return new PostMediaDto( - new Uri(row.MediaUrl), - row.MediaHeight, - row.MediaHeight - ); - } - else - return null; - }) - .OfType() - .ToList(); - return new PostDto( - postId, - post.Content, - media, - post.PostedOn, - new PostAuthorDto(post.AuthorId, post.Username) - ); - }) - .ToList(); + var postIds = posts.Select(p => p.PostId).ToList(); - var next = rows.Count >= query.Amount ? rows.LastOrDefault()?.PostId : null; + var loadMediaResult = await conn.QueryAsync( + """ + select + pm.url as MediaUrl, + pm.type as MediaType, + pm.width as MediaWidth, + pm.height as MediaHeight, + pm.post_id as PostId + from blog.post_media pm where pm.post_id = ANY (@postIds) + order by pm.ordering + """, + new { postIds } + ); - return new GetPostsQueryResult(posts, next); + var loadReactionsResult = await conn.QueryAsync( + """ + select + pr.post_id as PostId, + pr.author_id as AuthorId, + pr.emoji as Emoji + from blog.post_reaction pr + where pr.post_id = ANY (@postIds) + """, + new { postIds } + ); + + var reactionsByPostId = loadReactionsResult + .GroupBy(r => r.PostId) + .ToDictionary( + group => group.Key, + group => + group + .GroupBy( + r => r.Emoji, + (key, g) => + { + var reactions = g.ToList(); + return new PostReactionDto( + key, + reactions.Count, + reactions.Any(r => r.AuthorId == query.CurrentUserId) + ); + } + ) + .ToList() + ); + + var mediaByPostId = loadMediaResult + .GroupBy(m => m.PostId) + .ToDictionary( + g => g.Key, + g => + g.Select(m => new PostMediaDto( + new Uri(m.MediaUrl), + m.MediaWidth, + m.MediaHeight + )) + .ToList() + ); + + return new GetPostsQueryResult( + posts + .Select(p => new PostDto( + p.PostId, + p.Content, + mediaByPostId.TryGetValue(p.PostId, out var mediaDtos) ? mediaDtos : [], + p.PostedOn, + new PostAuthorDto(p.AuthorId, p.Username), + reactionsByPostId.TryGetValue(p.PostId, out var reactionDtos) + ? reactionDtos.ToList() + : [], + !string.IsNullOrEmpty(p.PossibleReactions) + ? JsonSerializer.Deserialize>(p.PossibleReactions)! + : [] + )) + .ToList(), + next + ); } - internal class QueryResult + internal record LoadPostRow { - public Guid PostId { get; set; } - public string Content { get; set; } - public string? MediaUrl { get; set; } - public string? MediaType { get; set; } - public int? MediaWidth { get; set; } - public int? MediaHeight { get; set; } - public DateTimeOffset PostedOn { get; set; } - public Guid AuthorId { get; set; } - public string Username { get; set; } + public Guid PostId { get; init; } + public string Content { get; init; } + public DateTimeOffset PostedOn { get; init; } + public string Username { get; init; } + public Guid AuthorId { get; init; } + public string? PossibleReactions { get; init; } + } + + internal record LoadMediaRow + { + public string MediaUrl { get; init; } + public string? MediaType { get; init; } + public int? MediaWidth { get; init; } + public int? MediaHeight { get; init; } + public Guid PostId { get; init; } + } + + internal record LoadReactionRow + { + public Guid PostId { get; init; } + public Guid AuthorId { get; init; } + public string Emoji { get; init; } } } diff --git a/Femto.Modules.Blog/Domain/Posts/Post.cs b/Femto.Modules.Blog/Domain/Posts/Post.cs index 0279dcb..2244cca 100644 --- a/Femto.Modules.Blog/Domain/Posts/Post.cs +++ b/Femto.Modules.Blog/Domain/Posts/Post.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Femto.Common.Domain; using Femto.Modules.Blog.Domain.Posts.Events; using Femto.Modules.Blog.Emoji; @@ -13,6 +14,16 @@ internal class Post : Entity public IList Reactions { get; private set; } = []; public bool IsPublic { get; set; } + + public DateTimeOffset PostedOn { get; private set; } + + private string PossibleReactionsJson { get; set; } = null!; + + public IEnumerable PossibleReactions + { + get => JsonSerializer.Deserialize>(this.PossibleReactionsJson)!; + init => PossibleReactionsJson = JsonSerializer.Serialize(value); + } private Post() { } @@ -22,13 +33,9 @@ internal class Post : Entity this.AuthorId = authorId; this.Content = content; this.Media = media; - - this.Reactions = AllEmoji - .GetRandomEmoji(5) - .Select(emoji => new PostReaction(emoji, 0)) - .ToList(); + this.PossibleReactions = AllEmoji.GetRandomEmoji(5); + this.PostedOn = DateTimeOffset.UtcNow; this.AddDomainEvent(new PostCreated(this)); } - } diff --git a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs index beb24b8..ea8ab16 100644 --- a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs +++ b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs @@ -2,15 +2,14 @@ namespace Femto.Modules.Blog.Domain.Posts; public class PostReaction { - public Guid Id { get; private set; } + public Guid AuthorId { get; private set; } + public Guid PostId { get; private set; } public string Emoji { get; private set; } = null!; - public int Count { get; private set; } - - public PostReaction(string emoji, int count) + public PostReaction(Guid authorId, Guid postId, string emoji) { - this.Id = Guid.CreateVersion7(); + this.AuthorId = authorId; + this.PostId = postId; this.Emoji = emoji; - this.Count = count; } private PostReaction() { }