Provide real user and posts data for the thinking

This commit is contained in:
2025-10-25 17:58:58 +08:00
parent 40325c6df5
commit 93f7dfd379
17 changed files with 898 additions and 72 deletions

View File

@@ -12,7 +12,7 @@ namespace DysonNetwork.Sphere.Activity;
public class ActivityService(
AppDatabase db,
Publisher.PublisherService pub,
PostService ps,
Post.PostService ps,
RemoteRealmService rs,
DiscoveryService ds,
AccountService.AccountServiceClient accounts

View File

@@ -125,7 +125,7 @@ public class PostController(
if (realm != null)
query = query.Where(p => p.RealmId == realm.Id);
if (type != null)
query = query.Where(p => p.Type == (PostType)type);
query = query.Where(p => p.Type == (Shared.Models.PostType)type);
if (categories is { Count: > 0 })
query = query.Where(p => p.Categories.Any(c => categories.Contains(c.Slug)));
if (tags is { Count: > 0 })
@@ -139,10 +139,10 @@ public class PostController(
switch (pinned)
{
case true when realm != null:
query = query.Where(p => p.PinMode == PostPinMode.RealmPage);
query = query.Where(p => p.PinMode == Shared.Models.PostPinMode.RealmPage);
break;
case true when publisher != null:
query = query.Where(p => p.PinMode == PostPinMode.PublisherPage);
query = query.Where(p => p.PinMode == Shared.Models.PostPinMode.PublisherPage);
break;
case true:
return BadRequest(
@@ -360,7 +360,7 @@ public class PostController(
var now = SystemClock.Instance.GetCurrentInstant();
var posts = await db.Posts
.Where(e => e.RepliedPostId == id && e.PinMode == PostPinMode.ReplyPage)
.Where(e => e.RepliedPostId == id && e.PinMode == Shared.Models.PostPinMode.ReplyPage)
.OrderByDescending(p => p.CreatedAt)
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.ToListAsync();
@@ -425,9 +425,9 @@ public class PostController(
[MaxLength(4096)] public string? Description { get; set; }
[MaxLength(1024)] public string? Slug { get; set; }
public string? Content { get; set; }
public PostVisibility? Visibility { get; set; } = PostVisibility.Public;
public PostType? Type { get; set; }
public PostEmbedView? EmbedView { get; set; }
public Shared.Models.PostVisibility? Visibility { get; set; } = Shared.Models.PostVisibility.Public;
public Shared.Models.PostType? Type { get; set; }
public Shared.Models.PostEmbedView? EmbedView { get; set; }
[MaxLength(16)] public List<string>? Tags { get; set; }
[MaxLength(8)] public List<string>? Categories { get; set; }
[MaxLength(32)] public List<string>? Attachments { get; set; }
@@ -477,9 +477,9 @@ public class PostController(
Description = request.Description,
Slug = request.Slug,
Content = request.Content,
Visibility = request.Visibility ?? PostVisibility.Public,
Visibility = request.Visibility ?? Shared.Models.PostVisibility.Public,
PublishedAt = request.PublishedAt,
Type = request.Type ?? PostType.Moment,
Type = request.Type ?? Shared.Models.PostType.Moment,
Meta = request.Meta,
EmbedView = request.EmbedView,
Publisher = publisher,
@@ -565,7 +565,7 @@ public class PostController(
public class PostReactionRequest
{
[MaxLength(256)] public string Symbol { get; set; } = null!;
public PostReactionAttitude Attitude { get; set; }
public Shared.Models.PostReactionAttitude Attitude { get; set; }
}
public static readonly List<string> ReactionsAllowedDefault =
@@ -638,7 +638,7 @@ public class PostController(
public class PostAwardRequest
{
public decimal Amount { get; set; }
public PostReactionAttitude Attitude { get; set; }
public Shared.Models.PostReactionAttitude Attitude { get; set; }
[MaxLength(4096)] public string? Message { get; set; }
}
@@ -671,7 +671,7 @@ public class PostController(
public async Task<ActionResult<PostAwardResponse>> AwardPost(Guid id, [FromBody] PostAwardRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (request.Attitude == PostReactionAttitude.Neutral)
if (request.Attitude == Shared.Models.PostReactionAttitude.Neutral)
return BadRequest("You cannot create a neutral post award");
var friendsResponse =
@@ -714,7 +714,7 @@ public class PostController(
public class PostPinRequest
{
[Required] public PostPinMode Mode { get; set; }
[Required] public Shared.Models.PostPinMode Mode { get; set; }
}
[HttpPost("{id:guid}/pin")]
@@ -734,7 +734,7 @@ public class PostController(
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You are not an editor of this publisher");
if (request.Mode == PostPinMode.RealmPage && post.RealmId != null)
if (request.Mode == Shared.Models.PostPinMode.RealmPage && post.RealmId != null)
{
if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, new List<int> { RealmMemberRole.Moderator }))
return StatusCode(403, "You are not a moderator of this realm");
@@ -782,7 +782,7 @@ public class PostController(
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You are not an editor of this publisher");
if (post is { PinMode: PostPinMode.RealmPage, RealmId: not null })
if (post is { PinMode: Shared.Models.PostPinMode.RealmPage, RealmId: not null })
{
if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, new List<int> { RealmMemberRole.Moderator }))
return StatusCode(403, "You are not a moderator of this realm");

View File

@@ -290,7 +290,7 @@ public partial class PostService(
public async Task<SnPost> PreviewPostLinkAsync(SnPost item)
{
if (item.Type != PostType.Moment || string.IsNullOrEmpty(item.Content)) return item;
if (item.Type != Shared.Models.PostType.Moment || string.IsNullOrEmpty(item.Content)) return item;
// Find all URLs in the content
var matches = GetLinkRegex().Matches(item.Content);
@@ -420,12 +420,12 @@ public partial class PostService(
}
}
public async Task<SnPost> PinPostAsync(SnPost post, Account currentUser, PostPinMode pinMode)
public async Task<SnPost> PinPostAsync(SnPost post, Account currentUser, Shared.Models.PostPinMode pinMode)
{
var accountId = Guid.Parse(currentUser.Id);
if (post.RepliedPostId != null)
{
if (pinMode != PostPinMode.ReplyPage)
if (pinMode != Shared.Models.PostPinMode.ReplyPage)
throw new InvalidOperationException("Replies can only be pinned in the reply page.");
if (post.RepliedPost == null) throw new ArgumentNullException(nameof(post.RepliedPost));
@@ -516,11 +516,11 @@ public partial class PostService(
switch (reaction.Attitude)
{
case PostReactionAttitude.Positive:
case Shared.Models.PostReactionAttitude.Positive:
if (isRemoving) post.Upvotes--;
else post.Upvotes++;
break;
case PostReactionAttitude.Negative:
case Shared.Models.PostReactionAttitude.Negative:
if (isRemoving) post.Downvotes--;
else post.Downvotes++;
break;
@@ -771,7 +771,7 @@ public partial class PostService(
if (currentUser is null)
{
// Anonymous user can only view public posts that are published
return post.PublishedAt != null && now >= post.PublishedAt && post.Visibility == PostVisibility.Public;
return post.PublishedAt != null && now >= post.PublishedAt && post.Visibility == Shared.Models.PostVisibility.Public;
}
// Check publication status - either published or user is member
@@ -781,10 +781,10 @@ public partial class PostService(
return false;
// Check visibility
if (post.Visibility == PostVisibility.Private && !isMember)
if (post.Visibility == Shared.Models.PostVisibility.Private && !isMember)
return false;
if (post.Visibility == PostVisibility.Friends &&
if (post.Visibility == Shared.Models.PostVisibility.Friends &&
!(post.Publisher.AccountId.HasValue && userFriends.Contains(post.Publisher.AccountId.Value) || isMember))
return false;
@@ -843,7 +843,7 @@ public partial class PostService(
var periodEnd = today.InUtc().Date.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
var postsInPeriod = await db.Posts
.Where(e => e.Visibility == PostVisibility.Public)
.Where(e => e.Visibility == Shared.Models.PostVisibility.Public)
.Where(e => e.CreatedAt >= periodStart && e.CreatedAt < periodEnd)
.Select(e => e.Id)
.ToListAsync();
@@ -854,7 +854,7 @@ public partial class PostService(
.Select(e => new
{
PostId = e.Key,
Score = e.Sum(r => r.Attitude == PostReactionAttitude.Positive ? 1 : -1)
Score = e.Sum(r => r.Attitude == Shared.Models.PostReactionAttitude.Positive ? 1 : -1)
})
.ToDictionaryAsync(e => e.PostId, e => e.Score);
@@ -928,7 +928,7 @@ public partial class PostService(
Guid postId,
Guid accountId,
decimal amount,
PostReactionAttitude attitude,
Shared.Models.PostReactionAttitude attitude,
string? message
)
{
@@ -947,7 +947,7 @@ public partial class PostService(
db.PostAwards.Add(award);
await db.SaveChangesAsync();
var delta = award.Attitude == PostReactionAttitude.Positive ? amount : -amount;
var delta = award.Attitude == Shared.Models.PostReactionAttitude.Positive ? amount : -amount;
await db.Posts.Where(p => p.Id == postId)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.AwardedScore, p => p.AwardedScore + delta));
@@ -1017,20 +1017,20 @@ public static class PostQueryExtensions
source = isListing switch
{
true when currentUser is not null => source.Where(e =>
e.Visibility != PostVisibility.Unlisted || publishersId.Contains(e.PublisherId)),
true => source.Where(e => e.Visibility != PostVisibility.Unlisted),
e.Visibility != Shared.Models.PostVisibility.Unlisted || publishersId.Contains(e.PublisherId)),
true => source.Where(e => e.Visibility != Shared.Models.PostVisibility.Unlisted),
_ => source
};
if (currentUser is null)
return source
.Where(e => e.PublishedAt != null && now >= e.PublishedAt)
.Where(e => e.Visibility == PostVisibility.Public);
.Where(e => e.Visibility == Shared.Models.PostVisibility.Public);
return source
.Where(e => (e.PublishedAt != null && now >= e.PublishedAt) || publishersId.Contains(e.PublisherId))
.Where(e => e.Visibility != PostVisibility.Private || publishersId.Contains(e.PublisherId))
.Where(e => e.Visibility != PostVisibility.Friends ||
.Where(e => e.Visibility != Shared.Models.PostVisibility.Private || publishersId.Contains(e.PublisherId))
.Where(e => e.Visibility != Shared.Models.PostVisibility.Friends ||
(e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) ||
publishersId.Contains(e.PublisherId));
}

View File

@@ -0,0 +1,252 @@
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Models;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Post;
public class PostServiceGrpc(AppDatabase db, PostService ps) : Shared.Proto.PostService.PostServiceBase
{
public override async Task<Shared.Proto.Post> GetPost(GetPostRequest request, ServerCallContext context)
{
if (!Guid.TryParse(request.Id, out var id))
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid post id"));
var post = await db.Posts
.Include(p => p.Publisher)
.Include(p => p.Tags)
.Include(p => p.Categories)
.Include(p => p.RepliedPost)
.Include(p => p.ForwardedPost)
.Include(p => p.FeaturedRecords)
.FilterWithVisibility(null, [], [])
.FirstOrDefaultAsync(p => p.Id == id);
if (post == null) throw new RpcException(new Status(StatusCode.NotFound, "post not found"));
post = await ps.LoadPostInfo(post);
return post.ToProtoValue();
}
public override async Task<GetPostBatchResponse> GetPostBatch(GetPostBatchRequest request, ServerCallContext context)
{
var ids = request.Ids
.Where(s => !string.IsNullOrWhiteSpace(s) && Guid.TryParse(s, out _))
.Select(Guid.Parse)
.ToList();
if (ids.Count == 0) return new GetPostBatchResponse();
var posts = await db.Posts
.Include(p => p.Publisher)
.Include(p => p.Tags)
.Include(p => p.Categories)
.Include(p => p.RepliedPost)
.Include(p => p.ForwardedPost)
.Include(p => p.FeaturedRecords)
.Include(p => p.Awards)
.Where(p => ids.Contains(p.Id))
.FilterWithVisibility(null, [], [])
.ToListAsync();
posts = await ps.LoadPostInfo(posts, null);
var resp = new GetPostBatchResponse();
resp.Posts.AddRange(posts.Select(p => p.ToProtoValue()));
return resp;
}
public override async Task<SearchPostsResponse> SearchPosts(SearchPostsRequest request, ServerCallContext context)
{
var query = db.Posts
.Include(p => p.Publisher)
.Include(p => p.Tags)
.Include(p => p.Categories)
.Include(p => p.RepliedPost)
.Include(p => p.ForwardedPost)
.Include(p => p.Attachments)
.Include(p => p.Awards)
.Include(p => p.Reactions)
.Include(p => p.FeaturedRecords)
.Where(p => p.DeletedAt == null) // Only active posts
.AsQueryable();
if (!string.IsNullOrWhiteSpace(request.Query))
{
// Simple search, assuming full-text search or title/content contains
query = query.Where(p =>
EF.Functions.ILike(p.Title, $"%{request.Query}%") ||
EF.Functions.ILike(p.Content, $"%{request.Query}%") ||
EF.Functions.ILike(p.Description, $"%{request.Query}%"));
}
if (!string.IsNullOrWhiteSpace(request.PublisherId) && Guid.TryParse(request.PublisherId, out var pid))
{
query = query.Where(p => p.PublisherId == pid);
}
if (!string.IsNullOrWhiteSpace(request.RealmId) && Guid.TryParse(request.RealmId, out var rid))
{
query = query.Where(p => p.RealmId == rid);
}
query = query.FilterWithVisibility(null, [], []);
var totalSize = await query.CountAsync();
// Apply pagination
var pageSize = request.PageSize > 0 ? request.PageSize : 20;
var pageToken = request.PageToken;
var offset = string.IsNullOrEmpty(pageToken) ? 0 : int.Parse(pageToken);
var posts = await query
.OrderByDescending(p => p.PublishedAt ?? p.CreatedAt)
.Skip(offset)
.Take(pageSize)
.ToListAsync();
posts = await ps.LoadPostInfo(posts, null, true);
var nextToken = offset + pageSize < totalSize ? (offset + pageSize).ToString() : string.Empty;
var resp = new SearchPostsResponse();
resp.Posts.AddRange(posts.Select(p => p.ToProtoValue()));
resp.NextPageToken = nextToken;
resp.TotalSize = totalSize;
return resp;
}
public override async Task<ListPostsResponse> ListPosts(ListPostsRequest request, ServerCallContext context)
{
var query = db.Posts
.Include(p => p.Publisher)
.Include(p => p.Tags)
.Include(p => p.Categories)
.Include(p => p.RepliedPost)
.Include(p => p.ForwardedPost)
.Include(p => p.Awards)
.Include(p => p.FeaturedRecords)
.Where(p => p.DeletedAt == null)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(request.PublisherId) && Guid.TryParse(request.PublisherId, out var pid))
{
query = query.Where(p => p.PublisherId == pid);
}
if (!string.IsNullOrWhiteSpace(request.RealmId) && Guid.TryParse(request.RealmId, out var rid))
{
query = query.Where(p => p.RealmId == rid);
}
if (request.Categories.Count > 0)
{
query = query.Where(p => p.Categories.Any(c => request.Categories.Contains(c.Slug)));
}
if (request.Tags.Count > 0)
{
query = query.Where(p => p.Tags.Any(c => request.Tags.Contains(c.Slug)));
}
// TODO: Add types filtering when proto is regenerated
// if (request.Types.Count > 0)
// {
// var types = request.Types.Select(t => (Shared.Models.PostType)t).Distinct();
// query = query.Where(p => types.Contains(p.Type));
// }
if (request.OnlyMedia)
{
query = query.Where(e => e.Attachments.Count > 0);
}
// Pinned filtering
switch (request.Pinned)
{
case Shared.Proto.PostPinMode.RealmPage when !string.IsNullOrWhiteSpace(request.RealmId):
query = query.Where(p => p.PinMode == Shared.Models.PostPinMode.RealmPage);
break;
case Shared.Proto.PostPinMode.PublisherPage when !string.IsNullOrWhiteSpace(request.PublisherId):
query = query.Where(p => p.PinMode == Shared.Models.PostPinMode.PublisherPage);
break;
case Shared.Proto.PostPinMode.ReplyPage:
query = query.Where(p => p.PinMode == Shared.Models.PostPinMode.ReplyPage);
break;
default:
if (request.Pinned != null)
{
// Specific pinned mode but conditions not met, or unknown mode
query = query.Where(p => p.PinMode == (Shared.Models.PostPinMode)request.Pinned);
}
else
{
query = query.Where(p => p.PinMode == null);
}
break;
}
// Include/exclude replies
if (request.IncludeReplies)
{
// Include both root and reply posts
}
else
{
// Exclude reply posts, only root posts
query = query.Where(e => e.RepliedPostId == null);
}
// TODO: Time range filtering when proto fields are available
// if (request.After != null)
// {
// var afterTime = request.After.ToDateTimeOffset();
// query = query.Where(p => (p.CreatedAt >= afterTime) || (p.PublishedAt >= afterTime));
// }
// if (request.Before != null)
// {
// var beforeTime = request.Before.ToDateTimeOffset();
// query = query.Where(p => (p.CreatedAt <= beforeTime) || (p.PublishedAt <= beforeTime));
// }
// TODO: Query text search when proto field is available
// if (!string.IsNullOrWhiteSpace(request.Query))
// {
// query = query.Where(p =>
// EF.Functions.ILike(p.Title, $"%{request.Query}%") ||
// EF.Functions.ILike(p.Content, $"%{request.Query}%") ||
// EF.Functions.ILike(p.Description, $"%{request.Query}%"));
// }
// Visibility filter (simplified for grpc - no user context)
query = query.FilterWithVisibility(null, [], []);
var totalSize = await query.CountAsync();
var pageSize = request.PageSize > 0 ? request.PageSize : 20;
var pageToken = request.PageToken;
var offset = string.IsNullOrEmpty(pageToken) ? 0 : int.Parse(pageToken);
// Ordering - TODO: Add shuffle when proto field is available
var orderedQuery = query.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt);
var posts = await orderedQuery
.Skip(offset)
.Take(pageSize)
.ToListAsync();
posts = await ps.LoadPostInfo(posts, null, true);
var nextToken = offset + pageSize < totalSize ? (offset + pageSize).ToString() : string.Empty;
var resp = new ListPostsResponse();
resp.Posts.AddRange(posts.Select(p => p.ToProtoValue()));
resp.NextPageToken = nextToken;
resp.TotalSize = totalSize;
return resp;
}
}

View File

@@ -4,6 +4,7 @@ using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using PublisherMemberRole = DysonNetwork.Shared.Models.PublisherMemberRole;
using PublisherType = DysonNetwork.Shared.Models.PublisherType;
namespace DysonNetwork.Sphere.Publisher;
@@ -161,7 +162,7 @@ public class PublisherService(
{
var publisher = new SnPublisher
{
Type = Shared.Models.PublisherType.Individual,
Type = PublisherType.Individual,
Name = name ?? account.Name,
Nick = nick ?? account.Nick,
Bio = bio ?? account.Profile.Bio,
@@ -177,7 +178,7 @@ public class PublisherService(
new()
{
AccountId = Guid.Parse(account.Id),
Role = Shared.Models.PublisherMemberRole.Owner,
Role = PublisherMemberRole.Owner,
JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow)
}
]
@@ -214,7 +215,7 @@ public class PublisherService(
}
public async Task<SnPublisher> CreateOrganizationPublisher(
Shared.Models.SnRealm realm,
SnRealm realm,
Account account,
string? name,
string? nick,
@@ -225,7 +226,7 @@ public class PublisherService(
{
var publisher = new SnPublisher
{
Type = Shared.Models.PublisherType.Organizational,
Type = PublisherType.Organizational,
Name = name ?? realm.Slug,
Nick = nick ?? realm.Name,
Bio = bio ?? realm.Description,
@@ -237,7 +238,7 @@ public class PublisherService(
new()
{
AccountId = Guid.Parse(account.Id),
Role = Shared.Models.PublisherMemberRole.Owner,
Role = PublisherMemberRole.Owner,
JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow)
}
}
@@ -299,10 +300,10 @@ public class PublisherService(
var postsCount = await db.Posts.Where(e => e.Publisher.Id == publisher.Id).CountAsync();
var postsUpvotes = await db.PostReactions
.Where(r => r.Post.Publisher.Id == publisher.Id && r.Attitude == PostReactionAttitude.Positive)
.Where(r => r.Post.Publisher.Id == publisher.Id && r.Attitude == Shared.Models.PostReactionAttitude.Positive)
.CountAsync();
var postsDownvotes = await db.PostReactions
.Where(r => r.Post.Publisher.Id == publisher.Id && r.Attitude == PostReactionAttitude.Negative)
.Where(r => r.Post.Publisher.Id == publisher.Id && r.Attitude == Shared.Models.PostReactionAttitude.Negative)
.CountAsync();
var stickerPacksId = await db.StickerPacks.Where(e => e.Publisher.Id == publisher.Id).Select(e => e.Id)

View File

@@ -11,7 +11,7 @@ namespace DysonNetwork.Sphere.Publisher;
public class PublisherSubscriptionService(
AppDatabase db,
PostService ps,
Post.PostService ps,
IStringLocalizer<NotificationResource> localizer,
ICacheService cache,
RingService.RingServiceClient pusher,
@@ -54,7 +54,7 @@ public class PublisherSubscriptionService(
{
if (post.RepliedPostId is not null)
return 0;
if (post.Visibility != PostVisibility.Public)
if (post.Visibility != Shared.Models.PostVisibility.Public)
return 0;
// Create notification data

View File

@@ -1,5 +1,6 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
namespace DysonNetwork.Sphere.Startup;
@@ -20,6 +21,7 @@ public static class ApplicationConfiguration
app.MapControllers();
// Map gRPC services
app.MapGrpcService<PostServiceGrpc>();
app.MapGrpcService<PublisherServiceGrpc>();
return app;

View File

@@ -24,7 +24,7 @@ public class PaymentOrderAwardMeta
[JsonPropertyName("account_id")] public Guid AccountId { get; set; }
[JsonPropertyName("post_id")] public Guid PostId { get; set; }
[JsonPropertyName("amount")] public string Amount { get; set; } = null!;
[JsonPropertyName("attitude")] public PostReactionAttitude Attitude { get; set; }
[JsonPropertyName("attitude")] public Shared.Models.PostReactionAttitude Attitude { get; set; }
[JsonPropertyName("message")] public string? Message { get; set; }
}
@@ -82,7 +82,7 @@ public class BroadcastEventHandler(
logger.LogInformation("Handling post award order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var ps = scope.ServiceProvider.GetRequiredService<PostService>();
var ps = scope.ServiceProvider.GetRequiredService<Post.PostService>();
var amountNum = decimal.Parse(meta.Amount);