This commit is contained in:
john 2025-08-10 19:57:47 +02:00
parent cbf67bf5f1
commit ce3888f1ab
15 changed files with 162 additions and 21 deletions

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record AddPostCommentRequest(Guid AuthorId, string Content);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);

View file

@ -10,7 +10,8 @@ public record PostDto(
IEnumerable<PostMediaDto> Media, IEnumerable<PostMediaDto> Media,
IEnumerable<PostReactionDto> Reactions, IEnumerable<PostReactionDto> Reactions,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
IEnumerable<string> PossibleReactions IEnumerable<string> PossibleReactions,
IEnumerable<PostCommentDto> Comments
) )
{ {
public static PostDto FromModel(Modules.Blog.Application.Queries.GetPosts.Dto.PostDto post) => 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.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)),
post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.ReactedOn)), post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.ReactedOn)),
post.CreatedAt, post.CreatedAt,
post.PossibleReactions post.PossibleReactions,
post.Comments.Select(c => new PostCommentDto(c.Author, c.Content, c.PostedOn))
); );
} }

View file

@ -1,6 +1,7 @@
using Femto.Api.Controllers.Posts.Dto; using Femto.Api.Controllers.Posts.Dto;
using Femto.Common; using Femto.Common;
using Femto.Modules.Blog.Application; 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.AddPostReaction;
using Femto.Modules.Blog.Application.Commands.ClearPostReaction; using Femto.Modules.Blog.Application.Commands.ClearPostReaction;
using Femto.Modules.Blog.Application.Commands.CreatePost; using Femto.Modules.Blog.Application.Commands.CreatePost;
@ -13,7 +14,7 @@ namespace Femto.Api.Controllers.Posts;
[ApiController] [ApiController]
[Route("posts")] [Route("posts")]
public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext, IAuthorizationService auth)
: ControllerBase : ControllerBase
{ {
[HttpGet] [HttpGet]
@ -131,4 +132,23 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
return this.Ok(); return this.Ok();
} }
[HttpPost("{postId}/comments")]
[Authorize]
public async Task<ActionResult> 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();
}
} }

View file

@ -0,0 +1,11 @@
-- Migration: AddCommentToPost
-- Created at: 10/08/2025 17:22:42
CREATE TABLE blog.post_comment
(
id uuid PRIMARY KEY,
post_id uuid REFERENCES blog.post(id),
author_id uuid REFERENCES blog.author(id),
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)

View file

@ -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.') ('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 INSERT INTO blog.post_media
(id, post_id, url, ordering) (id, post_id, url, ordering)
VALUES VALUES
@ -63,6 +64,12 @@ public static class TestDataSeeder
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '🕗') ('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 INSERT INTO authn.user_identity
(id, username, password_hash, password_salt) (id, username, password_hash, password_salt)
VALUES VALUES

View file

@ -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;

View file

@ -0,0 +1,20 @@
using Femto.Common.Domain;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Blog.Application.Commands.AddPostComment;
internal class AddPostCommentCommandHandler(BlogContext context) : ICommandHandler<AddPostCommentCommand>
{
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);
}
}

View file

@ -24,11 +24,9 @@ internal class CreatePostCommandHandler(BlogContext context)
media.Width, media.Width,
media.Height media.Height
)) ))
.ToList() .ToList(),
) request.IsPublic is true
{ );
IsPublic = request.IsPublic is true
};
await context.AddAsync(post, cancellationToken); await context.AddAsync(post, cancellationToken);
@ -39,7 +37,8 @@ internal class CreatePostCommandHandler(BlogContext context)
post.PostedOn, post.PostedOn,
new PostAuthorDto(post.AuthorId, request.CurrentUser.Username), new PostAuthorDto(post.AuthorId, request.CurrentUser.Username),
[], [],
post.PossibleReactions post.PossibleReactions,
[]
); );
} }
} }

View file

@ -24,6 +24,8 @@ internal class PostConfiguration : IEntityTypeConfiguration<Post>
} }
); );
table.OwnsMany(p => p.Comments).WithOwner();
table.Property<string>("PossibleReactionsJson").HasColumnName("possible_reactions"); table.Property<string>("PossibleReactionsJson").HasColumnName("possible_reactions");
table.Ignore(e => e.PossibleReactions); table.Ignore(e => e.PossibleReactions);

View file

@ -0,0 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);

View file

@ -7,5 +7,6 @@ public record PostDto(
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
PostAuthorDto Author, PostAuthorDto Author,
IList<PostReactionDto> Reactions, IList<PostReactionDto> Reactions,
IEnumerable<string> PossibleReactions IEnumerable<string> PossibleReactions,
); IList<PostCommentDto> Comments
);

View file

@ -68,7 +68,7 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
""", """,
new { postIds } new { postIds }
); );
var media = loadMediaResult.ToList(); var media = loadMediaResult.ToList();
var loadReactionsResult = await conn.QueryAsync<LoadReactionRow>( var loadReactionsResult = await conn.QueryAsync<LoadReactionRow>(
@ -87,16 +87,36 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
var reactions = loadReactionsResult.ToList(); var reactions = loadReactionsResult.ToList();
var loadCommentsResult = await conn.QueryAsync<LoadCommentRow>(
"""
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( return new GetPostsQueryResult(
posts posts
.Select(p => new PostDto( .Select(p => new PostDto(
p.PostId, p.PostId,
p.Content, p.Content,
media.Where(m => m.PostId == p.PostId).Select(m => new PostMediaDto( media
new Uri(m.MediaUrl), .Where(m => m.PostId == p.PostId)
m.MediaWidth, .Select(m => new PostMediaDto(
m.MediaHeight new Uri(m.MediaUrl),
)).ToList(), m.MediaWidth,
m.MediaHeight
))
.ToList(),
p.PostedOn, p.PostedOn,
new PostAuthorDto(p.AuthorId, p.Username), new PostAuthorDto(p.AuthorId, p.Username),
reactions reactions
@ -105,7 +125,11 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
.ToList(), .ToList(),
!string.IsNullOrEmpty(p.PossibleReactions) !string.IsNullOrEmpty(p.PossibleReactions)
? JsonSerializer.Deserialize<IEnumerable<string>>(p.PossibleReactions)! ? JsonSerializer.Deserialize<IEnumerable<string>>(p.PossibleReactions)!
: [] : [],
comments
.Where(c => c.PostId == p.PostId)
.Select(c => new PostCommentDto(c.AuthorName, c.Content, c.PostedOn))
.ToList()
)) ))
.ToList() .ToList()
); );
@ -137,4 +161,13 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
public string Emoji { get; init; } public string Emoji { get; init; }
public DateTimeOffset CreatedAt { 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; }
}
} }

View file

@ -13,7 +13,9 @@ internal class Post : Entity
public IList<PostMedia> Media { get; private set; } public IList<PostMedia> Media { get; private set; }
public IList<PostReaction> Reactions { get; private set; } = []; public IList<PostReaction> Reactions { get; private set; } = [];
public bool IsPublic { get; set; }
public IList<PostComment> Comments { get; private set; } = [];
public bool IsPublic { get; private set; }
public DateTimeOffset PostedOn { get; private set; } public DateTimeOffset PostedOn { get; private set; }
@ -27,7 +29,7 @@ internal class Post : Entity
private Post() { } private Post() { }
public Post(Guid authorId, string content, IList<PostMedia> media) public Post(Guid authorId, string content, IList<PostMedia> media, bool isPublic)
{ {
this.Id = Guid.CreateVersion7(); this.Id = Guid.CreateVersion7();
this.AuthorId = authorId; this.AuthorId = authorId;
@ -35,6 +37,7 @@ internal class Post : Entity
this.Media = media; this.Media = media;
this.PossibleReactions = AllEmoji.GetRandomEmoji(5); this.PossibleReactions = AllEmoji.GetRandomEmoji(5);
this.PostedOn = DateTimeOffset.UtcNow; this.PostedOn = DateTimeOffset.UtcNow;
this.IsPublic = isPublic;
this.AddDomainEvent(new PostCreated(this)); this.AddDomainEvent(new PostCreated(this));
} }
@ -56,4 +59,14 @@ internal class Post : Entity
.Reactions.Where(r => r.AuthorId != reactorId || r.Emoji != emoji) .Reactions.Where(r => r.AuthorId != reactorId || r.Emoji != emoji)
.ToList(); .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));
}
} }

View file

@ -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();
}
}