refactor post reactions
This commit is contained in:
parent
2519fc77d2
commit
5379d29c5f
13 changed files with 129 additions and 97 deletions
3
Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs
Normal file
3
Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
|
public record GetPostResponse(PostDto Post);
|
|
@ -3,4 +3,4 @@ using JetBrains.Annotations;
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public record LoadPostsResponse(IEnumerable<PostDto> Posts, Guid? Next);
|
public record LoadPostsResponse(IEnumerable<PostDto> Posts);
|
|
@ -11,4 +11,16 @@ public record PostDto(
|
||||||
IEnumerable<PostReactionDto> Reactions,
|
IEnumerable<PostReactionDto> Reactions,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
IEnumerable<string> PossibleReactions
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
public record PostReactionDto(string Emoji, int Count, bool DidReact);
|
public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);
|
|
@ -25,7 +25,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
|
||||||
var res = await blogModule.Query(
|
var res = await blogModule.Query(
|
||||||
new GetPostsQuery(currentUserContext.CurrentUser?.Id)
|
new GetPostsQuery(currentUserContext.CurrentUser?.Id)
|
||||||
{
|
{
|
||||||
From = searchParams.From,
|
After = searchParams.From,
|
||||||
Amount = searchParams.Amount ?? 20,
|
Amount = searchParams.Amount ?? 20,
|
||||||
AuthorId = searchParams.AuthorId,
|
AuthorId = searchParams.AuthorId,
|
||||||
Author = searchParams.Author,
|
Author = searchParams.Author,
|
||||||
|
@ -33,18 +33,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
return new LoadPostsResponse(
|
return new LoadPostsResponse(res.Posts.Select(PostDto.FromModel));
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
@ -75,17 +64,26 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
return new CreatePostResponse(
|
return new CreatePostResponse(PostDto.FromModel(post));
|
||||||
new PostDto(
|
}
|
||||||
new PostAuthorDto(post.Author.AuthorId, post.Author.Username),
|
|
||||||
post.PostId,
|
[HttpGet("{postId}")]
|
||||||
post.Text,
|
public async Task<ActionResult<GetPostResponse>> GetPost(
|
||||||
post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)),
|
Guid postId,
|
||||||
post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)).ToList(),
|
CancellationToken cancellationToken
|
||||||
post.CreatedAt,
|
)
|
||||||
post.PossibleReactions
|
{
|
||||||
)
|
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}")]
|
[HttpDelete("{postId}")]
|
||||||
|
@ -100,24 +98,37 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
|
||||||
|
|
||||||
[HttpPost("{postId}/reactions")]
|
[HttpPost("{postId}/reactions")]
|
||||||
[Authorize]
|
[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!;
|
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();
|
return this.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{postId}/reactions")]
|
[HttpDelete("{postId}/reactions")]
|
||||||
[Authorize]
|
[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!;
|
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();
|
return this.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "https://stinkpad:7269;http://0.0.0.0:5181",
|
"applicationUrl": "https://localhost:7269",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- Migration: AddTimestampToReaction
|
||||||
|
-- Created at: 10/08/2025 15:21:32
|
||||||
|
alter table blog.post_reaction
|
||||||
|
add column created_at timestamptz;
|
|
@ -1,3 +1,3 @@
|
||||||
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
||||||
|
|
||||||
public record GetPostsQueryResult(IList<PostDto> Posts, Guid? Next);
|
public record GetPostsQueryResult(IList<PostDto> Posts);
|
|
@ -1,3 +1,3 @@
|
||||||
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
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);
|
||||||
|
|
|
@ -3,22 +3,27 @@ using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Application.Queries.GetPosts;
|
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 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 int Amount { get; init; } = 20;
|
||||||
public Guid? AuthorId { get; init; }
|
public Guid? AuthorId { get; init; }
|
||||||
public string? Author { get; init; }
|
public string? Author { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
public GetPostsQuery(Guid postId, Guid? currentUserId) : this(currentUserId)
|
||||||
/// Default is to load in reverse chronological order
|
{
|
||||||
/// TODO this is not exposed on the client as it probably wouldn't work that well
|
this.PostId = postId;
|
||||||
/// </summary>
|
}
|
||||||
public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public enum GetPostsDirection
|
|
||||||
{
|
|
||||||
Forward,
|
|
||||||
Backward,
|
|
||||||
}
|
|
|
@ -18,7 +18,7 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
|
|
||||||
var username = query.Author;
|
var username = query.Author;
|
||||||
var authorGuid = query.AuthorId;
|
var authorGuid = query.AuthorId;
|
||||||
var cursor = query.From;
|
var cursor = query.After;
|
||||||
var showPrivate = query.CurrentUserId is not null;
|
var showPrivate = query.CurrentUserId is not null;
|
||||||
|
|
||||||
var loadPostsResult = await conn.QueryAsync<LoadPostRow>(
|
var loadPostsResult = await conn.QueryAsync<LoadPostRow>(
|
||||||
|
@ -33,9 +33,10 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
from blog.post
|
from blog.post
|
||||||
inner join blog.author on blog.author.id = blog.post.author_id
|
inner join blog.author on blog.author.id = blog.post.author_id
|
||||||
where (@username is null or blog.author.username = @username)
|
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 (@showPrivate or blog.post.is_public = true)
|
||||||
and (@authorGuid is null or blog.author.id = @authorGuid)
|
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
|
order by blog.post.id desc
|
||||||
limit @amount
|
limit @amount
|
||||||
""",
|
""",
|
||||||
|
@ -44,15 +45,13 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
username,
|
username,
|
||||||
authorGuid,
|
authorGuid,
|
||||||
cursor,
|
cursor,
|
||||||
// load an extra one to take for the cursor
|
amount = query.PostId is not null ? 1 : query.Amount,
|
||||||
amount = query.Amount + 1,
|
|
||||||
showPrivate,
|
showPrivate,
|
||||||
|
postId = query.PostId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
var loadedPosts = loadPostsResult.ToList();
|
var posts = loadPostsResult.ToList();
|
||||||
var posts = loadedPosts.Take(query.Amount).ToList();
|
|
||||||
var next = loadedPosts.LastOrDefault()?.PostId;
|
|
||||||
|
|
||||||
var postIds = posts.Select(p => p.PostId).ToList();
|
var postIds = posts.Select(p => p.PostId).ToList();
|
||||||
|
|
||||||
|
@ -69,70 +68,46 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
""",
|
""",
|
||||||
new { postIds }
|
new { postIds }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
var media = loadMediaResult.ToList();
|
||||||
|
|
||||||
var loadReactionsResult = await conn.QueryAsync<LoadReactionRow>(
|
var loadReactionsResult = await conn.QueryAsync<LoadReactionRow>(
|
||||||
"""
|
"""
|
||||||
select
|
select
|
||||||
pr.post_id as PostId,
|
pr.post_id as PostId,
|
||||||
pr.author_id as AuthorId,
|
a.username as AuthorName,
|
||||||
pr.emoji as Emoji
|
pr.emoji as Emoji,
|
||||||
|
pr.created_at as CreatedOn
|
||||||
from blog.post_reaction pr
|
from blog.post_reaction pr
|
||||||
|
join blog.author a on a.id = pr.author_id
|
||||||
where pr.post_id = ANY (@postIds)
|
where pr.post_id = ANY (@postIds)
|
||||||
""",
|
""",
|
||||||
new { postIds }
|
new { postIds }
|
||||||
);
|
);
|
||||||
|
|
||||||
var reactionsByPostId = loadReactionsResult
|
var reactions = loadReactionsResult.ToList();
|
||||||
.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(
|
return new GetPostsQueryResult(
|
||||||
posts
|
posts
|
||||||
.Select(p => new PostDto(
|
.Select(p => new PostDto(
|
||||||
p.PostId,
|
p.PostId,
|
||||||
p.Content,
|
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,
|
p.PostedOn,
|
||||||
new PostAuthorDto(p.AuthorId, p.Username),
|
new PostAuthorDto(p.AuthorId, p.Username),
|
||||||
reactionsByPostId.TryGetValue(p.PostId, out var reactionDtos)
|
reactions
|
||||||
? reactionDtos.ToList()
|
.Where(r => r.PostId == p.PostId)
|
||||||
: [],
|
.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.CreatedAt))
|
||||||
|
.ToList(),
|
||||||
!string.IsNullOrEmpty(p.PossibleReactions)
|
!string.IsNullOrEmpty(p.PossibleReactions)
|
||||||
? JsonSerializer.Deserialize<IEnumerable<string>>(p.PossibleReactions)!
|
? JsonSerializer.Deserialize<IEnumerable<string>>(p.PossibleReactions)!
|
||||||
: []
|
: []
|
||||||
))
|
))
|
||||||
.ToList(),
|
.ToList()
|
||||||
next
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -158,7 +133,8 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
internal record LoadReactionRow
|
internal record LoadReactionRow
|
||||||
{
|
{
|
||||||
public Guid PostId { get; init; }
|
public Guid PostId { get; init; }
|
||||||
public Guid AuthorId { get; init; }
|
public string AuthorName { get; init; }
|
||||||
public string Emoji { get; init; }
|
public string Emoji { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,11 +5,13 @@ public class PostReaction
|
||||||
public Guid AuthorId { get; private set; }
|
public Guid AuthorId { get; private set; }
|
||||||
public Guid PostId { get; private set; }
|
public Guid PostId { get; private set; }
|
||||||
public string Emoji { get; private set; } = null!;
|
public string Emoji { get; private set; } = null!;
|
||||||
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
public PostReaction(Guid authorId, Guid postId, string emoji)
|
public PostReaction(Guid authorId, Guid postId, string emoji)
|
||||||
{
|
{
|
||||||
this.AuthorId = authorId;
|
this.AuthorId = authorId;
|
||||||
this.PostId = postId;
|
this.PostId = postId;
|
||||||
this.Emoji = emoji;
|
this.Emoji = emoji;
|
||||||
|
this.CreatedAt = TimeProvider.System.GetUtcNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
private PostReaction() { }
|
private PostReaction() { }
|
||||||
|
|
19
scripts/push-db.bash
Normal file
19
scripts/push-db.bash
Normal 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"
|
Loading…
Add table
Add a link
Reference in a new issue