refactor post reactions

This commit is contained in:
john 2025-08-10 18:12:16 +02:00
parent 2519fc77d2
commit 5379d29c5f
13 changed files with 129 additions and 97 deletions

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record GetPostResponse(PostDto Post);

View file

@ -3,4 +3,4 @@ using JetBrains.Annotations;
namespace Femto.Api.Controllers.Posts.Dto;
[PublicAPI]
public record LoadPostsResponse(IEnumerable<PostDto> Posts, Guid? Next);
public record LoadPostsResponse(IEnumerable<PostDto> Posts);

View file

@ -11,4 +11,16 @@ public record PostDto(
IEnumerable<PostReactionDto> Reactions,
DateTimeOffset CreatedAt,
IEnumerable<string> PossibleReactions
)
{
public static PostDto FromModel(Modules.Blog.Application.Queries.GetPosts.Dto.PostDto post) =>
new(
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.AuthorName, r.ReactedOn)),
post.CreatedAt,
post.PossibleReactions
);
}

View file

@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record PostReactionDto(string Emoji, int Count, bool DidReact);
public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);

View file

@ -25,7 +25,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
var res = await blogModule.Query(
new GetPostsQuery(currentUserContext.CurrentUser?.Id)
{
From = searchParams.From,
After = searchParams.From,
Amount = searchParams.Amount ?? 20,
AuthorId = searchParams.AuthorId,
Author = searchParams.Author,
@ -33,18 +33,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
cancellationToken
);
return new LoadPostsResponse(
res.Posts.Select(p => new PostDto(
new PostAuthorDto(p.Author.AuthorId, p.Author.Username),
p.PostId,
p.Text,
p.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)),
p.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)),
p.CreatedAt,
p.PossibleReactions
)),
res.Next
);
return new LoadPostsResponse(res.Posts.Select(PostDto.FromModel));
}
[HttpPost]
@ -75,17 +64,26 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
cancellationToken
);
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
return new CreatePostResponse(PostDto.FromModel(post));
}
[HttpGet("{postId}")]
public async Task<ActionResult<GetPostResponse>> GetPost(
Guid postId,
CancellationToken cancellationToken
)
{
var result = await blogModule.Query(
new GetPostsQuery(postId, currentUserContext.CurrentUser?.Id),
cancellationToken
);
var post = result.Posts.SingleOrDefault();
if (post is null)
return NotFound();
return new GetPostResponse(PostDto.FromModel(post));
}
[HttpDelete("{postId}")]
@ -100,24 +98,37 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
[HttpPost("{postId}/reactions")]
[Authorize]
public async Task<ActionResult> AddPostReaction(Guid postId, [FromBody] AddPostReactionRequest request, CancellationToken cancellationToken)
public async Task<ActionResult> AddPostReaction(
Guid postId,
[FromBody] AddPostReactionRequest request,
CancellationToken cancellationToken
)
{
var currentUser = currentUserContext.CurrentUser!;
await blogModule.Command(new AddPostReactionCommand(postId, request.Emoji, currentUser.Id), cancellationToken);
await blogModule.Command(
new AddPostReactionCommand(postId, request.Emoji, currentUser.Id),
cancellationToken
);
return this.Ok();
}
[HttpDelete("{postId}/reactions")]
[Authorize]
public async Task<ActionResult> DeletePostReaction(Guid postId, [FromBody] DeletePostReactionRequest request, CancellationToken cancellationToken)
public async Task<ActionResult> DeletePostReaction(
Guid postId,
[FromBody] DeletePostReactionRequest request,
CancellationToken cancellationToken
)
{
var currentUser = currentUserContext.CurrentUser!;
await blogModule.Command(new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id), cancellationToken);
await blogModule.Command(
new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id),
cancellationToken
);
return this.Ok();
}
}

View file

@ -14,7 +14,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://stinkpad:7269;http://0.0.0.0:5181",
"applicationUrl": "https://localhost:7269",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View file

@ -0,0 +1,4 @@
-- Migration: AddTimestampToReaction
-- Created at: 10/08/2025 15:21:32
alter table blog.post_reaction
add column created_at timestamptz;

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record GetPostsQueryResult(IList<PostDto> Posts, Guid? Next);
public record GetPostsQueryResult(IList<PostDto> Posts);

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record PostReactionDto(string Emoji, int Count, bool DidReact);
public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);

View file

@ -3,22 +3,27 @@ using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
namespace Femto.Modules.Blog.Application.Queries.GetPosts;
/// <summary>
/// Get posts in reverse chronological order
/// </summary>
/// <param name="CurrentUserId"></param>
public record GetPostsQuery(Guid? CurrentUserId) : IQuery<GetPostsQueryResult>
{
public Guid? From { get; init; }
/// <summary>
/// Id of the specific post to load. If specified, After and Amount are ignored
/// </summary>
public Guid? PostId { get; }
/// <summary>
/// If specified, loads posts from after the given Id. Used for paging
/// </summary>
public Guid? After { get; init; }
public int Amount { get; init; } = 20;
public Guid? AuthorId { get; init; }
public string? Author { get; init; }
/// <summary>
/// Default is to load in reverse chronological order
/// TODO this is not exposed on the client as it probably wouldn't work that well
/// </summary>
public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward;
}
public enum GetPostsDirection
public GetPostsQuery(Guid postId, Guid? currentUserId) : this(currentUserId)
{
Forward,
Backward,
this.PostId = postId;
}
}

View file

@ -18,7 +18,7 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
var username = query.Author;
var authorGuid = query.AuthorId;
var cursor = query.From;
var cursor = query.After;
var showPrivate = query.CurrentUserId is not null;
var loadPostsResult = await conn.QueryAsync<LoadPostRow>(
@ -33,9 +33,10 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
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 (@postId is null or blog.post.id = @postId)
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)
and (@cursor is null or blog.post.id < @cursor)
order by blog.post.id desc
limit @amount
""",
@ -44,15 +45,13 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
username,
authorGuid,
cursor,
// load an extra one to take for the cursor
amount = query.Amount + 1,
amount = query.PostId is not null ? 1 : query.Amount,
showPrivate,
postId = query.PostId,
}
);
var loadedPosts = loadPostsResult.ToList();
var posts = loadedPosts.Take(query.Amount).ToList();
var next = loadedPosts.LastOrDefault()?.PostId;
var posts = loadPostsResult.ToList();
var postIds = posts.Select(p => p.PostId).ToList();
@ -70,69 +69,45 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
new { postIds }
);
var media = loadMediaResult.ToList();
var loadReactionsResult = await conn.QueryAsync<LoadReactionRow>(
"""
select
pr.post_id as PostId,
pr.author_id as AuthorId,
pr.emoji as Emoji
a.username as AuthorName,
pr.emoji as Emoji,
pr.created_at as CreatedOn
from blog.post_reaction pr
join blog.author a on a.id = pr.author_id
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()
);
var reactions = loadReactionsResult.ToList();
return new GetPostsQueryResult(
posts
.Select(p => new PostDto(
p.PostId,
p.Content,
mediaByPostId.TryGetValue(p.PostId, out var mediaDtos) ? mediaDtos : [],
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),
reactionsByPostId.TryGetValue(p.PostId, out var reactionDtos)
? reactionDtos.ToList()
: [],
reactions
.Where(r => r.PostId == p.PostId)
.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.CreatedAt))
.ToList(),
!string.IsNullOrEmpty(p.PossibleReactions)
? JsonSerializer.Deserialize<IEnumerable<string>>(p.PossibleReactions)!
: []
))
.ToList(),
next
.ToList()
);
}
@ -158,7 +133,8 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
internal record LoadReactionRow
{
public Guid PostId { get; init; }
public Guid AuthorId { get; init; }
public string AuthorName { get; init; }
public string Emoji { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
}

View file

@ -5,11 +5,13 @@ public class PostReaction
public Guid AuthorId { get; private set; }
public Guid PostId { get; private set; }
public string Emoji { get; private set; } = null!;
public DateTimeOffset CreatedAt { get; private set; }
public PostReaction(Guid authorId, Guid postId, string emoji)
{
this.AuthorId = authorId;
this.PostId = postId;
this.Emoji = emoji;
this.CreatedAt = TimeProvider.System.GetUtcNow();
}
private PostReaction() { }

19
scripts/push-db.bash Normal file
View file

@ -0,0 +1,19 @@
# Check if connection string is provided
if [ $# -lt 1 ]; then
echo "Usage: $0 <connection-string>"
echo "Example: $0 'Host=localhost;Database=mydb;Username=user;Password=pass'"
exit 1
fi
# Get the connection string from the first argument
CONNECTION_STRING="$1"
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Navigate to the parent directory (assuming migrator is built there)
cd "$SCRIPT_DIR/.."
# Run the migrator with the 'up' command and provided connection string
dotnet run --project . -- up --connection-string "$CONNECTION_STRING"