diff --git a/Refresh.Database/GameDatabaseContext.LevelRevisions.cs b/Refresh.Database/GameDatabaseContext.LevelRevisions.cs new file mode 100644 index 000000000..e4cf0a312 --- /dev/null +++ b/Refresh.Database/GameDatabaseContext.LevelRevisions.cs @@ -0,0 +1,44 @@ +using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Users; + +namespace Refresh.Database; + +public partial class GameDatabaseContext // LevelRevisions +{ + private IQueryable GameLevelRevisionsIncluded => this.GameLevelRevisions + .Include(r => r.Level); + + public GameLevelRevision CreateRevisionForLevel(GameLevel level, GameUser? creator, bool saveChanges = false) + { + // FIXME: this isn't exactly atomic, but it should be incredibly rare + // for multiple threads to be trying to make a revision for the same level at the same time + int sequentialId = this.GameLevelRevisions + .Where(r => r.LevelId == level.LevelId) + .DefaultIfEmpty() + .Max(r => r != null ? r.RevisionId : 0); + + GameLevelRevision revision = new() + { + LevelId = level.LevelId, + CreatedAt = this._time.Now, + CreatedById = creator?.UserId, + + RevisionId = sequentialId + 1, + + Title = level.Title, + Description = level.Description, + GameVersion = level.GameVersion, + IconHash = level.IconHash, + RootResource = level.RootResource, + LevelType = level.LevelType, + StoryId = level.StoryId, + }; + + this.GameLevelRevisions.Add(revision); + + if(saveChanges) + this.SaveChanges(); + + return revision; + } +} \ No newline at end of file diff --git a/Refresh.Database/GameDatabaseContext.Levels.cs b/Refresh.Database/GameDatabaseContext.Levels.cs index 130797ffe..2bd8de6e4 100644 --- a/Refresh.Database/GameDatabaseContext.Levels.cs +++ b/Refresh.Database/GameDatabaseContext.Levels.cs @@ -55,19 +55,18 @@ public bool AddLevel(GameLevel level) level.OriginalPublisher = levelAttributes.GetValueOrDefault("op") ?? SystemUsers.UnknownUserName; } - this.Write(() => - { - this.GameLevels.Add(level); - }); - - this.Write(() => + this.GameLevels.Add(level); + + this.SaveChanges(); + + this.CreateRevisionForLevel(level, level.Publisher); + this.GameLevelStatistics.Add(level.Statistics = new GameLevelStatistics { - this.GameLevelStatistics.Add(level.Statistics = new GameLevelStatistics() - { - LevelId = level.LevelId, - }); + LevelId = level.LevelId, }); + this.SaveChanges(); + if (level.Publisher != null) { this.WriteEnsuringStatistics(level.Publisher, () => @@ -206,20 +205,19 @@ public void UpdateLevelLocations(IEnumerable locat // Now newLevel is set up to replace oldLevel. // If information is lost here, then that's probably a bug. // Update the level's properties in the database - this.Write(() => + PropertyInfo[] userProps = typeof(GameLevel).GetProperties(); + foreach (PropertyInfo prop in userProps) { - PropertyInfo[] userProps = typeof(GameLevel).GetProperties(); - foreach (PropertyInfo prop in userProps) - { - if (!prop.CanWrite || !prop.CanRead) continue; - prop.SetValue(oldLevel, prop.GetValue(newLevel)); - } - }); + if (!prop.CanWrite || !prop.CanRead) continue; + prop.SetValue(oldLevel, prop.GetValue(newLevel)); + } + this.CreateRevisionForLevel(newLevel, author); + this.SaveChanges(); return oldLevel; } - public GameLevel? UpdateLevel(IApiEditLevelRequest body, GameLevel level) + public GameLevel? UpdateLevel(IApiEditLevelRequest body, GameLevel level, GameUser? updatingUser) { if (body.Title is { Length: > UgcLimits.TitleLimit }) body.Title = body.Title[..UgcLimits.TitleLimit]; @@ -227,25 +225,24 @@ public void UpdateLevelLocations(IEnumerable locat if (body.Description is { Length: > UgcLimits.DescriptionLimit }) body.Description = body.Description[..UgcLimits.DescriptionLimit]; - this.Write(() => + PropertyInfo[] userProps = body.GetType().GetProperties(); + foreach (PropertyInfo prop in userProps) { - PropertyInfo[] userProps = body.GetType().GetProperties(); - foreach (PropertyInfo prop in userProps) - { - if (!prop.CanWrite || !prop.CanRead) continue; + if (!prop.CanWrite || !prop.CanRead) continue; - object? propValue = prop.GetValue(body); - if(propValue == null) continue; + object? propValue = prop.GetValue(body); + if(propValue == null) continue; - PropertyInfo? gameLevelProp = level.GetType().GetProperty(prop.Name); - Debug.Assert(gameLevelProp != null, $"Invalid property {prop.Name} on {nameof(IApiEditLevelRequest)}"); + PropertyInfo? gameLevelProp = level.GetType().GetProperty(prop.Name); + Debug.Assert(gameLevelProp != null, $"Invalid property {prop.Name} on {nameof(IApiEditLevelRequest)}"); - gameLevelProp.SetValue(level, prop.GetValue(body)); - } + gameLevelProp.SetValue(level, prop.GetValue(body)); + } - level.UpdateDate = this._time.Now; - }); + level.UpdateDate = this._time.Now; + this.CreateRevisionForLevel(level, updatingUser); + this.SaveChanges(); return level; } diff --git a/Refresh.Database/GameDatabaseContext.cs b/Refresh.Database/GameDatabaseContext.cs index 3e8c909ff..b8002b525 100644 --- a/Refresh.Database/GameDatabaseContext.cs +++ b/Refresh.Database/GameDatabaseContext.cs @@ -74,6 +74,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext internal DbSet GameSkillRewards { get; set; } internal DbSet Workers { get; set; } internal DbSet JobStates { get; set; } + internal DbSet GameLevelRevisions { 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/20250721211144_AddRevisionTable.cs b/Refresh.Database/Migrations/20250721211144_AddRevisionTable.cs new file mode 100644 index 000000000..1a73a2297 --- /dev/null +++ b/Refresh.Database/Migrations/20250721211144_AddRevisionTable.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Refresh.Database.Migrations +{ + [DbContext(typeof(GameDatabaseContext))] + [Migration("20250721211144_AddRevisionTable")] + /// + public partial class AddRevisionTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "GameLevelRevisions", + columns: table => new + { + LevelId = table.Column(type: "integer", nullable: false), + RevisionId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedById = table.Column(type: "text", nullable: false), + Title = table.Column(type: "text", nullable: false), + IconHash = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: false), + RootResource = table.Column(type: "text", nullable: false), + GameVersion = table.Column(type: "integer", nullable: false), + LevelType = table.Column(type: "smallint", nullable: false), + StoryId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GameLevelRevisions", x => new { x.LevelId, x.RevisionId }); + table.ForeignKey( + name: "FK_GameLevelRevisions_GameLevels_LevelId", + column: x => x.LevelId, + principalTable: "GameLevels", + principalColumn: "LevelId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GameLevelRevisions_GameUsers_CreatedById", + column: x => x.CreatedById, + principalTable: "GameUsers", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GameLevelRevisions_CreatedById", + table: "GameLevelRevisions", + column: "CreatedById"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GameLevelRevisions"); + } + } +} diff --git a/Refresh.Database/Migrations/20250723050220_MakeRevisionCreatorUserIdNullable.cs b/Refresh.Database/Migrations/20250723050220_MakeRevisionCreatorUserIdNullable.cs new file mode 100644 index 000000000..8bee5e61a --- /dev/null +++ b/Refresh.Database/Migrations/20250723050220_MakeRevisionCreatorUserIdNullable.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Refresh.Database.Migrations +{ + [DbContext(typeof(GameDatabaseContext))] + [Migration("20250723050220_MakeRevisionCreatorUserIdNullable")] + /// + public partial class MakeRevisionCreatorUserIdNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_GameLevelRevisions_GameUsers_CreatedById", + table: "GameLevelRevisions"); + + migrationBuilder.AlterColumn( + name: "CreatedById", + table: "GameLevelRevisions", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AddForeignKey( + name: "FK_GameLevelRevisions_GameUsers_CreatedById", + table: "GameLevelRevisions", + column: "CreatedById", + principalTable: "GameUsers", + principalColumn: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_GameLevelRevisions_GameUsers_CreatedById", + table: "GameLevelRevisions"); + + migrationBuilder.AlterColumn( + name: "CreatedById", + table: "GameLevelRevisions", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_GameLevelRevisions_GameUsers_CreatedById", + table: "GameLevelRevisions", + column: "CreatedById", + principalTable: "GameUsers", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/Refresh.Database/Migrations/20250723060405_SwapRevisionKeyOrder.cs b/Refresh.Database/Migrations/20250723060405_SwapRevisionKeyOrder.cs new file mode 100644 index 000000000..3798fb095 --- /dev/null +++ b/Refresh.Database/Migrations/20250723060405_SwapRevisionKeyOrder.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Refresh.Database.Migrations +{ + [DbContext(typeof(GameDatabaseContext))] + [Migration("20250723060405_SwapRevisionKeyOrder")] + /// + public partial class SwapRevisionKeyOrder : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_GameLevelRevisions", + table: "GameLevelRevisions"); + + migrationBuilder.AddPrimaryKey( + name: "PK_GameLevelRevisions", + table: "GameLevelRevisions", + columns: new[] { "RevisionId", "LevelId" }); + + migrationBuilder.CreateIndex( + name: "IX_GameLevelRevisions_LevelId", + table: "GameLevelRevisions", + column: "LevelId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_GameLevelRevisions", + table: "GameLevelRevisions"); + + migrationBuilder.DropIndex( + name: "IX_GameLevelRevisions_LevelId", + table: "GameLevelRevisions"); + + migrationBuilder.AddPrimaryKey( + name: "PK_GameLevelRevisions", + table: "GameLevelRevisions", + columns: new[] { "LevelId", "RevisionId" }); + } + } +} diff --git a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs index 4d3b7c7e3..3525657ff 100644 --- a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs +++ b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs @@ -472,6 +472,54 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("GameLevels"); }); + modelBuilder.Entity("Refresh.Database.Models.Levels.GameLevelRevision", b => + { + b.Property("RevisionId") + .HasColumnType("integer"); + + b.Property("LevelId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameVersion") + .HasColumnType("integer"); + + b.Property("IconHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("LevelType") + .HasColumnType("smallint"); + + b.Property("RootResource") + .IsRequired() + .HasColumnType("text"); + + b.Property("StoryId") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("RevisionId", "LevelId"); + + b.HasIndex("CreatedById"); + + b.HasIndex("LevelId"); + + b.ToTable("GameLevelRevisions"); + }); + modelBuilder.Entity("Refresh.Database.Models.Levels.GameSkillReward", b => { b.Property("LevelId") @@ -1629,6 +1677,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Statistics"); }); + modelBuilder.Entity("Refresh.Database.Models.Levels.GameLevelRevision", b => + { + b.HasOne("Refresh.Database.Models.Users.GameUser", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.HasOne("Refresh.Database.Models.Levels.GameLevel", "Level") + .WithMany() + .HasForeignKey("LevelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedBy"); + + b.Navigation("Level"); + }); + modelBuilder.Entity("Refresh.Database.Models.Levels.GameSkillReward", b => { b.HasOne("Refresh.Database.Models.Levels.GameLevel", "Level") diff --git a/Refresh.Database/Models/Levels/GameLevelRevision.cs b/Refresh.Database/Models/Levels/GameLevelRevision.cs new file mode 100644 index 000000000..113bac87c --- /dev/null +++ b/Refresh.Database/Models/Levels/GameLevelRevision.cs @@ -0,0 +1,50 @@ +using MongoDB.Bson; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Users; + +namespace Refresh.Database.Models.Levels; + +/// +/// A snapshot of a level's details at a point in time. This can be used for moderation, rollbacks of migrations, and in LBP hub's challenges. +/// +[PrimaryKey(nameof(RevisionId), nameof(LevelId))] +public class GameLevelRevision +{ + /// + /// The sequential revision ID for this revision. + /// + [Required] public int RevisionId { get; set; } + + /// + /// The level whose snapshot this is. + /// + [ForeignKey(nameof(LevelId))] public GameLevel Level { get; set; } = null!; + /// + /// The ID of the level whose snapshot this is. + /// + [Required] public int LevelId { get; set; } + + /// + /// The point in time in which this revision was created. + /// + public DateTimeOffset CreatedAt { get; set; } + /// + /// The user this revision was created by. + /// + /// + /// If null, this was actioned by Refresh itself. + /// + [ForeignKey(nameof(CreatedById))] public GameUser? CreatedBy { get; set; } + public ObjectId? CreatedById { get; set; } + + public string Title { get; set; } = ""; + public string IconHash { get; set; } = ""; + public string Description { get; set; } = ""; + + public string RootResource { get; set; } = ""; + + public TokenGame GameVersion { get; set; } + public GameLevelType LevelType { get; set; } + + public int StoryId { get; set; } +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLevelApiEndpoints.cs index c80ae0434..09df765ff 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLevelApiEndpoints.cs @@ -57,7 +57,7 @@ public ApiResponse EditLevelById(RequestContext context, G !dataContext.GuidChecker.IsTextureGuid(level.GameVersion, long.Parse(body.IconHash))) return ApiValidationError.InvalidTextureGuidError; - level = database.UpdateLevel(body, level); + level = database.UpdateLevel(body, level, user); return ApiGameLevelResponse.FromOld(level, dataContext); } diff --git a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs index 764ed24f3..eef6488bf 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs @@ -121,7 +121,7 @@ public ApiResponse EditLevelById(RequestContext context, !dataContext.GuidChecker.IsTextureGuid(level.GameVersion, long.Parse(body.IconHash))) return ApiValidationError.InvalidTextureGuidError; - level = dataContext.Database.UpdateLevel(body, level); + level = dataContext.Database.UpdateLevel(body, level, dataContext.User); return ApiGameLevelResponse.FromOld(level, dataContext); } diff --git a/Refresh.Interfaces.Workers/Migrations/BackfillRevisionMigrator.cs b/Refresh.Interfaces.Workers/Migrations/BackfillRevisionMigrator.cs new file mode 100644 index 000000000..5a8b3d4fd --- /dev/null +++ b/Refresh.Interfaces.Workers/Migrations/BackfillRevisionMigrator.cs @@ -0,0 +1,20 @@ +using Refresh.Database.Models.Levels; +using Refresh.Workers; + +namespace Refresh.Interfaces.Workers.Migrations; + +public class BackfillRevisionMigration : MigrationJob +{ + protected override void Migrate(WorkContext context, GameLevel[] batch) + { + foreach (GameLevel level in batch) + { + context.Database.CreateRevisionForLevel(level, null); + } + } + + protected override IQueryable SortAndFilter(IQueryable query) + { + return query.OrderBy(l => l.LevelId); + } +} \ No newline at end of file diff --git a/Refresh.Interfaces.Workers/RefreshWorkerManager.cs b/Refresh.Interfaces.Workers/RefreshWorkerManager.cs index a738f969d..e2e3beabb 100644 --- a/Refresh.Interfaces.Workers/RefreshWorkerManager.cs +++ b/Refresh.Interfaces.Workers/RefreshWorkerManager.cs @@ -1,6 +1,7 @@ using Bunkum.Core.Storage; using NotEnoughLogs; using Refresh.Database; +using Refresh.Interfaces.Workers.Migrations; using Refresh.Interfaces.Workers.Repeating; using Refresh.Workers; @@ -18,6 +19,8 @@ public static WorkerManager Create(Logger logger, IDataStore dataStore, GameData manager.AddJob(); manager.AddJob(); + manager.AddJob(); + return manager; } } \ No newline at end of file diff --git a/Refresh.Workers/MigrationJob.cs b/Refresh.Workers/MigrationJob.cs index 35bd8389a..6c4f845b6 100644 --- a/Refresh.Workers/MigrationJob.cs +++ b/Refresh.Workers/MigrationJob.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore.Storage; +using Refresh.Core; using Refresh.Workers.State; namespace Refresh.Workers; @@ -12,7 +13,6 @@ public abstract class MigrationJob : WorkerJob, IJobStoresState where T public MigrationJobState? MigrationJobState => JobState as MigrationJobState; protected virtual int BatchCount => 1_000; - protected virtual IQueryable SortAndFilter(IQueryable query) => query; public override bool CanExecute() { @@ -43,7 +43,10 @@ public override void ExecuteJob(WorkContext context) transaction.Commit(); state.Processed += batch.Length; + context.Logger.LogInfo(RefreshContext.Database, $"{this.JobId} migrated {batch.Length} objects ({state.Processed}/{state.Total}, complete: {state.Complete})"); } + + protected abstract IQueryable SortAndFilter(IQueryable query); protected abstract void Migrate(WorkContext context, TEntity[] batch); } \ No newline at end of file