diff --git a/Refresh.Database/GameDatabaseContext.Moderation.cs b/Refresh.Database/GameDatabaseContext.Moderation.cs new file mode 100644 index 00000000..c4d64f2d --- /dev/null +++ b/Refresh.Database/GameDatabaseContext.Moderation.cs @@ -0,0 +1,104 @@ +using Refresh.Database.Models.Users; +using Refresh.Database.Models.Moderation; +using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Levels.Scores; +using Refresh.Database.Models.Photos; +using Refresh.Database.Models.Comments; +using Refresh.Database.Models.Playlists; +using Refresh.Database.Models.Assets; +using Refresh.Database.Models.Levels.Challenges; + +namespace Refresh.Database; + +public partial class GameDatabaseContext // Moderation +{ + private IQueryable ModerationActionsIncluded => this.ModerationActions + .Include(s => s.Actor) + .Include(s => s.InvolvedUser); + + #region Retrieval + + public DatabaseList GetModerationActions(int skip, int count) + { + return new(this.ModerationActionsIncluded.OrderByDescending(a => a.Timestamp), skip, count); + } + + public DatabaseList GetModerationActionsByActor(GameUser actor, int skip, int count) + { + return new(this.ModerationActionsIncluded + .Where(a => a.ActorId == actor.UserId) + .OrderByDescending(a => a.Timestamp), skip, count); + } + + public DatabaseList GetModerationActionsForInvolvedUser(GameUser involvedUser, int skip, int count) + { + return new(this.ModerationActionsIncluded + .Where(a => a.InvolvedUserId == involvedUser.UserId) + .OrderByDescending(a => a.Timestamp), skip, count); + } + + public DatabaseList GetModerationActionsForObject(string id, ModerationObjectType objectType, int skip, int count) + { + return new(this.ModerationActionsIncluded + .Where(a => a.ModeratedObjectType == objectType && a.ModeratedObjectId == id) + .OrderByDescending(a => a.Timestamp), skip, count); + } + + #endregion + + #region Creation + + public ModerationAction CreateModerationAction(GameUser user, ModerationActionType actionType, GameUser actor, string description) + => this.CreateModerationActionInternal(user.UserId.ToString(), ModerationObjectType.User, actionType, actor, user, description); + + public ModerationAction CreateModerationAction(GameLevel level, ModerationActionType actionType, GameUser actor, string description) + => this.CreateModerationActionInternal(level.LevelId.ToString(), ModerationObjectType.Level, actionType, actor, level.Publisher, description); + + public ModerationAction CreateModerationAction(GameScore score, ModerationActionType actionType, GameUser actor, string description) + => this.CreateModerationActionInternal(score.ScoreId.ToString(), ModerationObjectType.Score, actionType, actor, score.Publisher, description); + + public ModerationAction CreateModerationAction(GamePhoto photo, ModerationActionType actionType, GameUser actor, string description) + => this.CreateModerationActionInternal(photo.PhotoId.ToString(), ModerationObjectType.Photo, actionType, actor, photo.Publisher, description); + + public ModerationAction CreateModerationAction(GameReview review, ModerationActionType actionType, GameUser actor, string description) + => this.CreateModerationActionInternal(review.ReviewId.ToString(), ModerationObjectType.Review, actionType, actor, review.Publisher, description); + + public ModerationAction CreateModerationAction(GameLevelComment comment, ModerationActionType actionType, GameUser actor, string description) + => this.CreateModerationActionInternal(comment.SequentialId.ToString(), ModerationObjectType.LevelComment, actionType, actor, comment.Author, description); + + public ModerationAction CreateModerationAction(GameProfileComment comment, ModerationActionType actionType, GameUser actor, string description) + => this.CreateModerationActionInternal(comment.SequentialId.ToString(), ModerationObjectType.UserComment, actionType, actor, comment.Author, description); + + public ModerationAction CreateModerationAction(GamePlaylist playlist, ModerationActionType actionType, GameUser actor, string description) + => this.CreateModerationActionInternal(playlist.PlaylistId.ToString(), ModerationObjectType.Playlist, actionType, actor, playlist.Publisher, description); + + public ModerationAction CreateModerationAction(GameAsset asset, ModerationActionType actionType, GameUser actor, string description) + => this.CreateModerationActionInternal(asset.AssetHash, ModerationObjectType.Asset, actionType, actor, asset.OriginalUploader, description); + + public ModerationAction CreateModerationAction(GameChallenge challenge, ModerationActionType actionType, GameUser actor, string description) + => this.CreateModerationActionInternal(challenge.ChallengeId.ToString(), ModerationObjectType.Challenge, actionType, actor, challenge.Publisher, description); + + public ModerationAction CreateModerationAction(GameChallengeScore score, ModerationActionType actionType, GameUser actor, string description) + => this.CreateModerationActionInternal(score.ScoreId.ToString(), ModerationObjectType.Score, actionType, actor, score.Publisher, description); + + private ModerationAction CreateModerationActionInternal(string id, ModerationObjectType objectType, ModerationActionType actionType, GameUser actor, GameUser? involvedUser, string description) + { + ModerationAction moderationAction = new() + { + ModeratedObjectId = id, + ModeratedObjectType = objectType, + ActionType = actionType, + Actor = actor, + InvolvedUser = involvedUser, + Description = description, + Timestamp = this._time.Now, + }; + + this.ModerationActions.Add(moderationAction); + this.SaveChanges(); + + return moderationAction; + } + + #endregion +} \ No newline at end of file diff --git a/Refresh.Database/GameDatabaseContext.cs b/Refresh.Database/GameDatabaseContext.cs index 4217d8cd..a35eea0c 100644 --- a/Refresh.Database/GameDatabaseContext.cs +++ b/Refresh.Database/GameDatabaseContext.cs @@ -22,6 +22,7 @@ using Refresh.Database.Models.Statistics; using Refresh.Database.Models.Workers; using LogLevel = Microsoft.Extensions.Logging.LogLevel; +using Refresh.Database.Models.Moderation; namespace Refresh.Database; @@ -79,6 +80,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext internal DbSet Workers { get; set; } internal DbSet JobStates { get; set; } internal DbSet GameLevelRevisions { get; set; } + internal DbSet ModerationActions { get; set; } #pragma warning disable CS8618 // Non-nullable variable must contain a non-null value when exiting constructor. Consider declaring it as nullable. internal GameDatabaseContext(Logger logger, IDateTimeProvider time, IDatabaseConfig dbConfig) diff --git a/Refresh.Database/Migrations/20251028183803_AddModerationAction.cs b/Refresh.Database/Migrations/20251028183803_AddModerationAction.cs new file mode 100644 index 00000000..d413ed05 --- /dev/null +++ b/Refresh.Database/Migrations/20251028183803_AddModerationAction.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Refresh.Database.Migrations +{ + /// + [DbContext(typeof(GameDatabaseContext))] + [Migration("20251028183803_AddModerationAction")] + public partial class AddModerationAction : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ModerationActions", + columns: table => new + { + ActionId = table.Column(type: "text", nullable: false), + ActionType = table.Column(type: "smallint", nullable: false), + ModeratedObjectType = table.Column(type: "smallint", nullable: false), + ModeratedObjectId = table.Column(type: "text", nullable: false), + ActorId = table.Column(type: "text", nullable: false), + InvolvedUserId = table.Column(type: "text", nullable: true), + Description = table.Column(type: "text", nullable: false), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ModerationActions", x => x.ActionId); + table.ForeignKey( + name: "FK_ModerationActions_GameUsers_ActorId", + column: x => x.ActorId, + principalTable: "GameUsers", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ModerationActions_GameUsers_InvolvedUserId", + column: x => x.InvolvedUserId, + principalTable: "GameUsers", + principalColumn: "UserId"); + }); + + migrationBuilder.CreateIndex( + name: "IX_ModerationActions_ActorId", + table: "ModerationActions", + column: "ActorId"); + + migrationBuilder.CreateIndex( + name: "IX_ModerationActions_InvolvedUserId", + table: "ModerationActions", + column: "InvolvedUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ModerationActions"); + } + } +} diff --git a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs index 88a67cdb..22908ac3 100644 --- a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs +++ b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -598,6 +598,43 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("GameScores"); }); + modelBuilder.Entity("Refresh.Database.Models.Moderation.ModerationAction", b => + { + b.Property("ActionId") + .HasColumnType("text"); + + b.Property("ActionType") + .HasColumnType("smallint"); + + b.Property("ActorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("InvolvedUserId") + .HasColumnType("text"); + + b.Property("ModeratedObjectId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ModeratedObjectType") + .HasColumnType("smallint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ActionId"); + + b.HasIndex("ActorId"); + + b.HasIndex("InvolvedUserId"); + + b.ToTable("ModerationActions"); + }); + modelBuilder.Entity("Refresh.Database.Models.Notifications.GameAnnouncement", b => { b.Property("AnnouncementId") @@ -1912,6 +1949,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Publisher"); }); + modelBuilder.Entity("Refresh.Database.Models.Moderation.ModerationAction", b => + { + b.HasOne("Refresh.Database.Models.Users.GameUser", "Actor") + .WithMany() + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Refresh.Database.Models.Users.GameUser", "InvolvedUser") + .WithMany() + .HasForeignKey("InvolvedUserId"); + + b.Navigation("Actor"); + + b.Navigation("InvolvedUser"); + }); + modelBuilder.Entity("Refresh.Database.Models.Notifications.GameNotification", b => { b.HasOne("Refresh.Database.Models.Users.GameUser", "User") diff --git a/Refresh.Database/Models/Moderation/ModerationAction.cs b/Refresh.Database/Models/Moderation/ModerationAction.cs new file mode 100644 index 00000000..4dd02551 --- /dev/null +++ b/Refresh.Database/Models/Moderation/ModerationAction.cs @@ -0,0 +1,50 @@ +using MongoDB.Bson; +using Refresh.Database.Models.Users; + +namespace Refresh.Database.Models.Moderation; + +#nullable disable + +public partial class ModerationAction +{ + [Key] public ObjectId ActionId { get; set; } = ObjectId.GenerateNewId(); + + /// + /// Describes what was done with the object. + /// + public ModerationActionType ActionType { get; set; } + + /// + /// The type of data/object that was moderated. + /// + public ModerationObjectType ModeratedObjectType { get; set; } + + /// + /// The ID of the object that was moderated. May be a UUID, sequential ID, or a GameAsset hash, + /// depending on ModeratedObjectType's value + /// + [Required] public string ModeratedObjectId { get; set; } + + /// + /// The user in question who has moderated the object. + /// + [Required, ForeignKey(nameof(ActorId))] public GameUser Actor { get; set; } + [Required] public ObjectId ActorId { get; set; } + + #nullable restore + + /// + /// Usually the publisher/owner of the object that was moderated. May also see this moderation action. + /// + [ForeignKey(nameof(InvolvedUserId))] public GameUser? InvolvedUser { get; set; } + public ObjectId? InvolvedUserId { get; set; } + + #nullable disable + + /// + /// A description, stating the reason of this moderation action. + /// + public string Description { get; set; } + + public DateTimeOffset Timestamp { get; set; } +} \ No newline at end of file diff --git a/Refresh.Database/Models/Moderation/ModerationActionType.cs b/Refresh.Database/Models/Moderation/ModerationActionType.cs new file mode 100644 index 00000000..7c6864d5 --- /dev/null +++ b/Refresh.Database/Models/Moderation/ModerationActionType.cs @@ -0,0 +1,51 @@ +using Newtonsoft.Json.Converters; + +namespace Refresh.Database.Models.Moderation; + +[JsonConverter(typeof(StringEnumConverter))] +public enum ModerationActionType : byte +{ + // Users + UserModification, + UserDeletion, + UserPunishment, + UserPardon, + PinProgressDeletion, + + // Levels + LevelModification, + LevelDeletion, + + // Playlists + PlaylistModification, + PlaylistDeletion, + + // Photos + PhotoDeletion, + PhotosByUserDeletion, + + // Scores + ScoreDeletion, + ScoresByUserForLevelDeletion, + ScoresByUserDeletion, + + // Reviews + ReviewDeletion, + ReviewsByUserDeletion, + + // Comments + LevelCommentDeletion, + LevelCommentsByUserDeletion, + ProfileCommentDeletion, + ProfileCommentsByUserDeletion, + + // Assets + BlockAsset, + UnblockAsset, + + // Challenges + ChallengeDeletion, + ChallengesByUserDeletion, + ChallengeScoreDeletion, + ChallengeScoresByUserDeletion, +} \ No newline at end of file diff --git a/Refresh.Database/Models/Moderation/ModerationObjectType.cs b/Refresh.Database/Models/Moderation/ModerationObjectType.cs new file mode 100644 index 00000000..0346476e --- /dev/null +++ b/Refresh.Database/Models/Moderation/ModerationObjectType.cs @@ -0,0 +1,16 @@ +namespace Refresh.Database.Models.Moderation; + +public enum ModerationObjectType : byte +{ + User, + Level, + Score, + Photo, + Review, + LevelComment, + UserComment, + Playlist, + Asset, + Challenge, + ChallengeScore, +} \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/Moderation/ModerationActionTests.cs b/RefreshTests.GameServer/Tests/Moderation/ModerationActionTests.cs new file mode 100644 index 00000000..6e4802e7 --- /dev/null +++ b/RefreshTests.GameServer/Tests/Moderation/ModerationActionTests.cs @@ -0,0 +1,100 @@ +using Refresh.Database; +using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Moderation; +using Refresh.Database.Models.Users; + +namespace RefreshTests.GameServer.Tests.Moderation; + +public class ModerationActionTests : GameServerTest +{ + [Test] + public void CreateUserModerationAction() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameUser mod = context.CreateUser(); + GameUser unrelated = context.CreateUser(); + + // Create action + ModerationAction action = context.Database.CreateModerationAction(user, ModerationActionType.UserModification, mod, "Cringe description"); + + // Retrieve all actions + DatabaseList list = context.Database.GetModerationActions(0, 10); + Assert.That(list.TotalItems, Is.EqualTo(1)); + Assert.That(list.Items.Count(), Is.EqualTo(1)); + Assert.That(list.Items.First().ActionId, Is.EqualTo(action.ActionId)); + + // Retrieve actions for moderator + list = context.Database.GetModerationActionsByActor(mod, 0, 10); + Assert.That(list.TotalItems, Is.EqualTo(1)); + Assert.That(list.Items.Count(), Is.EqualTo(1)); + Assert.That(list.Items.First().ActionId, Is.EqualTo(action.ActionId)); + + // Retrieve actions for user + list = context.Database.GetModerationActionsForInvolvedUser(user, 0, 10); + Assert.That(list.TotalItems, Is.EqualTo(1)); + Assert.That(list.Items.Count(), Is.EqualTo(1)); + Assert.That(list.Items.First().ActionId, Is.EqualTo(action.ActionId)); + + // Retrieve actions for user profile + list = context.Database.GetModerationActionsForObject(user.UserId.ToString(), ModerationObjectType.User, 0, 10); + Assert.That(list.TotalItems, Is.EqualTo(1)); + Assert.That(list.Items.Count(), Is.EqualTo(1)); + Assert.That(list.Items.First().ActionId, Is.EqualTo(action.ActionId)); + + // Hide from unrelated user + list = context.Database.GetModerationActionsByActor(unrelated, 0, 10); + Assert.That(list.TotalItems, Is.EqualTo(0)); + Assert.That(list.Items.Count(), Is.EqualTo(0)); + + list = context.Database.GetModerationActionsForInvolvedUser(unrelated, 0, 10); + Assert.That(list.TotalItems, Is.EqualTo(0)); + Assert.That(list.Items.Count(), Is.EqualTo(0)); + } + + [Test] + public void CreateLevelModerationAction() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameUser mod = context.CreateUser(); + GameUser unrelated = context.CreateUser(); + GameLevel level = context.CreateLevel(user); + + // Create action + ModerationAction action = context.Database.CreateModerationAction(level, ModerationActionType.LevelModification, mod, "Cringe icon"); + + // Retrieve all actions + DatabaseList list = context.Database.GetModerationActions(0, 10); + Assert.That(list.TotalItems, Is.EqualTo(1)); + Assert.That(list.Items.Count(), Is.EqualTo(1)); + Assert.That(list.Items.First().ActionId, Is.EqualTo(action.ActionId)); + + // Retrieve actions for moderator + list = context.Database.GetModerationActionsByActor(mod, 0, 10); + Assert.That(list.TotalItems, Is.EqualTo(1)); + Assert.That(list.Items.Count(), Is.EqualTo(1)); + Assert.That(list.Items.First().ActionId, Is.EqualTo(action.ActionId)); + + // Retrieve actions for user + list = context.Database.GetModerationActionsForInvolvedUser(user, 0, 10); + Assert.That(list.TotalItems, Is.EqualTo(1)); + Assert.That(list.Items.Count(), Is.EqualTo(1)); + Assert.That(list.Items.First().ActionId, Is.EqualTo(action.ActionId)); + + // Retrieve actions for level + list = context.Database.GetModerationActionsForObject(level.LevelId.ToString(), ModerationObjectType.Level, 0, 10); + Assert.That(list.TotalItems, Is.EqualTo(1)); + Assert.That(list.Items.Count(), Is.EqualTo(1)); + Assert.That(list.Items.First().ActionId, Is.EqualTo(action.ActionId)); + + // Hide from unrelated user + list = context.Database.GetModerationActionsByActor(unrelated, 0, 10); + Assert.That(list.TotalItems, Is.EqualTo(0)); + Assert.That(list.Items.Count(), Is.EqualTo(0)); + + list = context.Database.GetModerationActionsForInvolvedUser(unrelated, 0, 10); + Assert.That(list.TotalItems, Is.EqualTo(0)); + Assert.That(list.Items.Count(), Is.EqualTo(0)); + } +} \ No newline at end of file