diff --git a/Refresh.GameServer/Database/GameDatabaseContext.Comments.cs b/Refresh.GameServer/Database/GameDatabaseContext.Comments.cs index be37bab8..caabec02 100644 --- a/Refresh.GameServer/Database/GameDatabaseContext.Comments.cs +++ b/Refresh.GameServer/Database/GameDatabaseContext.Comments.cs @@ -6,6 +6,8 @@ namespace Refresh.GameServer.Database; public partial class GameDatabaseContext // Comments { + public GameComment? GetCommentById(int id) => + this._realm.All().FirstOrDefault(c => c.SequentialId == id); public GameComment PostCommentToProfile(GameUser profile, GameUser author, string content) { GameComment comment = new() diff --git a/Refresh.GameServer/Database/GameDatabaseContext.Relations.cs b/Refresh.GameServer/Database/GameDatabaseContext.Relations.cs index c98eef3a..05a125dc 100644 --- a/Refresh.GameServer/Database/GameDatabaseContext.Relations.cs +++ b/Refresh.GameServer/Database/GameDatabaseContext.Relations.cs @@ -2,6 +2,7 @@ using Refresh.GameServer.Authentication; using Refresh.GameServer.Endpoints.Game.Levels.FilterSettings; using Refresh.GameServer.Extensions; +using Refresh.GameServer.Types.Comments; using Refresh.GameServer.Types.Levels; using Refresh.GameServer.Types.Relations; using Refresh.GameServer.Types.Reviews; @@ -305,4 +306,56 @@ public bool HasUserPlayedLevel(GameLevel level, GameUser user) => .FirstOrDefault(r => r.Level == level && r.User == user) != null; #endregion + + #region Comments + + private CommentRelation? GetCommentRelationByUser(GameComment comment, GameUser user) => this._realm + .All().FirstOrDefault(r => r.Comment == comment && r.User == user); + + /// + /// Get a user's rating on a particular comment. + /// A null return value means a user has not set a rating. + /// + /// The comment to check + /// The user to check + /// The rating if found + [Pure] + public RatingType? GetRatingByUser(GameComment comment, GameUser user) + => this.GetCommentRelationByUser(comment, user)?.RatingType; + + public bool RateComment(GameUser user, GameComment comment, RatingType ratingType) + { + if (ratingType == RatingType.Neutral) + return false; + + CommentRelation? relation = GetCommentRelationByUser(comment, user); + + if (relation == null) + { + relation = new CommentRelation + { + User = user, + Comment = comment, + Timestamp = this._time.Now, + RatingType = ratingType, + }; + + this._realm.Write(() => + { + this._realm.Add(relation); + }); + } + else + { + this._realm.Write(() => + { + relation.Timestamp = this._time.Now; + relation.RatingType = ratingType; + }); + } + + return true; + } + + #endregion } \ No newline at end of file diff --git a/Refresh.GameServer/Database/GameDatabaseProvider.cs b/Refresh.GameServer/Database/GameDatabaseProvider.cs index 8d28a0f2..bf55fa12 100644 --- a/Refresh.GameServer/Database/GameDatabaseProvider.cs +++ b/Refresh.GameServer/Database/GameDatabaseProvider.cs @@ -34,7 +34,7 @@ protected GameDatabaseProvider(IDateTimeProvider time) this._time = time; } - protected override ulong SchemaVersion => 120; + protected override ulong SchemaVersion => 121; protected override string Filename => "refreshGameServer.realm"; @@ -47,6 +47,7 @@ protected GameDatabaseProvider(IDateTimeProvider time) typeof(GameLevel), typeof(GameSkillReward), typeof(GameComment), + typeof(CommentRelation), typeof(FavouriteLevelRelation), typeof(QueueLevelRelation), typeof(FavouriteUserRelation), @@ -76,7 +77,7 @@ protected GameDatabaseProvider(IDateTimeProvider time) typeof(ScreenRect), typeof(Slot), typeof(GameReview), - typeof(DisallowedUser) + typeof(DisallowedUser), }; public override void Warmup() diff --git a/Refresh.GameServer/Endpoints/Game/CommentEndpoints.cs b/Refresh.GameServer/Endpoints/Game/CommentEndpoints.cs index 072095e3..503d2acd 100644 --- a/Refresh.GameServer/Endpoints/Game/CommentEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/CommentEndpoints.cs @@ -9,6 +9,7 @@ using Refresh.GameServer.Types.Comments; using Refresh.GameServer.Types.Levels; using Refresh.GameServer.Types.Lists; +using Refresh.GameServer.Types.Reviews; using Refresh.GameServer.Types.Roles; using Refresh.GameServer.Types.UserData; @@ -34,7 +35,7 @@ public Response PostProfileComment(RequestContext context, GameDatabaseContext d [GameEndpoint("userComments/{username}", ContentType.Xml)] [NullStatusCode(NotFound)] [MinimumRole(GameUserRole.Restricted)] - public SerializedCommentList? GetProfileComments(RequestContext context, GameDatabaseContext database, string username) + public SerializedCommentList? GetProfileComments(RequestContext context, GameDatabaseContext database, GameUser user, string username) { GameUser? profile = database.GetUserByUsername(username); if (profile == null) return null; @@ -42,7 +43,7 @@ public Response PostProfileComment(RequestContext context, GameDatabaseContext d (int skip, int count) = context.GetPageData(); List comments = database.GetProfileComments(profile, count, skip).ToList(); - foreach (GameComment comment in comments) comment.PrepareForSerialization(); + foreach (GameComment comment in comments) comment.PrepareForSerialization(user); return new SerializedCommentList(comments); } @@ -88,7 +89,7 @@ public Response PostLevelComment(RequestContext context, GameDatabaseContext dat [GameEndpoint("comments/{slotType}/{id}", ContentType.Xml)] [NullStatusCode(NotFound)] [MinimumRole(GameUserRole.Restricted)] - public SerializedCommentList? GetLevelComments(RequestContext context, GameDatabaseContext database, string slotType, int id) + public SerializedCommentList? GetLevelComments(RequestContext context, GameDatabaseContext database, GameUser user, string slotType, int id) { GameLevel? level = database.GetLevelByIdAndType(slotType, id); if (level == null) return null; @@ -96,7 +97,7 @@ public Response PostLevelComment(RequestContext context, GameDatabaseContext dat (int skip, int count) = context.GetPageData(); List comments = database.GetLevelComments(level, count, skip).ToList(); - foreach(GameComment comment in comments) comment.PrepareForSerialization(); + foreach(GameComment comment in comments) comment.PrepareForSerialization(user); return new SerializedCommentList(comments); } @@ -123,4 +124,23 @@ public Response DeleteLevelComment(RequestContext context, GameDatabaseContext d return OK; } + + + [GameEndpoint("rateComment/user/{content}", HttpMethods.Post)] // `user` level comments + [GameEndpoint("rateComment/developer/{content}", HttpMethods.Post)] // `developer` level comments + [GameEndpoint("rateUserComment/{content}", HttpMethods.Post)] // profile comments + public Response RateComment(RequestContext context, GameDatabaseContext database, GameUser user, string content) + { + if (!int.TryParse(context.QueryString["commentId"], out int commentId)) return BadRequest; + if (!Enum.TryParse(context.QueryString["rating"], out RatingType ratingType)) return BadRequest; + + GameComment? comment = database.GetCommentById(commentId); + if (comment == null) + return NotFound; + + if (!database.RateComment(user, comment, ratingType)) + return BadRequest; + + return OK; + } } \ No newline at end of file diff --git a/Refresh.GameServer/Types/Comments/GameComment.cs b/Refresh.GameServer/Types/Comments/GameComment.cs index 4ab777c6..db1a8a70 100644 --- a/Refresh.GameServer/Types/Comments/GameComment.cs +++ b/Refresh.GameServer/Types/Comments/GameComment.cs @@ -1,7 +1,10 @@ using System.Xml.Serialization; using Realms; using Refresh.GameServer.Database; +using Refresh.GameServer.Types.Relations; +using Refresh.GameServer.Types.Reviews; using Refresh.GameServer.Types.UserData; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. namespace Refresh.GameServer.Types.Comments; @@ -18,6 +21,9 @@ public partial class GameComment : IRealmObject, ISequentialId /// Timestamp in Unix milliseconds /// [XmlElement("timestamp")] public long Timestamp { get; set; } + + [Backlink(nameof(CommentRelation.Comment))] + public IQueryable CommentRelations { get; } #region LBP Serialization Quirks @@ -27,14 +33,15 @@ public partial class GameComment : IRealmObject, ISequentialId [XmlElement("thumbsdown")] [Ignored] public int? ThumbsDown { get; set; } [XmlElement("yourthumb")] [Ignored] public int? YourThumb { get; set; } - public void PrepareForSerialization() + public void PrepareForSerialization(GameUser user) { this.Handle = this.Author.Username; - this.ThumbsUp = 0; - this.ThumbsDown = 0; + this.ThumbsUp = this.CommentRelations.Count(r => r._RatingType == (int)RatingType.Yay); + this.ThumbsDown = this.CommentRelations.Count(r => r._RatingType == (int)RatingType.Boo); - this.YourThumb = 0; + this.YourThumb = (int?)(this.CommentRelations + .FirstOrDefault(r => r.User == user)?.RatingType ?? 0); } #endregion diff --git a/Refresh.GameServer/Types/Relations/CommentRelation.cs b/Refresh.GameServer/Types/Relations/CommentRelation.cs new file mode 100644 index 00000000..ad01817d --- /dev/null +++ b/Refresh.GameServer/Types/Relations/CommentRelation.cs @@ -0,0 +1,25 @@ +using MongoDB.Bson; +using Realms; +using Refresh.GameServer.Types.Comments; +using Refresh.GameServer.Types.Reviews; +using Refresh.GameServer.Types.UserData; + +namespace Refresh.GameServer.Types.Relations; +#nullable disable + +public partial class CommentRelation : IRealmObject +{ + public ObjectId CommentRelationId { get; set; } = ObjectId.GenerateNewId(); + public GameUser User { get; set; } + public GameComment Comment { get; set; } + [Ignored] + public RatingType RatingType + { + get => (RatingType)this._RatingType; + set => this._RatingType = (int)value; + } + + // ReSharper disable once InconsistentNaming + internal int _RatingType { get; set; } + public DateTimeOffset Timestamp { get; set; } +} \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/Comments/CommentTests.cs b/RefreshTests.GameServer/Tests/Comments/CommentTests.cs new file mode 100644 index 00000000..72c45d7b --- /dev/null +++ b/RefreshTests.GameServer/Tests/Comments/CommentTests.cs @@ -0,0 +1,59 @@ +using Refresh.GameServer.Types.Comments; +using Refresh.GameServer.Types.Lists; +using Refresh.GameServer.Types.Reviews; +using Refresh.GameServer.Types.UserData; +using RefreshTests.GameServer.Extensions; +using TokenType = Refresh.GameServer.Authentication.TokenType; + +namespace RefreshTests.GameServer.Tests.Comments; + +public class CommentTests : GameServerTest +{ + public static void RateComment(TestContext context, GameUser user, GameComment comment, string rateCommentUrl, string getCommentsUrl) + { + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + + + foreach (RatingType ratingType in new List {RatingType.Neutral, RatingType.Boo, RatingType.Yay}) + { + for (int i = 0; i < 3; i++) // Rate multiple times to test that duplicate ratings are not added + { + // ReSharper disable once RedundantAssignment + client.PostAsync($"{rateCommentUrl}?commentId={comment.SequentialId}&rating={ratingType.ToDPad()}", null); + } + + HttpResponseMessage response = client.GetAsync(getCommentsUrl).Result; + SerializedCommentList userComments = response.Content.ReadAsXML(); + comment = userComments.Items.First(); + + int expectedThumbsUp, expectedThumbsDown; + + switch (ratingType) + { + case RatingType.Neutral: + expectedThumbsDown = 0; + expectedThumbsUp = 0; + break; + case RatingType.Boo: + expectedThumbsDown = 1; + expectedThumbsUp = 0; + break; + case RatingType.Yay: + expectedThumbsDown = 0; + expectedThumbsUp = 1; + break; + default: + throw new ArgumentOutOfRangeException(); + } + + Assert.Multiple(() => + { + Assert.That(comment.YourThumb, Is.EqualTo(ratingType.ToDPad())); + Assert.That(comment.ThumbsUp, Is.EqualTo(expectedThumbsUp)); + Assert.That(comment.ThumbsDown, Is.EqualTo(expectedThumbsDown)); + }); + } + + + } +} \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/Comments/LevelCommentTests.cs b/RefreshTests.GameServer/Tests/Comments/LevelCommentTests.cs index 1ceae68c..7c52b2c7 100644 --- a/RefreshTests.GameServer/Tests/Comments/LevelCommentTests.cs +++ b/RefreshTests.GameServer/Tests/Comments/LevelCommentTests.cs @@ -155,4 +155,28 @@ public void CantDeleteAnotherUsersComment() response = client2.PostAsync($"/lbp/deleteComment/user/{level.LevelId}?commentId={userComments.Items[0].SequentialId}", new ByteArrayContent(Array.Empty())).Result; Assert.That(response.StatusCode, Is.EqualTo(Unauthorized)); } + + [Test] + public void RateUserLevelComment() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameLevel level = context.CreateLevel(user); + GameComment comment = context.Database.PostCommentToLevel(level, user, "This is a test comment!"); + + CommentTests.RateComment(context, user, comment, $"/lbp/rateComment/user/{level.LevelId}", $"/lbp/comments/user/{level.LevelId}"); + } + + [Test] + public void RateDeveloperLevelComment() + { + const int levelId = 1; + + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameLevel level = context.Database.GetStoryLevelById(levelId); + GameComment comment = context.Database.PostCommentToLevel(level, user, "This is a test comment!"); + + CommentTests.RateComment(context, user, comment, $"/lbp/rateComment/developer/{level.LevelId}", $"/lbp/comments/developer/{level.LevelId}"); + } } \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/Comments/UserCommentTests.cs b/RefreshTests.GameServer/Tests/Comments/UserCommentTests.cs index b5f124aa..6b5cebd6 100644 --- a/RefreshTests.GameServer/Tests/Comments/UserCommentTests.cs +++ b/RefreshTests.GameServer/Tests/Comments/UserCommentTests.cs @@ -151,4 +151,14 @@ public void CantDeleteAnotherUsersComment() response = client2.PostAsync($"/lbp/deleteUserComment/{user2.Username}?commentId={userComments.Items[0].SequentialId}", new ByteArrayContent(Array.Empty())).Result; Assert.That(response.StatusCode, Is.EqualTo(Unauthorized)); } + + [Test] + public void RateProfileComment() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameComment comment = context.Database.PostCommentToProfile(user, user, "This is a test comment!"); + + CommentTests.RateComment(context, user, comment, $"/lbp/rateUserComment/{user.Username}", $"/lbp/userComments/{user.Username}"); + } } \ No newline at end of file