From 0c36d1551120e98c7c549836fc4dccc09815c4b3 Mon Sep 17 00:00:00 2001 From: jvyden Date: Mon, 21 Jul 2025 17:15:06 -0400 Subject: [PATCH 1/5] Add table for level revisions --- Refresh.Database/GameDatabaseContext.cs | 1 + .../20250721211144_AddRevisionTable.cs | 63 +++++++++++++++++ .../GameDatabaseContextModelSnapshot.cs | 68 ++++++++++++++++++- .../Models/Levels/GameLevelRevision.cs | 49 +++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 Refresh.Database/Migrations/20250721211144_AddRevisionTable.cs create mode 100644 Refresh.Database/Models/Levels/GameLevelRevision.cs diff --git a/Refresh.Database/GameDatabaseContext.cs b/Refresh.Database/GameDatabaseContext.cs index 40761df71..0edcf6295 100644 --- a/Refresh.Database/GameDatabaseContext.cs +++ b/Refresh.Database/GameDatabaseContext.cs @@ -71,6 +71,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext internal DbSet PinProgressRelations { get; set; } internal DbSet ProfilePinRelations { get; set; } internal DbSet GameSkillRewards { 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/GameDatabaseContextModelSnapshot.cs b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs index 490549e92..3e960a5d4 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.6") + .HasAnnotation("ProductVersion", "9.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -472,6 +472,53 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("GameLevels"); }); + modelBuilder.Entity("Refresh.Database.Models.Levels.GameLevelRevision", b => + { + b.Property("LevelId") + .HasColumnType("integer"); + + b.Property("RevisionId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .IsRequired() + .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("LevelId", "RevisionId"); + + b.HasIndex("CreatedById"); + + b.ToTable("GameLevelRevisions"); + }); + modelBuilder.Entity("Refresh.Database.Models.Levels.GameSkillReward", b => { b.Property("LevelId") @@ -1593,6 +1640,25 @@ 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") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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..9a4ebd1b9 --- /dev/null +++ b/Refresh.Database/Models/Levels/GameLevelRevision.cs @@ -0,0 +1,49 @@ +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(LevelId), nameof(RevisionId))] +public class GameLevelRevision +{ + /// + /// 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 sequential revision ID for this revision. + /// + [Required] public int RevisionId { 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 From 9d313d02475cf7d31fef2a75f4d2213e99aa146c Mon Sep 17 00:00:00 2001 From: jvyden Date: Wed, 23 Jul 2025 01:06:21 -0400 Subject: [PATCH 2/5] Add migration job for backfilling revisions --- .../GameDatabaseContext.LevelRevisions.cs | 37 +++++++++++ ...50220_MakeRevisionCreatorUserIdNullable.cs | 62 +++++++++++++++++++ .../GameDatabaseContextModelSnapshot.cs | 5 +- .../Models/Levels/GameLevelRevision.cs | 2 +- .../Migrations/BackfillRevisionMigrator.cs | 20 ++++++ .../RefreshWorkerManager.cs | 3 + 6 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 Refresh.Database/GameDatabaseContext.LevelRevisions.cs create mode 100644 Refresh.Database/Migrations/20250723050220_MakeRevisionCreatorUserIdNullable.cs create mode 100644 Refresh.Interfaces.Workers/Migrations/BackfillRevisionMigrator.cs diff --git a/Refresh.Database/GameDatabaseContext.LevelRevisions.cs b/Refresh.Database/GameDatabaseContext.LevelRevisions.cs new file mode 100644 index 000000000..f8d7e0835 --- /dev/null +++ b/Refresh.Database/GameDatabaseContext.LevelRevisions.cs @@ -0,0 +1,37 @@ +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 save = true) + { + GameLevelRevision revision = new() + { + Level = level, + LevelId = level.LevelId, + CreatedAt = this._time.Now, + CreatedBy = creator, + CreatedById = creator?.UserId, + + 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(save) + this.SaveChanges(); + + return revision; + } +} \ No newline at end of file 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/GameDatabaseContextModelSnapshot.cs b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs index 30fee75e7..01498d518 100644 --- a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs +++ b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs @@ -484,7 +484,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone"); b.Property("CreatedById") - .IsRequired() .HasColumnType("text"); b.Property("Description") @@ -1680,9 +1679,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.HasOne("Refresh.Database.Models.Users.GameUser", "CreatedBy") .WithMany() - .HasForeignKey("CreatedById") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .HasForeignKey("CreatedById"); b.HasOne("Refresh.Database.Models.Levels.GameLevel", "Level") .WithMany() diff --git a/Refresh.Database/Models/Levels/GameLevelRevision.cs b/Refresh.Database/Models/Levels/GameLevelRevision.cs index 9a4ebd1b9..29cf76d9d 100644 --- a/Refresh.Database/Models/Levels/GameLevelRevision.cs +++ b/Refresh.Database/Models/Levels/GameLevelRevision.cs @@ -34,7 +34,7 @@ public class GameLevelRevision /// If null, this was actioned by Refresh itself. /// [ForeignKey(nameof(CreatedById))] public GameUser? CreatedBy { get; set; } - public ObjectId CreatedById { get; set; } + public ObjectId? CreatedById { get; set; } public string Title { get; set; } = ""; public string IconHash { get; set; } = ""; diff --git a/Refresh.Interfaces.Workers/Migrations/BackfillRevisionMigrator.cs b/Refresh.Interfaces.Workers/Migrations/BackfillRevisionMigrator.cs new file mode 100644 index 000000000..74cb70650 --- /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, false); + } + } + + 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 From 6c38b760d3790efd9e48e3d051bcff12e60fa8a6 Mon Sep 17 00:00:00 2001 From: jvyden Date: Wed, 23 Jul 2025 01:10:05 -0400 Subject: [PATCH 3/5] Add logging for in-progress migrations --- Refresh.Workers/MigrationJob.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Refresh.Workers/MigrationJob.cs b/Refresh.Workers/MigrationJob.cs index 35bd8389a..6d97db0d7 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; @@ -43,6 +44,7 @@ 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 void Migrate(WorkContext context, TEntity[] batch); From cc86eb9941bb3dc30982144c599983cff6abe909 Mon Sep 17 00:00:00 2001 From: jvyden Date: Wed, 23 Jul 2025 01:17:48 -0400 Subject: [PATCH 4/5] Require SortAndFilter to be implemented for migration jobs --- Refresh.Workers/MigrationJob.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Refresh.Workers/MigrationJob.cs b/Refresh.Workers/MigrationJob.cs index 6d97db0d7..6c4f845b6 100644 --- a/Refresh.Workers/MigrationJob.cs +++ b/Refresh.Workers/MigrationJob.cs @@ -13,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() { @@ -46,6 +45,8 @@ public override void ExecuteJob(WorkContext context) 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 From e004f525150c20b25decc4d264bc0692b4ce2c3f Mon Sep 17 00:00:00 2001 From: jvyden Date: Wed, 23 Jul 2025 02:09:45 -0400 Subject: [PATCH 5/5] Create revisions when publishing and modifying levels --- .../GameDatabaseContext.LevelRevisions.cs | 15 +++-- .../GameDatabaseContext.Levels.cs | 61 +++++++++---------- .../20250723060405_SwapRevisionKeyOrder.cs | 48 +++++++++++++++ .../GameDatabaseContextModelSnapshot.cs | 8 ++- .../Models/Levels/GameLevelRevision.cs | 11 ++-- .../Endpoints/Admin/AdminLevelApiEndpoints.cs | 2 +- .../Endpoints/LevelApiEndpoints.cs | 2 +- .../Migrations/BackfillRevisionMigrator.cs | 2 +- 8 files changed, 102 insertions(+), 47 deletions(-) create mode 100644 Refresh.Database/Migrations/20250723060405_SwapRevisionKeyOrder.cs diff --git a/Refresh.Database/GameDatabaseContext.LevelRevisions.cs b/Refresh.Database/GameDatabaseContext.LevelRevisions.cs index f8d7e0835..e4cf0a312 100644 --- a/Refresh.Database/GameDatabaseContext.LevelRevisions.cs +++ b/Refresh.Database/GameDatabaseContext.LevelRevisions.cs @@ -8,16 +8,23 @@ public partial class GameDatabaseContext // LevelRevisions private IQueryable GameLevelRevisionsIncluded => this.GameLevelRevisions .Include(r => r.Level); - public GameLevelRevision CreateRevisionForLevel(GameLevel level, GameUser? creator, bool save = true) + 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() { - Level = level, LevelId = level.LevelId, CreatedAt = this._time.Now, - CreatedBy = creator, CreatedById = creator?.UserId, + RevisionId = sequentialId + 1, + Title = level.Title, Description = level.Description, GameVersion = level.GameVersion, @@ -29,7 +36,7 @@ public GameLevelRevision CreateRevisionForLevel(GameLevel level, GameUser? creat this.GameLevelRevisions.Add(revision); - if(save) + if(saveChanges) this.SaveChanges(); return revision; 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/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 01498d518..3525657ff 100644 --- a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs +++ b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs @@ -474,10 +474,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Refresh.Database.Models.Levels.GameLevelRevision", b => { - b.Property("LevelId") + b.Property("RevisionId") .HasColumnType("integer"); - b.Property("RevisionId") + b.Property("LevelId") .HasColumnType("integer"); b.Property("CreatedAt") @@ -511,10 +511,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); - b.HasKey("LevelId", "RevisionId"); + b.HasKey("RevisionId", "LevelId"); b.HasIndex("CreatedById"); + b.HasIndex("LevelId"); + b.ToTable("GameLevelRevisions"); }); diff --git a/Refresh.Database/Models/Levels/GameLevelRevision.cs b/Refresh.Database/Models/Levels/GameLevelRevision.cs index 29cf76d9d..113bac87c 100644 --- a/Refresh.Database/Models/Levels/GameLevelRevision.cs +++ b/Refresh.Database/Models/Levels/GameLevelRevision.cs @@ -7,9 +7,14 @@ 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(LevelId), nameof(RevisionId))] +[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. /// @@ -18,10 +23,6 @@ public class GameLevelRevision /// The ID of the level whose snapshot this is. /// [Required] public int LevelId { get; set; } - /// - /// The sequential revision ID for this revision. - /// - [Required] public int RevisionId { get; set; } /// /// The point in time in which this revision was created. 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 index 74cb70650..5a8b3d4fd 100644 --- a/Refresh.Interfaces.Workers/Migrations/BackfillRevisionMigrator.cs +++ b/Refresh.Interfaces.Workers/Migrations/BackfillRevisionMigrator.cs @@ -9,7 +9,7 @@ protected override void Migrate(WorkContext context, GameLevel[] batch) { foreach (GameLevel level in batch) { - context.Database.CreateRevisionForLevel(level, null, false); + context.Database.CreateRevisionForLevel(level, null); } }