diff --git a/MyApp.ServiceInterface/BackgroundMqServices.cs b/MyApp.ServiceInterface/BackgroundMqServices.cs index b631799..f101e6c 100644 --- a/MyApp.ServiceInterface/BackgroundMqServices.cs +++ b/MyApp.ServiceInterface/BackgroundMqServices.cs @@ -20,18 +20,40 @@ public async Task Any(DiskTasks request) } } + public async Task Any(DbWriteTasks request) + { + var vote = request.RecordPostVote; + if (vote != null) + { + if (string.IsNullOrEmpty(vote.RefId)) + throw new ArgumentNullException(nameof(vote.RefId)); + if (string.IsNullOrEmpty(vote.UserName)) + throw new ArgumentNullException(nameof(vote.UserName)); + + await Db.DeleteAsync(new { vote.RefId, vote.UserName }); + if (vote.Score != 0) + { + await Db.InsertAsync(vote); + } + + MessageProducer.Publish(new RenderComponent { + RegenerateMeta = vote.PostId + }); + } + } + public async Task Any(AnalyticsTasks request) { - if (request.RecordPostStat != null && !Stats.IsAdminOrModerator(request.RecordPostStat.UserName)) + if (request.RecordPostView != null && !Stats.IsAdminOrModerator(request.RecordPostView.UserName)) { using var analyticsDb = HostContext.AppHost.GetDbConnection(Databases.Analytics); - await analyticsDb.InsertAsync(request.RecordPostStat); + await analyticsDb.InsertAsync(request.RecordPostView); } - if (request.RecordSearchStat != null && !Stats.IsAdminOrModerator(request.RecordSearchStat.UserName)) + if (request.RecordSearchView != null && !Stats.IsAdminOrModerator(request.RecordSearchView.UserName)) { using var analyticsDb = HostContext.AppHost.GetDbConnection(Databases.Analytics); - await analyticsDb.InsertAsync(request.RecordSearchStat); + await analyticsDb.InsertAsync(request.RecordSearchView); } } } diff --git a/MyApp.ServiceInterface/Data/QuestionFiles.cs b/MyApp.ServiceInterface/Data/QuestionFiles.cs index 27fca05..39d05c2 100644 --- a/MyApp.ServiceInterface/Data/QuestionFiles.cs +++ b/MyApp.ServiceInterface/Data/QuestionFiles.cs @@ -1,9 +1,7 @@ using System.Collections.Concurrent; -using System.Data; using MyApp.ServiceModel; using ServiceStack; using ServiceStack.IO; -using ServiceStack.OrmLite; using ServiceStack.Text; namespace MyApp.Data; @@ -25,18 +23,55 @@ public class QuestionFiles(int id, string dir1, string dir2, string fileId, List ["accepted"] = 9, ["most-voted"] = 10, }; + + public int GetModelScore(string model) => model switch { + "accepted" => AcceptedScore, + "most-voted" => MostVotedScore, + _ => ModelScores.GetValueOrDefault(model, 0) + }; public int Id { get; init; } = id; public string Dir1 { get; init; } = dir1; public string Dir2 { get; init; } = dir2; public string DirPath = "/{Dir1}/{Dir2}"; public string FileId { get; init; } = fileId; - public List Files { get; init; } = files; + public List Files { get; init; } = WithoutDuplicateAnswers(files); public bool LoadedRemotely { get; set; } = remote; - public bool ScoresUpdated { get; set; } public ConcurrentDictionary FileContents { get; } = []; public QuestionAndAnswers? Question { get; set; } - public Meta? Meta { get; set; } + + public static Meta DeserializeMeta(string json) + { + if (string.IsNullOrEmpty(json)) + return new Meta(); + + var meta = json.FromJson(); + var toRemove = new List(); + foreach (var item in meta.Comments) + { + if (item.Key.Contains('[') || item.Key.Contains(']')) + { + toRemove.Add(item.Key); + } + } + toRemove.ForEach(key => meta.Comments.Remove(key)); + return meta; + } + + public IVirtualFile? GetMetaFile() => Files.FirstOrDefault(x => x.Name == $"{FileId}.meta.json"); + + public static List WithoutDuplicateAnswers(List files) + { + var accepted = files.FirstOrDefault(x => x.Name.Contains(".h.accepted")); + var mostVoted = files.FirstOrDefault(x => x.Name.Contains(".h.most-voted")); + return accepted?.Length == mostVoted?.Length + ? files.Where(x => !Equals(x, mostVoted)).ToList() + : files; + } + + public IEnumerable GetAnswerFiles() => Files.Where(x => x.Name.Contains(".a.") || x.Name.Contains(".h.")); + + public int GetAnswerFilesCount() => GetAnswerFiles().Count(); public async Task GetQuestionAsync() { @@ -47,24 +82,19 @@ public class QuestionFiles(int id, string dir1, string dir2, string fileId, List return Question; } - public void UpdateScores(List postStats) + public void ApplyScores(List postStats) { if (Question == null) throw new ArgumentNullException(nameof(Question)); - if (ScoresUpdated) - return; - - ScoresUpdated = true; - - var map = postStats.ToDictionary(x => x.Id); - foreach (var answer in Question.Answers) + + Question.Answers.Sort((a, b) => { - if (map.TryGetValue(answer.Id, out var stat)) - { - answer.UpVotes = stat.UpVotes; - answer.DownVotes = stat.DownVotes; - } - } + var aScore = postStats.FirstOrDefault(x => x.Id == a.Id)?.GetScore() + ?? GetModelScore(a.Model); + var bScore = postStats.FirstOrDefault(x => x.Id == b.Id)?.GetScore() + ?? GetModelScore(b.Model); + return bScore - aScore; + }); } public async Task LoadContentsAsync() @@ -77,11 +107,15 @@ public async Task LoadContentsAsync() await Task.WhenAll(tasks); } + public string GetAnswerUserName(string answerFileName) => answerFileName[(FileId + ".a.").Length..].LeftPart('.'); + + public string GetAnswerId(string answerFileName) => Id + "-" + GetAnswerUserName(answerFileName); + public async Task LoadQuestionAndAnswersAsync() { var questionFileName = FileId + ".json"; await LoadContentsAsync(); - + var to = new QuestionAndAnswers(); foreach (var entry in FileContents) { @@ -92,14 +126,14 @@ public async Task LoadQuestionAndAnswersAsync() } else if (fileName == $"{FileId}.meta.json") { - Meta = entry.Value.FromJson(); - Meta.StatTotals ??= new(); - Meta.ModelVotes ??= new(); + to.Meta = DeserializeMeta(entry.Value); + to.Meta.StatTotals ??= new(); + to.Meta.ModelVotes ??= new(); } else if (fileName.StartsWith(FileId + ".a.")) { var answer = entry.Value.FromJson(); - answer.Id = $"{Id}-{answer.Model.Replace(':','-')}"; + answer.Id = GetAnswerId(fileName); to.Answers.Add(answer); } else if (fileName.StartsWith(FileId + ".h.")) @@ -111,7 +145,6 @@ public async Task LoadQuestionAndAnswersAsync() Id = $"{Id}-{userName}", Model = userName, Created = (post.LastEditDate ?? post.CreationDate).ToUnixTime(), - UpVotes = userName == "most-voted" ? MostVotedScore : AcceptedScore, Choices = [ new() { @@ -120,22 +153,18 @@ public async Task LoadQuestionAndAnswersAsync() } ] }; - if (to.Answers.All(x => x.Id != answer.Id)) - to.Answers.Add(answer); + to.Answers.Add(answer); } } if (to.Post == null) return; + + Question = to; - to.Answers.Each(x => x.UpVotes = x.UpVotes == 0 ? ModelScores.GetValueOrDefault(x.Model, 1) : x.UpVotes); - to.Answers.Sort((a, b) => b.Votes - a.Votes); - - if (Meta?.StatTotals.Count > 0) + if (to.Meta?.StatTotals.Count > 0) { - UpdateScores(Meta.StatTotals); + ApplyScores(to.Meta.StatTotals); } - - Question = to; } } diff --git a/MyApp.ServiceInterface/Data/QuestionsProvider.cs b/MyApp.ServiceInterface/Data/QuestionsProvider.cs index f816918..94e9d55 100644 --- a/MyApp.ServiceInterface/Data/QuestionsProvider.cs +++ b/MyApp.ServiceInterface/Data/QuestionsProvider.cs @@ -1,12 +1,19 @@ using Microsoft.Extensions.Logging; +using MyApp.ServiceModel; using ServiceStack; using ServiceStack.IO; using ServiceStack.Messaging; +using ServiceStack.Text; namespace MyApp.Data; public class QuestionsProvider(ILogger log, IMessageProducer mqClient, IVirtualFiles fs, R2VirtualFiles r2) { + public System.Text.Json.JsonSerializerOptions SystemJsonOptions = new(TextConfig.SystemJsonOptions) + { + WriteIndented = true + }; + public QuestionFiles GetLocalQuestionFiles(int id) { var (dir1, dir2, fileId) = id.ToFileParts(); @@ -31,6 +38,22 @@ public async Task GetRemoteQuestionFilesAsync(int id) return new QuestionFiles(id: id, dir1: dir1, dir2: dir2, fileId: fileId, files: files, remote:true); } + public static string GetMetaPath(int id) + { + var (dir1, dir2, fileId) = id.ToFileParts(); + var metaPath = $"{dir1}/{dir2}/{fileId}.meta.json"; + return metaPath; + } + + public async Task WriteMetaAsync(Meta meta) + { + var metaJson = System.Text.Json.JsonSerializer.Serialize(meta, SystemJsonOptions); + var metaPath = GetMetaPath(meta.Id); + await Task.WhenAll( + fs.WriteFileAsync(metaPath, metaJson), + r2.WriteFileAsync(metaPath, metaJson)); + } + public async Task GetQuestionFilesAsync(int id) { var localFiles = GetLocalQuestionFiles(id); diff --git a/MyApp.ServiceInterface/Data/StatUtils.cs b/MyApp.ServiceInterface/Data/StatUtils.cs index 24ad2f5..4a134c1 100644 --- a/MyApp.ServiceInterface/Data/StatUtils.cs +++ b/MyApp.ServiceInterface/Data/StatUtils.cs @@ -1,10 +1,15 @@ -using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; using MyApp.ServiceModel; namespace MyApp.Data; public static class StatUtils { + public static string? GetUserName(this HttpContext? ctx) => ctx?.User.GetUserName(); + public static string? GetUserName(this ClaimsPrincipal? user) => user?.Identity?.Name; + public static bool IsAdminOrModerator(this ClaimsPrincipal? user) => Stats.IsAdminOrModerator(user?.Identity?.Name); + public static T WithRequest(this T stat, HttpContext? ctx) where T : StatBase { var user = ctx?.User; diff --git a/MyApp.ServiceInterface/UserServices.cs b/MyApp.ServiceInterface/UserServices.cs index 7064d70..716b65b 100644 --- a/MyApp.ServiceInterface/UserServices.cs +++ b/MyApp.ServiceInterface/UserServices.cs @@ -82,4 +82,36 @@ public async Task Any(GetUserAvatar request) } return new HttpResult(Svg.GetImage(Svg.Icons.Users), MimeTypes.ImageSvg); } + + public async Task Any(UserPostData request) + { + var userName = Request.GetClaimsPrincipal().Identity!.Name!; + var allUserPostVotes = await Db.SelectAsync(x => x.PostId == request.PostId && x.UserName == userName); + + var to = new UserPostDataResponse + { + UpVoteIds = allUserPostVotes.Where(x => x.Score > 0).Select(x => x.RefId).ToSet(), + DownVoteIds = allUserPostVotes.Where(x => x.Score < 0).Select(x => x.RefId).ToSet(), + }; + return to; + } + + public async Task Any(PostVote request) + { + var userName = Request.GetClaimsPrincipal().Identity!.Name!; + if (string.IsNullOrEmpty(userName)) + throw new ArgumentNullException(nameof(userName)); + var postId = request.RefId.LeftPart('-').ToInt(); + var score = request.Up == true ? 1 : request.Down == true ? -1 : 0; + MessageProducer.Publish(new DbWriteTasks + { + RecordPostVote = new() + { + RefId = request.RefId, + PostId = postId, + UserName = userName, + Score = score, + } + }); + } } diff --git a/MyApp.ServiceModel/Meta.cs b/MyApp.ServiceModel/Meta.cs index bf63bc2..c00279c 100644 --- a/MyApp.ServiceModel/Meta.cs +++ b/MyApp.ServiceModel/Meta.cs @@ -1,9 +1,20 @@ namespace MyApp.ServiceModel; +// AnswerId = `{PostId}-{UserName}` +// RefId = PostId | AnswerId public class Meta { + // PostId + public int Id { get; set; } + + // ModelName => Votes + public Dictionary ModelVotes { get; set; } = []; + + // RefId => Comments + public Dictionary> Comments { get; set; } = []; + // Question + Answer Stats Totals - public List StatTotals { get; set; } - public Dictionary ModelVotes { get; set; } - public List Comments { get; set; } = []; + public List StatTotals { get; set; } = []; + + public DateTime ModifiedDate { get; set; } } diff --git a/MyApp.ServiceModel/Posts.cs b/MyApp.ServiceModel/Posts.cs index 6406b38..43c5dba 100644 --- a/MyApp.ServiceModel/Posts.cs +++ b/MyApp.ServiceModel/Posts.cs @@ -115,17 +115,10 @@ public class Answer public Dictionary Usage { get; set; } public decimal Temperature { get; set; } public List Comments { get; set; } = []; - public int Votes => UpVotes - DownVotes; - public int UpVotes { get; set; } - public int DownVotes { get; set; } } public class Comment { - /// - /// `Post.Id` or `${Post.Id}-{UserName}` (Answer) - /// - public string Id { get; set; } public string Body { get; set; } public string CreatedBy { get; set; } public int? UpVotes { get; set; } @@ -136,9 +129,22 @@ public class QuestionAndAnswers { public int Id => Post.Id; public Post Post { get; set; } - - public List PostComments { get; set; } = []; + public Meta? Meta { get; set; } public List Answers { get; set; } = []; + + public int ViewCount => Post.ViewCount + Meta?.StatTotals.Find(x => x.Id == $"{Id}")?.ViewCount ?? 0; + + public int QuestionScore => Meta?.StatTotals.Find(x => x.Id == $"{Id}")?.GetScore() ?? Post.Score; + + public int GetAnswerScore(string refId) => Meta?.StatTotals.Find(x => x.Id == refId)?.GetScore() ?? 0; + + public List QuestionComments => Meta?.Comments.TryGetValue($"{Id}", out var comments) == true + ? comments + : []; + + public List GetAnswerComments(string refId) => Meta?.Comments.TryGetValue($"{refId}", out var comments) == true + ? comments + : []; } public class AdminData : IGet, IReturn @@ -154,4 +160,25 @@ public class PageStats public class AdminDataResponse { public List PageStats { get; set; } -} \ No newline at end of file +} + +[ValidateIsAuthenticated] +public class UserPostData : IGet, IReturn +{ + public int PostId { get; set; } +} + +public class UserPostDataResponse +{ + public HashSet UpVoteIds { get; set; } = []; + public HashSet DownVoteIds { get; set; } = []; + public ResponseStatus? ResponseStatus { get; set; } +} + +[ValidateIsAuthenticated] +public class PostVote : IReturnVoid +{ + public string RefId { get; set; } + public bool? Up { get; set; } + public bool? Down { get; set; } +} diff --git a/MyApp.ServiceModel/RenderComponent.cs b/MyApp.ServiceModel/RenderComponent.cs index 407d821..6f93e29 100644 --- a/MyApp.ServiceModel/RenderComponent.cs +++ b/MyApp.ServiceModel/RenderComponent.cs @@ -11,6 +11,7 @@ public class RenderHome public class RenderComponent : IReturnVoid { public int? IfQuestionModified { get; set; } + public int? RegenerateMeta { get; set; } public QuestionAndAnswers? Question { get; set; } public RenderHome? Home { get; set; } } \ No newline at end of file diff --git a/MyApp.ServiceModel/Stats.cs b/MyApp.ServiceModel/Stats.cs index 03f801a..25d054a 100644 --- a/MyApp.ServiceModel/Stats.cs +++ b/MyApp.ServiceModel/Stats.cs @@ -16,16 +16,56 @@ public static class Databases public const string Search = nameof(Search); } +/// +/// Aggregate Stats for Questions(Id=PostId) and Answers(Id=PostId-UserName) +/// public class StatTotals { - public required string Id { get; set; } // PostId or PostId-UserName (Answer) + // PostId (Question) or PostId-UserName (Answer) + public required string Id { get; set; } + + [Index] public int PostId { get; set; } + public int FavoriteCount { get; set; } + + // post.ViewCount + Sum(PostView.PostId) public int ViewCount { get; set; } + + // Sum(Vote(PostId).Score > 0) public int UpVotes { get; set; } + + // Sum(Vote(PostId).Score < 0) public int DownVotes { get; set; } + + // post.Score || Meta.ModelVotes[PostId] (Model Ranking Score) public int StartingUpVotes { get; set; } - public DateTime ModifiedDate { get; set; } + + public int GetScore() => StartingUpVotes + UpVotes - DownVotes; + + private sealed class StatTotalsEqualityComparer : IEqualityComparer + { + public bool Equals(StatTotals x, StatTotals y) + { + if (ReferenceEquals(x, y)) return true; + if (ReferenceEquals(x, null)) return false; + if (ReferenceEquals(y, null)) return false; + if (x.GetType() != y.GetType()) return false; + return x.Id == y.Id && x.PostId == y.PostId && x.FavoriteCount == y.FavoriteCount && x.ViewCount == y.ViewCount && x.UpVotes == y.UpVotes && x.DownVotes == y.DownVotes && x.StartingUpVotes == y.StartingUpVotes; + } + + public int GetHashCode(StatTotals obj) + { + return HashCode.Combine(obj.Id, obj.PostId, obj.FavoriteCount, obj.ViewCount, obj.UpVotes, obj.DownVotes, obj.StartingUpVotes); + } + } + + public static IEqualityComparer StatTotalsComparer { get; } = new StatTotalsEqualityComparer(); + + public bool Matches(StatTotals? other) + { + return other == null || UpVotes != other.UpVotes || DownVotes != other.DownVotes || StartingUpVotes != other.DownVotes; + } } [NamedConnection(Databases.Analytics)] @@ -42,6 +82,7 @@ public class PostView : StatBase { [AutoIncrement] public int Id { get; set; } + [Index] public int PostId { get; set; } } @@ -58,7 +99,11 @@ public class SearchView : StatBase [Restrict(InternalOnly = true)] public class AnalyticsTasks { - public SearchView? RecordSearchStat { get; set; } - public PostView? RecordPostStat { get; set; } + public SearchView? RecordSearchView { get; set; } + public PostView? RecordPostView { get; set; } } +public class DbWriteTasks +{ + public Vote? RecordPostVote { get; set; } +} \ No newline at end of file diff --git a/MyApp.ServiceModel/Votes.cs b/MyApp.ServiceModel/Votes.cs index 1d5489e..1d14294 100644 --- a/MyApp.ServiceModel/Votes.cs +++ b/MyApp.ServiceModel/Votes.cs @@ -2,18 +2,25 @@ namespace MyApp.ServiceModel; -[UniqueConstraint(nameof(UserId), nameof(AnswerId))] +[UniqueConstraint(nameof(RefId), nameof(UserName))] public class Vote { [AutoIncrement] public int Id { get; set; } - public int UserId { get; set; } - + [Index] public int PostId { get; set; } + /// + /// `Post.Id` or `${Post.Id}-{UserName}` (Answer) + /// [Required] - public string AnswerId { get; set; } + public string RefId { get; set; } + public string UserName { get; set; } + + /// + /// 1 for UpVote, -1 for DownVote + /// public int Score { get; set; } } diff --git a/MyApp/Components/Pages/Questions/Index.razor b/MyApp/Components/Pages/Questions/Index.razor index cef5d34..84dfc69 100644 --- a/MyApp/Components/Pages/Questions/Index.razor +++ b/MyApp/Components/Pages/Questions/Index.razor @@ -97,7 +97,7 @@ { MessageProducer.Publish(new AnalyticsTasks { - RecordSearchStat = new SearchView { Query = Q }.WithRequest(HttpContext) + RecordSearchView = new SearchView { Query = Q }.WithRequest(HttpContext) }); using var dbSearch = await DbFactory.OpenAsync(Databases.Search); diff --git a/MyApp/Components/Pages/Questions/Question.razor b/MyApp/Components/Pages/Questions/Question.razor index a19a3cb..127f5a3 100644 --- a/MyApp/Components/Pages/Questions/Question.razor +++ b/MyApp/Components/Pages/Questions/Question.razor @@ -1,9 +1,11 @@ @page "/questions/{Id:int}/{*Slug}" +@using ServiceStack.Caching @inject QuestionsProvider QuestionsProvider -@inject R2VirtualFiles R2 @inject RendererCache RendererCache @inject NavigationManager NavigationManager @inject IMessageProducer MessageProducer +@inject MemoryCacheClient Cache +@inject IWebHostEnvironment Env @title @@ -33,6 +35,7 @@