diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index ddbcc53..f2c3f41 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -113,6 +113,7 @@ public static class DatabaseBootstrap await EnsureGiftPackageColumnsAsync(db); await EnsureAvatarColumnsAsync(db); await EnsureMarch2023TablesAsync(db); + await EnsureClient2023WireColumnsAsync(db); // Coach system account at Player.Id=1. The RR-Original room seeder // below assigns CreatorPlayerId=1 to every canonical room @@ -817,6 +818,134 @@ public static class DatabaseBootstrap await db.Database.ExecuteSqlRawAsync(sql); } + + /// Schema introduced by the 2023-client wire-contract work: + /// playlist settings the client can actually set, a host-chosen room code, + /// per-role club permissions, the club image gallery, per-message chat + /// moderation state, and QA comments on test cases. + /// + /// This exists for the same reason as the other Ensure* helpers, and the + /// reason is worth stating plainly because forgetting it is a live outage: + /// the Postgres path is EnsureCreated-only and never replays migrations, so + /// an EF migration alone adds nothing to an existing database. A handler + /// that selects one of these columns then fails with "column does not + /// exist" on every request — which is exactly how club home, matchmake/dorm + /// and the presence heartbeat all started returning 500 at once. + /// + /// Every statement is idempotent, so this is safe to run on a fresh + /// database (where EnsureCreated already built everything) and on one that + /// predates the change. + private static async Task EnsureClient2023WireColumnsAsync(DorkNetDbContext db) + { + var provider = db.Database.ProviderName ?? string.Empty; + if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase)) + { + var statements = new[] + { + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""Accessibility"" integer NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""SupportsLevelVoting"" boolean NOT NULL DEFAULT false;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""SupportsJuniors"" boolean NOT NULL DEFAULT true;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""SupportsScreens"" boolean NOT NULL DEFAULT true;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""SupportsTeleportVR"" boolean NOT NULL DEFAULT true;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""SupportsWalkVR"" boolean NOT NULL DEFAULT true;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""WarningMask"" integer NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""CustomWarning"" character varying(512) NOT NULL DEFAULT '';", + @"ALTER TABLE ""PrivateInstances"" ADD COLUMN IF NOT EXISTS ""RoomCode"" character varying(16) NOT NULL DEFAULT '';", + @"ALTER TABLE ""Clubs"" ADD COLUMN IF NOT EXISTS ""MinLevel"" integer NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Clubs"" ADD COLUMN IF NOT EXISTS ""ClubChatEnabled"" boolean NOT NULL DEFAULT true;", + @"ALTER TABLE ""ChatMessages"" ADD COLUMN IF NOT EXISTS ""ModerationState"" integer NOT NULL DEFAULT 0;", + @"ALTER TABLE ""TestCases"" ADD COLUMN IF NOT EXISTS ""CommentsJson"" text NOT NULL DEFAULT '[]';", + """ + CREATE TABLE IF NOT EXISTS "ClubRolePermissions" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "ClubId" bigint NOT NULL, + "MembershipType" integer NOT NULL, + "EditDetails" boolean NOT NULL DEFAULT false, + "ApproveMember" boolean NOT NULL DEFAULT false, + "CreateEvent" boolean NOT NULL DEFAULT false, + "PostAnnouncement" boolean NOT NULL DEFAULT false, + "EditPermissionSettings" boolean NOT NULL DEFAULT false, + "BanUnban" boolean NOT NULL DEFAULT false, + "UpdatedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubRolePermissions_ClubId_MembershipType"" ON ""ClubRolePermissions"" (""ClubId"", ""MembershipType"");", + """ + CREATE TABLE IF NOT EXISTS "ClubAdditionalImages" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "ClubId" bigint NOT NULL, + "Slot" integer NOT NULL, + "ImageName" character varying(256) NOT NULL DEFAULT '', + "UpdatedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");", + }; + foreach (var sql in statements) + await db.Database.ExecuteSqlRawAsync(sql); + return; + } + + // SQLite gets these from Database.Migrate(), but a database that was + // baselined past the migration (BaselineExistingSchemaIfNeeded) would + // skip them, so add the same columns defensively. SQLite has no + // ADD COLUMN IF NOT EXISTS; the duplicate-column error is the success + // case on a database that already has them. + var sqlite = new[] + { + @"ALTER TABLE ""Playlists"" ADD COLUMN ""Accessibility"" INTEGER NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""SupportsLevelVoting"" INTEGER NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""SupportsJuniors"" INTEGER NOT NULL DEFAULT 1;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""SupportsScreens"" INTEGER NOT NULL DEFAULT 1;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""SupportsTeleportVR"" INTEGER NOT NULL DEFAULT 1;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""SupportsWalkVR"" INTEGER NOT NULL DEFAULT 1;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""WarningMask"" INTEGER NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""CustomWarning"" TEXT NOT NULL DEFAULT '';", + @"ALTER TABLE ""PrivateInstances"" ADD COLUMN ""RoomCode"" TEXT NOT NULL DEFAULT '';", + @"ALTER TABLE ""Clubs"" ADD COLUMN ""MinLevel"" INTEGER NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Clubs"" ADD COLUMN ""ClubChatEnabled"" INTEGER NOT NULL DEFAULT 1;", + @"ALTER TABLE ""ChatMessages"" ADD COLUMN ""ModerationState"" INTEGER NOT NULL DEFAULT 0;", + @"ALTER TABLE ""TestCases"" ADD COLUMN ""CommentsJson"" TEXT NOT NULL DEFAULT '[]';", + }; + foreach (var sql in sqlite) + { + try { await db.Database.ExecuteSqlRawAsync(sql); } catch { } + } + + var sqliteTables = new[] + { + """ + CREATE TABLE IF NOT EXISTS "ClubRolePermissions" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_ClubRolePermissions" PRIMARY KEY AUTOINCREMENT, + "ClubId" INTEGER NOT NULL, + "MembershipType" INTEGER NOT NULL, + "EditDetails" INTEGER NOT NULL DEFAULT 0, + "ApproveMember" INTEGER NOT NULL DEFAULT 0, + "CreateEvent" INTEGER NOT NULL DEFAULT 0, + "PostAnnouncement" INTEGER NOT NULL DEFAULT 0, + "EditPermissionSettings" INTEGER NOT NULL DEFAULT 0, + "BanUnban" INTEGER NOT NULL DEFAULT 0, + "UpdatedAt" TEXT NOT NULL DEFAULT '' + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubRolePermissions_ClubId_MembershipType"" ON ""ClubRolePermissions"" (""ClubId"", ""MembershipType"");", + """ + CREATE TABLE IF NOT EXISTS "ClubAdditionalImages" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_ClubAdditionalImages" PRIMARY KEY AUTOINCREMENT, + "ClubId" INTEGER NOT NULL, + "Slot" INTEGER NOT NULL, + "ImageName" TEXT NOT NULL DEFAULT '', + "UpdatedAt" TEXT NOT NULL DEFAULT '' + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");", + }; + foreach (var sql in sqliteTables) + { + try { await db.Database.ExecuteSqlRawAsync(sql); } catch { } + } + } + private static async Task EnsureMarch2023TablesAsync(DorkNetDbContext db) { var provider = db.Database.ProviderName ?? string.Empty;