diff --git a/DysonNetwork.Sphere/Activity/ActivityService.cs b/DysonNetwork.Sphere/Activity/ActivityService.cs index e3f856e..c59b746 100644 --- a/DysonNetwork.Sphere/Activity/ActivityService.cs +++ b/DysonNetwork.Sphere/Activity/ActivityService.cs @@ -69,9 +69,14 @@ public class ActivityService(AppDatabase db, PublisherService pub, RelationshipS var postsId = posts.Select(e => e.Id).ToList(); var reactionMaps = await ps.GetPostReactionMapBatch(postsId); foreach (var post in posts) + { post.ReactionsCount = reactionMaps.TryGetValue(post.Id, out var count) ? count : new Dictionary(); + // Track view for each post in the feed + await ps.IncreaseViewCount(post.Id, currentUser.Id.ToString()); + } + // Formatting data foreach (var post in posts) activities.Add(post.ToActivity()); diff --git a/DysonNetwork.Sphere/Post/PostController.cs b/DysonNetwork.Sphere/Post/PostController.cs index 109cdcd..1a9820a 100644 --- a/DysonNetwork.Sphere/Post/PostController.cs +++ b/DysonNetwork.Sphere/Post/PostController.cs @@ -58,9 +58,21 @@ public class PostController( var postsId = posts.Select(e => e.Id).ToList(); var reactionMaps = await ps.GetPostReactionMapBatch(postsId); foreach (var post in posts) + { post.ReactionsCount = reactionMaps.TryGetValue(post.Id, out var count) ? count : new Dictionary(); + // Track view for each post in the list + if (currentUser != null) + { + await ps.IncreaseViewCount(post.Id, currentUser.Id.ToString()); + } + else + { + await ps.IncreaseViewCount(post.Id); + } + } + Response.Headers["X-Total"] = totalCount.ToString(); return Ok(posts); @@ -85,6 +97,9 @@ public class PostController( post.ReactionsCount = await ps.GetPostReactionMap(post.Id); + // Track view - use the account ID as viewer ID if user is logged in + await ps.IncreaseViewCount(post.Id, currentUser?.Id.ToString()); + return Ok(post); } diff --git a/DysonNetwork.Sphere/Post/PostService.cs b/DysonNetwork.Sphere/Post/PostService.cs index 71bc2c5..5a7e2ee 100644 --- a/DysonNetwork.Sphere/Post/PostService.cs +++ b/DysonNetwork.Sphere/Post/PostService.cs @@ -13,7 +13,9 @@ public class PostService( AppDatabase db, FileReferenceService fileRefService, IStringLocalizer localizer, - IServiceScopeFactory factory + IServiceScopeFactory factory, + FlushBufferService flushBuffer, + ICacheService cacheService ) { private const string PostFileUsageIdentifier = "post"; @@ -365,6 +367,40 @@ public class PostService( ); } + /// + /// Increases the view count for a post. + /// Uses the flush buffer service to batch database updates for better performance. + /// + /// The ID of the post to mark as viewed + /// Optional viewer ID for unique view counting (anonymous if null) + /// Task representing the asynchronous operation + public async Task IncreaseViewCount(Guid postId, string? viewerId = null) + { + // Check if this view is already counted in cache to prevent duplicate counting + if (!string.IsNullOrEmpty(viewerId)) + { + var cacheKey = $"post:view:{postId}:{viewerId}"; + var (found, _) = await cacheService.GetAsyncWithStatus(cacheKey); + + if (found) + { + // Already viewed by this user recently, don't count again + return; + } + + // Mark as viewed in cache for 1 hour to prevent duplicate counting + await cacheService.SetAsync(cacheKey, true, TimeSpan.FromHours(1)); + } + + // Add view info to flush buffer + flushBuffer.Enqueue(new PostViewInfo + { + PostId = postId, + ViewerId = viewerId, + ViewedAt = Instant.FromDateTimeUtc(DateTime.UtcNow) + }); + } + public async Task> LoadPublishers(List posts) { var publisherIds = posts diff --git a/DysonNetwork.Sphere/Post/PostViewInfo.cs b/DysonNetwork.Sphere/Post/PostViewInfo.cs new file mode 100644 index 0000000..d998080 --- /dev/null +++ b/DysonNetwork.Sphere/Post/PostViewInfo.cs @@ -0,0 +1,10 @@ +using NodaTime; + +namespace DysonNetwork.Sphere.Post; + +public class PostViewInfo +{ + public Guid PostId { get; set; } + public string? ViewerId { get; set; } + public Instant ViewedAt { get; set; } +} diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index 04f9ac0..192febb 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -195,6 +195,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // The handlers for websocket builder.Services.AddScoped(); @@ -282,6 +283,16 @@ builder.Services.AddQuartz(q => .WithIntervalInMinutes(5) .RepeatForever()) ); + + var postViewFlushJob = new JobKey("PostViewFlush"); + q.AddJob(opts => opts.WithIdentity(postViewFlushJob)); + q.AddTrigger(opts => opts + .ForJob(postViewFlushJob) + .WithIdentity("PostViewFlushTrigger") + .WithSimpleSchedule(o => o + .WithIntervalInMinutes(1) + .RepeatForever()) + ); }); builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); diff --git a/DysonNetwork.Sphere/Storage/Handlers/PostViewFlushHandler.cs b/DysonNetwork.Sphere/Storage/Handlers/PostViewFlushHandler.cs new file mode 100644 index 0000000..1984ec0 --- /dev/null +++ b/DysonNetwork.Sphere/Storage/Handlers/PostViewFlushHandler.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore; +using NodaTime; +using Quartz; + +namespace DysonNetwork.Sphere.Storage.Handlers; + +public class PostViewFlushHandler(IServiceProvider serviceProvider) : IFlushHandler +{ + public async Task FlushAsync(IReadOnlyList items) + { + using var scope = serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var cache = scope.ServiceProvider.GetRequiredService(); + + // Group views by post + var postViews = items + .GroupBy(x => x.PostId) + .ToDictionary(g => g.Key, g => g.ToList()); + + // Calculate total views and unique views per post + foreach (var postId in postViews.Keys) + { + // Calculate unique views by distinct viewer IDs (not null) + var uniqueViews = postViews[postId] + .Where(v => !string.IsNullOrEmpty(v.ViewerId)) + .Select(v => v.ViewerId) + .Distinct() + .Count(); + + // Total views is just the count of all items for this post + var totalViews = postViews[postId].Count; + + // Update the post in the database + await db.Posts + .Where(p => p.Id == postId) + .ExecuteUpdateAsync(p => p + .SetProperty(x => x.ViewsTotal, x => x.ViewsTotal + totalViews) + .SetProperty(x => x.ViewsUnique, x => x.ViewsUnique + uniqueViews)); + + // Invalidate any cache entries for this post + await cache.RemoveAsync($"post:{postId}"); + } + } +} + +public class PostViewFlushJob(FlushBufferService fbs, PostViewFlushHandler hdl) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + await fbs.FlushAsync(hdl); + } +}