From f7c9244a93a2f68d5b80a40fe066027f8e399848 Mon Sep 17 00:00:00 2001 From: Ben Stein <115497763+sei-bstein@users.noreply.github.com> Date: Fri, 8 Nov 2024 10:35:56 -0500 Subject: [PATCH 1/7] v3.24.0 (#525) * Remove unnecessary exception nonsense * Add fields to challenges report * Partially address #254 - allow alt names on certificates * MInor bug fixes * Fix name settings bug * Cleanup * Fix team session start bug * Unit test for ToLookup and snippet update * Tests * Add initial tests --- .../Extensions/AssertionExtensions.cs | 1 + .../Features/Teams/StartTeamSessionTests.cs | 94 +++++++++++++++++++ .../Extensions/ExceptionExtensions.cs | 3 + .../Features/Player/Services/PlayerService.cs | 2 +- .../StartTeamSessions/StartTeamSessions.cs | 7 +- .../StartTeamSessionsValidator.cs | 50 ++++------ 6 files changed, 122 insertions(+), 35 deletions(-) create mode 100644 src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/AssertionExtensions.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/AssertionExtensions.cs index 4091a518..0715ad87 100644 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/AssertionExtensions.cs +++ b/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/AssertionExtensions.cs @@ -15,6 +15,7 @@ public static async Task ShouldYieldGameboardValidationException(this Task(responseContent)) throw new WrongExceptionType(typeof(T), responseContent); } diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs new file mode 100644 index 00000000..b9b7b16f --- /dev/null +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs @@ -0,0 +1,94 @@ +using Gameboard.Api.Common; +using Gameboard.Api.Structure; + +namespace Gameboard.Api.Tests.Integration.Teams; + +public class TeamControllerStartTeamSessionTests(GameboardTestContext testContext) : IClassFixture +{ + private readonly GameboardTestContext _testContext = testContext; + + [Theory, GbIntegrationAutoData] + public async Task TeamGame_WithSinglePlayer_CantStart + ( + string gameId, + string playerId, + string userId, + IFixture fixture + ) + { + // given a team game and a registered player with no teammates + await _testContext.WithDataState(state => + { + state.Add(new Data.Game + { + Id = gameId, + MinTeamSize = 2, + MaxTeamSize = 5, + GameStart = DateTimeOffset.UtcNow, + GameEnd = DateTimeOffset.UtcNow.AddDays(1), + Mode = GameEngineMode.Standard, + Players = state.Build(fixture, p => + { + p.Id = playerId; + p.User = state.Build(fixture, u => u.Id = userId); + }).ToCollection() + }); + }); + + // when they try to start their session + await _testContext + .CreateHttpClientWithActingUser(u => u.Id = userId) + .PutAsync($"api/player/{playerId}/start", null) + // they should get a validation error + .ShouldYieldGameboardValidationException(); + } + + [Theory, GbIntegrationAutoData] + public async Task TeamGame_WithTwoPlayers_CanStart + ( + string gameId, + string playerId, + string userId, + string teamId, + IFixture fixture + ) + { + // given a team game and a registered player with no teammates + await _testContext.WithDataState(state => + { + state.Add(new Data.Game + { + Id = gameId, + MinTeamSize = 2, + MaxTeamSize = 5, + GameStart = DateTimeOffset.UtcNow, + GameEnd = DateTimeOffset.UtcNow.AddDays(1), + Mode = GameEngineMode.Standard, + Players = + [ + state.Build(fixture, p => + { + p.Id = playerId; + p.Role = PlayerRole.Manager; + p.TeamId = teamId; + p.User = state.Build(fixture, u => u.Id = userId); + }), + state.Build(fixture, p => + { + p.Id = fixture.Create(); + p.Role = PlayerRole.Member; + p.TeamId = teamId; + }) + ] + }); + }); + + // when they try to start their session + var result = await _testContext + .CreateHttpClientWithActingUser(u => u.Id = userId) + .PutAsync($"api/player/{playerId}/start", null) + .DeserializeResponseAs(); + + // + } +} diff --git a/src/Gameboard.Api/Extensions/ExceptionExtensions.cs b/src/Gameboard.Api/Extensions/ExceptionExtensions.cs index 4c13631f..9aa938a8 100644 --- a/src/Gameboard.Api/Extensions/ExceptionExtensions.cs +++ b/src/Gameboard.Api/Extensions/ExceptionExtensions.cs @@ -6,6 +6,9 @@ namespace Gameboard.Api; public static class ExceptionExtensions { + public static string ToTypeName(string exceptionCode) + => Encoding.UTF8.GetString(Convert.FromBase64String(exceptionCode)); + public static string ToExceptionCode(this T exception) where T : GameboardValidationException => ToExceptionCode(typeof(T)); diff --git a/src/Gameboard.Api/Features/Player/Services/PlayerService.cs b/src/Gameboard.Api/Features/Player/Services/PlayerService.cs index 6e6050a0..0a64ac8f 100644 --- a/src/Gameboard.Api/Features/Player/Services/PlayerService.cs +++ b/src/Gameboard.Api/Features/Player/Services/PlayerService.cs @@ -219,7 +219,7 @@ public async Task StartSession(SessionStartRequest model, User actor, bo .WithNoTracking() .SingleOrDefaultAsync(p => p.Id == model.PlayerId); - var result = await _mediator.Send(new StartTeamSessionsCommand(new string[] { startingPlayer.TeamId })); + var result = await _mediator.Send(new StartTeamSessionsCommand([startingPlayer.TeamId])); // also set the starting player's properties because we'll use them as a return var teamStartResult = result.Teams[startingPlayer.TeamId]; diff --git a/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessions.cs b/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessions.cs index eec7dedf..39e0d3f9 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessions.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessions.cs @@ -20,7 +20,8 @@ namespace Gameboard.Api.Features.Teams; public record StartTeamSessionsCommand(IEnumerable TeamIds) : IRequest; -internal sealed class StartTeamSessionsHandler( +internal sealed class StartTeamSessionsHandler +( IActingUserService actingUserService, IExternalGameHostService externalGameHostService, IGameModeServiceFactory gameModeServiceFactory, @@ -35,7 +36,7 @@ internal sealed class StartTeamSessionsHandler( ITeamService teamService, IUserRolePermissionsService permissionsService, IGameboardRequestValidator validator - ) : IRequestHandler +) : IRequestHandler { private readonly User _actingUser = actingUserService.Get(); private readonly IExternalGameHostService _externalGameHostService = externalGameHostService; @@ -59,7 +60,7 @@ public async Task Handle(StartTeamSessionsCommand reque // throw on cancel request so we can clean up the debris cancellationToken.ThrowIfCancellationRequested(); - _logger.LogInformation($"Gathering data for game start (teams: {request.TeamIds.ToDelimited()})", "resolving game..."); + _logger.LogInformation($"Gathering data for game start (teams: {request.TeamIds.ToDelimited()})"); var teams = await _store .WithNoTracking() .Where(p => request.TeamIds.Contains(p.TeamId)) diff --git a/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessionsValidator.cs b/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessionsValidator.cs index 1fc28c63..0f2926ae 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessionsValidator.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessionsValidator.cs @@ -14,38 +14,26 @@ namespace Gameboard.Api.Features.Teams; -internal class StartTeamSessionsValidator : IGameboardRequestValidator +internal class StartTeamSessionsValidator +( + IActingUserService actingUserService, + IGameService gameService, + IGameModeServiceFactory gameModeServiceFactory, + INowService now, + IUserRolePermissionsService permissionsService, + ISessionWindowCalculator sessionWindow, + IStore store, + IValidatorService validatorService +) : IGameboardRequestValidator { - private readonly User _actingUser; - private readonly IGameModeServiceFactory _gameModeServiceFactory; - private readonly IGameService _gameService; - private readonly INowService _now; - private readonly IUserRolePermissionsService _permissionsService; - private readonly ISessionWindowCalculator _sessionWindow; - private readonly IStore _store; - private readonly IValidatorService _validatorService; - - public StartTeamSessionsValidator - ( - IActingUserService actingUserService, - IGameService gameService, - IGameModeServiceFactory gameModeServiceFactory, - INowService now, - IUserRolePermissionsService permissionsService, - ISessionWindowCalculator sessionWindow, - IStore store, - IValidatorService validatorService - ) - { - _actingUser = actingUserService.Get(); - _gameModeServiceFactory = gameModeServiceFactory; - _gameService = gameService; - _now = now; - _permissionsService = permissionsService; - _sessionWindow = sessionWindow; - _store = store; - _validatorService = validatorService; - } + private readonly User _actingUser = actingUserService.Get(); + private readonly IGameModeServiceFactory _gameModeServiceFactory = gameModeServiceFactory; + private readonly IGameService _gameService = gameService; + private readonly INowService _now = now; + private readonly IUserRolePermissionsService _permissionsService = permissionsService; + private readonly ISessionWindowCalculator _sessionWindow = sessionWindow; + private readonly IStore _store = store; + private readonly IValidatorService _validatorService = validatorService; public async Task Validate(StartTeamSessionsCommand request, CancellationToken cancellationToken) { From 66c40e57a2262ebdc03dd8cdc6affa16dbce23f4 Mon Sep 17 00:00:00 2001 From: Ben Stein <115497763+sei-bstein@users.noreply.github.com> Date: Mon, 11 Nov 2024 10:53:22 -0500 Subject: [PATCH 2/7] v3.24.1 (#526) * Remove unnecessary exception nonsense * Add fields to challenges report * Partially address #254 - allow alt names on certificates * MInor bug fixes * Fix name settings bug * Cleanup * Fix team session start bug * Unit test for ToLookup and snippet update * Tests * Add initial tests * Finish test --- .../Tests/Features/Teams/StartTeamSessionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs index b9b7b16f..130b7f39 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs @@ -89,6 +89,7 @@ await _testContext.WithDataState(state => .PutAsync($"api/player/{playerId}/start", null) .DeserializeResponseAs(); - // + // then we should get a player back with a nonempty session start + result.SessionBegin.ShouldBeGreaterThan(DateTimeOffset.MinValue); } } From a35bf3d3a3a8c1cfe372aa5948af41e86677f886 Mon Sep 17 00:00:00 2001 From: Ben Stein <115497763+sei-bstein@users.noreply.github.com> Date: Thu, 14 Nov 2024 10:01:20 -0500 Subject: [PATCH 3/7] v3.24.2 (#530) * Tests * Feedback templates wiP * WIP feedback templates --- .vscode/gameboard.code-snippets | 2 +- .vscode/launch.json.recommended | 36 +- .../Fixtures/Auth/TestClaimsTransformation.cs | 13 +- .../Features/Teams/StartTeamSessionTests.cs | 1 + .../Data/Entities/FeedbackTemplate.cs | 16 + src/Gameboard.Api/Data/Entities/Game.cs | 4 + src/Gameboard.Api/Data/Entities/User.cs | 1 + src/Gameboard.Api/Data/GameboardDbContext.cs | 11 + ...163813_CreateFeedbackTemplates.Designer.cs | 2014 ++++++++++++++++ .../20241113163813_CreateFeedbackTemplates.cs | 106 + ...40_AddFeedbackTemplateHelpText.Designer.cs | 2018 +++++++++++++++++ ...41113192640_AddFeedbackTemplateHelpText.cs | 29 + ...meboardDbContextPostgreSQLModelSnapshot.cs | 71 + ...163824_CreateFeedbackTemplates.Designer.cs | 2014 ++++++++++++++++ .../20241113163824_CreateFeedbackTemplates.cs | 106 + ...53_AddFeedbackTemplateHelpText.Designer.cs | 2018 +++++++++++++++++ ...41113192653_AddFeedbackTemplateHelpText.cs | 29 + ...ameboardDbContextSqlServerModelSnapshot.cs | 71 + .../Features/Admin/AdminController.cs | 1 + .../Admin/AdminExternalGamesController.cs | 1 + .../Certificates/CertificatesController.cs | 1 + .../ChallengeBonusController.cs | 1 + .../Features/Consoles/ConsolesController.cs | 1 + .../Features/Feedback/Feedback.cs | 301 +-- .../Features/Feedback/FeedbackController.cs | 51 +- .../Features/Feedback/FeedbackMapper.cs | 15 +- .../Features/Feedback/FeedbackService.cs | 13 +- .../Features/Feedback/FeedbackValidator.cs | 2 +- .../CreateFeedbackModels.cs | 8 + .../CreateFeedbackTemplate.cs | 61 + .../ListFeedbackTemplates.cs | 45 + .../ListFeedbackTemplatesModels.cs | 8 + .../Game/External/ExternalGamesController.cs | 1 + src/Gameboard.Api/Features/Game/Game.cs | 1 + src/Gameboard.Api/Features/Game/GameMapper.cs | 1 + .../GameEngine/GameEngineController.cs | 1 + .../Features/Practice/PracticeController.cs | 1 + .../FeedbackGameReportModels.cs | 2 +- .../GetFeedbackGameReport.cs | 1 + .../Features/Reports/ReportService.cs | 1 + .../Features/Reports/ReportsController.cs | 1 + .../Reports/ReportsExportController.cs | 1 + .../Features/Reports/ReportsV1Controller.cs | 1 + .../Features/Scores/ScoringController.cs | 1 + .../Features/Settings/SettingsController.cs | 1 + .../Features/Support/SupportController.cs | 1 + .../SystemNotificationsController.cs | 1 + .../Features/Teams/AdminTeamController.cs | 1 + .../Features/Teams/TeamController.cs | 1 + .../Features/User/Requests/TryCreateUsers.cs | 10 +- src/Gameboard.Api/Features/User/User.cs | 10 +- .../Features/User/UserController.cs | 32 +- .../Features/User/UserService.cs | 2 + .../Auth/ClaimsPrincipalExtensions.cs | 2 +- src/Gameboard.Api/Structure/Exceptions.cs | 1 + .../UserRolePermissionsValidator.cs | 6 +- 56 files changed, 8935 insertions(+), 215 deletions(-) create mode 100644 src/Gameboard.Api/Data/Entities/FeedbackTemplate.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113163813_CreateFeedbackTemplates.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113163813_CreateFeedbackTemplates.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113192640_AddFeedbackTemplateHelpText.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113192640_AddFeedbackTemplateHelpText.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113163824_CreateFeedbackTemplates.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113163824_CreateFeedbackTemplates.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113192653_AddFeedbackTemplateHelpText.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113192653_AddFeedbackTemplateHelpText.cs create mode 100644 src/Gameboard.Api/Features/Feedback/Requests/CreateFeedbackTemplate/CreateFeedbackModels.cs create mode 100644 src/Gameboard.Api/Features/Feedback/Requests/CreateFeedbackTemplate/CreateFeedbackTemplate.cs create mode 100644 src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs create mode 100644 src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplatesModels.cs diff --git a/.vscode/gameboard.code-snippets b/.vscode/gameboard.code-snippets index 498d7bac..b5aa9e70 100644 --- a/.vscode/gameboard.code-snippets +++ b/.vscode/gameboard.code-snippets @@ -4,6 +4,7 @@ "scope": "csharp", "description": "Create a Gameboard unit test suite", "prefix": "test-suite-unit", + "isFileTemplate": true, "body": [ "namespace Gameboard.Api.Tests.Unit;", "", @@ -17,7 +18,6 @@ "scope": "csharp", "description": "Start a new Gameboard unit test", "prefix": "test-unit", - "isFileTemplate": true, "body": [ "[${0:Theory}, ${1:GameboardAutoData}]", "public async Task ${TM_FILENAME/Tests\\.cs//g}_$2_$3(IFixture fixture)", diff --git a/.vscode/launch.json.recommended b/.vscode/launch.json.recommended index 87da8727..518eb247 100644 --- a/.vscode/launch.json.recommended +++ b/.vscode/launch.json.recommended @@ -2,15 +2,14 @@ "version": "0.2.0", "configurations": [ { - // Use IntelliSense to find out which attributes exist for C# debugging // Use hover for the description of the existing attributes // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md - "name": ".NET Core Launch (web)", + "name": ".NET Core Launch (dev)", "type": "coreclr", "request": "launch", "preLaunchTask": "build", // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/src/Gameboard.Api/bin/Debug/net7.0/Gameboard.Api.dll", + "program": "${workspaceFolder}/src/Gameboard.Api/bin/Debug/net8.0/Gameboard.Api.dll", "args": [], "cwd": "${workspaceFolder}/src/Gameboard.Api", "stopAtEntry": false, @@ -22,15 +21,36 @@ "env": { "ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_URLS": "http://localhost:5002" - }, - "sourceFileMap": { - "/Views": "${workspaceFolder}/Views" } }, { - "name": ".NET Core Attach", + "name": ".NET Core Launch (test)", "type": "coreclr", - "request": "attach" + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/src/Gameboard.Api/bin/Debug/net8.0/Gameboard.Api.dll", + "args": [], + "cwd": "${workspaceFolder}/src/Gameboard.Api", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Test" + } + }, + { + "name": ".NET Core Launch (db only)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/src/Gameboard.Api/bin/Debug/net8.0/Gameboard.Api.dll", + "args": ["--dbonly"], + "cwd": "${workspaceFolder}/src/Gameboard.Api", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:5002" + } } ] } diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/Auth/TestClaimsTransformation.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/Auth/TestClaimsTransformation.cs index fca2d9ca..1456eb9b 100644 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/Auth/TestClaimsTransformation.cs +++ b/src/Gameboard.Api.Tests.Integration/Fixtures/Auth/TestClaimsTransformation.cs @@ -1,13 +1,10 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; -namespace Gameboard.Api.Tests.Integration.Fixtures +namespace Gameboard.Api.Tests.Integration.Fixtures; + +internal class TestClaimsTransformation : IClaimsTransformation { - internal class TestClaimsTransformation : IClaimsTransformation - { - public Task TransformAsync(ClaimsPrincipal principal) - { - return Task.FromResult(principal); - } - } + public Task TransformAsync(ClaimsPrincipal principal) + => Task.FromResult(principal); } diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs index 130b7f39..c2367353 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs @@ -78,6 +78,7 @@ await _testContext.WithDataState(state => p.Id = fixture.Create(); p.Role = PlayerRole.Member; p.TeamId = teamId; + p.User = state.Build(fixture, u => u.Id = fixture.Create()); }) ] }); diff --git a/src/Gameboard.Api/Data/Entities/FeedbackTemplate.cs b/src/Gameboard.Api/Data/Entities/FeedbackTemplate.cs new file mode 100644 index 00000000..1edbd180 --- /dev/null +++ b/src/Gameboard.Api/Data/Entities/FeedbackTemplate.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace Gameboard.Api.Data; + +public sealed class FeedbackTemplate : IEntity +{ + public string Id { get; set; } + public string HelpText { get; set;} + public required string Name { get; set; } + public required string Content { get; set; } + + public required string CreatedByUserId { get; set; } + public Data.User CreatedByUser { get; set; } + public required ICollection UseAsFeedbackTemplateForGames { get; set; } = []; + public required ICollection UseAsFeedbackTemplateForGameChallenges { get; set; } = []; +} diff --git a/src/Gameboard.Api/Data/Entities/Game.cs b/src/Gameboard.Api/Data/Entities/Game.cs index 63817d86..97c35f01 100644 --- a/src/Gameboard.Api/Data/Entities/Game.cs +++ b/src/Gameboard.Api/Data/Entities/Game.cs @@ -54,6 +54,10 @@ public class Game : IEntity public bool RequireSynchronizedStart { get; set; } = false; public bool ShowOnHomePageInPracticeMode { get; set; } = false; + // feedback + public FeedbackTemplate GameChallengesFeedbackTemplate { get; set; } + public FeedbackTemplate GameFeedbackTemplate { get; set; } + public ICollection AdvancedPlayers { get; set; } public ICollection Specs { get; set; } = new List(); public ICollection DenormalizedTeamScores = new List(); diff --git a/src/Gameboard.Api/Data/Entities/User.cs b/src/Gameboard.Api/Data/Entities/User.cs index 1bf5d138..533cc280 100644 --- a/src/Gameboard.Api/Data/Entities/User.cs +++ b/src/Gameboard.Api/Data/Entities/User.cs @@ -25,6 +25,7 @@ public class User : IEntity public string SponsorId { get; set; } public Sponsor Sponsor { get; set; } public ICollection ApiKeys { get; set; } = []; + public ICollection CreatedFeedbackTemplates { get; set; } = []; public ICollection CreatedSystemNotifications { get; set; } = []; public ICollection Enrollments { get; set; } = []; public ICollection EnteredManualBonuses { get; set; } = []; diff --git a/src/Gameboard.Api/Data/GameboardDbContext.cs b/src/Gameboard.Api/Data/GameboardDbContext.cs index 9a3c3521..2dfd59a0 100644 --- a/src/Gameboard.Api/Data/GameboardDbContext.cs +++ b/src/Gameboard.Api/Data/GameboardDbContext.cs @@ -208,6 +208,16 @@ protected override void OnModelCreating(ModelBuilder builder) b.Property(u => u.GameId).HasMaxLength(40); }); + builder.Entity(b => + { + b.Property(b => b.Name).HasStandardNameLength().IsRequired(); + b.Property(b => b.Content).IsRequired(); + b.Property(b => b.HelpText).HasMaxLength(200); + b.HasOne(b => b.CreatedByUser).WithMany(u => u.CreatedFeedbackTemplates).IsRequired(); + b.HasMany(t => t.UseAsFeedbackTemplateForGameChallenges).WithOne(g => g.GameFeedbackTemplate); + b.HasMany(t => t.UseAsFeedbackTemplateForGames).WithOne(g => g.GameChallengesFeedbackTemplate); + }); + builder.Entity(b => { b.Property(u => u.Id).HasMaxLength(40); @@ -454,6 +464,7 @@ protected override void OnModelCreating(ModelBuilder builder) public DbSet ExternalGameHosts { get; set; } public DbSet ExternalGameTeams { get; set; } public DbSet Feedback { get; set; } + public DbSet FeedbackTemplates { get; set; } public DbSet Games { get; set; } public DbSet ManualBonuses { get; set; } public DbSet Players { get; set; } diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113163813_CreateFeedbackTemplates.Designer.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113163813_CreateFeedbackTemplates.Designer.cs new file mode 100644 index 00000000..90d60ccc --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113163813_CreateFeedbackTemplates.Designer.cs @@ -0,0 +1,2014 @@ +// +using System; +using Gameboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + [DbContext(typeof(GameboardDbContextPostgreSQL))] + [Migration("20241113163813_CreateFeedbackTemplates")] + partial class CreateFeedbackTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Gameboard.Api.Data.ApiKey", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ExpiresOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NULL"); + + b.Property("GeneratedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnerId") + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ArchivedChallenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Duration") + .HasColumnType("bigint"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Events") + .HasColumnType("text"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasGamespaceDeployed") + .HasColumnType("boolean"); + + b.Property("LastScoreTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("PlayerName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("Result") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("text"); + + b.Property("Submissions") + .HasColumnType("text"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamMembers") + .HasColumnType("text"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("ArchivedChallenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.AwardedChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeBonusId") + .HasColumnType("character varying(40)"); + + b.Property("ChallengeId") + .HasColumnType("character varying(40)"); + + b.Property("EnteredOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("InternalSummary") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeBonusId"); + + b.HasIndex("ChallengeId"); + + b.ToTable("AwardedChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameEngineType") + .HasColumnType("integer"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GraderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasDeployedGamespace") + .HasColumnType("boolean"); + + b.Property("LastScoreTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PendingSubmission") + .HasColumnType("text"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("double precision"); + + b.Property("SpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("text"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("WhenCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeBonusType") + .HasColumnType("integer"); + + b.Property("ChallengeSpecId") + .HasColumnType("character varying(40)"); + + b.Property("Description") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PointValue") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeSpecId"); + + b.ToTable("ChallengeBonuses"); + + b.HasDiscriminator("ChallengeBonusType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Text") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeEvents"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RequiredId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RequiredScore") + .HasColumnType("double precision"); + + b.Property("TargetId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeGates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AverageDeploySeconds") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameEngineType") + .HasColumnType("integer"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("IsHidden") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("ShowSolutionGuideInCompetitiveMode") + .HasColumnType("boolean"); + + b.Property("SolutionGuideUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("Text") + .HasColumnType("text"); + + b.Property("X") + .HasColumnType("real"); + + b.Property("Y") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeSpecs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSubmission", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Answers") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChallengeId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("double precision") + .HasDefaultValue(0.0); + + b.Property("SubmittedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeSubmissions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.DenormalizedTeamScore", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CumulativeTimeMs") + .HasColumnType("double precision"); + + b.Property("GameId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("ScoreAdvanced") + .HasColumnType("double precision"); + + b.Property("ScoreAutoBonus") + .HasColumnType("double precision"); + + b.Property("ScoreChallenge") + .HasColumnType("double precision"); + + b.Property("ScoreManualBonus") + .HasColumnType("double precision"); + + b.Property("ScoreOverall") + .HasColumnType("double precision"); + + b.Property("SolveCountComplete") + .HasColumnType("integer"); + + b.Property("SolveCountNone") + .HasColumnType("integer"); + + b.Property("SolveCountPartial") + .HasColumnType("integer"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("DenormalizedTeamScores"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Extension", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("HostUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasAlternateKey("Type"); + + b.ToTable("Extensions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameHost", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClientUrl") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DestroyResourcesOnDeployFailure") + .HasColumnType("boolean"); + + b.Property("GamespaceDeployBatchSize") + .HasColumnType("integer"); + + b.Property("HostApiKey") + .HasMaxLength(70) + .HasColumnType("character varying(70)"); + + b.Property("HostUrl") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HttpTimeoutInSeconds") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PingEndpoint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StartupEndpoint") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TeamExtendedEndpoint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("ExternalGameHosts"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameTeam", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("DeployStatus") + .HasColumnType("integer"); + + b.Property("ExternalGameUrl") + .HasColumnType("text"); + + b.Property("GameId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasAlternateKey("TeamId", "GameId"); + + b.HasIndex("GameId"); + + b.ToTable("ExternalGameTeams"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Answers") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Submitted") + .HasColumnType("boolean"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("FeedbackTemplates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AllowLateStart") + .HasColumnType("boolean"); + + b.Property("AllowPreview") + .HasColumnType("boolean"); + + b.Property("AllowPublicScoreboardAccess") + .HasColumnType("boolean"); + + b.Property("AllowReset") + .HasColumnType("boolean"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("text"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExternalHostId") + .HasColumnType("character varying(40)"); + + b.Property("FeedbackConfig") + .HasColumnType("text"); + + b.Property("GameChallengesFeedbackTemplateId") + .HasColumnType("text"); + + b.Property("GameEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("GameFeedbackTemplateId") + .HasColumnType("text"); + + b.Property("GameMarkdown") + .HasColumnType("text"); + + b.Property("GameStart") + .HasColumnType("timestamp with time zone"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("integer"); + + b.Property("IsFeatured") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxTeamSize") + .HasColumnType("integer"); + + b.Property("MinTeamSize") + .HasColumnType("integer"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("RegistrationClose") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationConstraint") + .HasColumnType("text"); + + b.Property("RegistrationMarkdown") + .HasColumnType("text"); + + b.Property("RegistrationOpen") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationType") + .HasColumnType("integer"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("boolean"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("boolean"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SessionLimit") + .HasColumnType("integer"); + + b.Property("SessionMinutes") + .HasColumnType("integer"); + + b.Property("ShowOnHomePageInPracticeMode") + .HasColumnType("boolean"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalHostId"); + + b.HasIndex("GameChallengesFeedbackTemplateId"); + + b.HasIndex("GameFeedbackTemplateId"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EnteredByUserId") + .HasColumnType("character varying(40)"); + + b.Property("EnteredOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("PointValue") + .HasColumnType("double precision"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualBonuses"); + + b.HasDiscriminator("Type"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Advanced") + .HasColumnType("boolean"); + + b.Property("AdvancedFromGameId") + .HasColumnType("character varying(40)"); + + b.Property("AdvancedFromPlayerId") + .HasColumnType("character varying(40)"); + + b.Property("AdvancedFromTeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AdvancedWithScore") + .HasColumnType("double precision"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CorrectCount") + .HasColumnType("integer"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("InviteCode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("IsLateStart") + .HasColumnType("boolean"); + + b.Property("IsReady") + .HasColumnType("boolean"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PartialCount") + .HasColumnType("integer"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("SessionBegin") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionMinutes") + .HasColumnType("double precision"); + + b.Property("SponsorId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("WhenCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AdvancedFromGameId"); + + b.HasIndex("AdvancedFromPlayerId"); + + b.HasIndex("GameId"); + + b.HasIndex("SponsorId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.HasIndex("Id", "TeamId"); + + b.HasIndex("UserId", "TeamId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AttemptLimit") + .HasColumnType("integer"); + + b.Property("CertificateHtmlTemplate") + .HasColumnType("text"); + + b.Property("DefaultPracticeSessionLengthMinutes") + .HasColumnType("integer"); + + b.Property("IntroTextMarkdown") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("MaxConcurrentPracticeSessions") + .HasColumnType("integer"); + + b.Property("MaxPracticeSessionLengthMinutes") + .HasColumnType("integer"); + + b.Property("SuggestedSearches") + .HasColumnType("text"); + + b.Property("UpdatedByUserId") + .HasColumnType("character varying(40)"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique(); + + b.ToTable("PracticeModeSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCertificate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("OwnerUserId") + .HasColumnType("character varying(40)"); + + b.Property("PublishedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PublishedCertificate"); + + b.HasDiscriminator("Mode"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("Logo") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentSponsorId") + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ParentSponsorId"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SupportPageGreeting") + .HasColumnType("text"); + + b.Property("UpdatedByUserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique(); + + b.ToTable("SupportSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettingsAutoTag", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConditionType") + .HasColumnType("integer"); + + b.Property("ConditionValue") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("SupportSettingsId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("SupportSettingsId"); + + b.ToTable("SupportSettingsAutoTags"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("EndsOn") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDismissible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("StartsOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SystemNotifications"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotificationInteraction", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DismissedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("SawCalloutOn") + .HasColumnType("timestamp with time zone"); + + b.Property("SawFullNotificationOn") + .HasColumnType("timestamp with time zone"); + + b.Property("SystemNotificationId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasAlternateKey("SystemNotificationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("SystemNotificationInteractions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Key") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseSerialColumn(b.Property("Key")); + + b.Property("Label") + .HasColumnType("text"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RequesterId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("StaffCreated") + .HasColumnType("boolean"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("PlayerId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TicketId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TicketId"); + + b.HasIndex("UserId"); + + b.ToTable("TicketActivity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasDefaultSponsor") + .HasColumnType("boolean"); + + b.Property("LastLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LoginCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("0"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayAudioOnBrowserNotification") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("SponsorId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("SponsorId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonusCompleteSolveRank", b => + { + b.HasBaseType("Gameboard.Api.Data.ChallengeBonus"); + + b.Property("SolveRank") + .HasColumnType("integer"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.HasBaseType("Gameboard.Api.Data.ManualBonus"); + + b.Property("ChallengeId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.HasIndex("ChallengeId"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualTeamBonus", b => + { + b.HasBaseType("Gameboard.Api.Data.ManualBonus"); + + b.Property("TeamId") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue(1); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCompetitiveCertificate", b => + { + b.HasBaseType("Gameboard.Api.Data.PublishedCertificate"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasIndex("GameId"); + + b.HasIndex("OwnerUserId"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedPracticeCertificate", b => + { + b.HasBaseType("Gameboard.Api.Data.PublishedCertificate"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("OwnerUserId"); + + b.HasDiscriminator().HasValue(1); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ApiKey", b => + { + b.HasOne("Gameboard.Api.Data.User", "Owner") + .WithMany("ApiKeys") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.AwardedChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeBonus", "ChallengeBonus") + .WithMany("AwardedTo") + .HasForeignKey("ChallengeBonusId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + + b.Navigation("ChallengeBonus"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Challenges") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Challenges") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("Bonuses") + .HasForeignKey("ChallengeSpecId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ChallengeSpec"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Events") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Prerequisites") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Specs") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSubmission", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Submissions") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.DenormalizedTeamScore", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("DenormalizedTeamScores") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameTeam", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("ExternalGameTeams") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Feedback") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("Feedback") + .HasForeignKey("ChallengeSpecId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Feedback") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Feedback") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Feedback") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("Game"); + + b.Navigation("Player"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedFeedbackTemplates") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.HasOne("Gameboard.Api.Data.ExternalGameHost", "ExternalHost") + .WithMany("UsedByGames") + .HasForeignKey("ExternalHostId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "GameChallengesFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGames") + .HasForeignKey("GameChallengesFeedbackTemplateId"); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "GameFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGameChallenges") + .HasForeignKey("GameFeedbackTemplateId"); + + b.Navigation("ExternalHost"); + + b.Navigation("GameChallengesFeedbackTemplate"); + + b.Navigation("GameFeedbackTemplate"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualBonus", b => + { + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "AdvancedFromGame") + .WithMany("AdvancedPlayers") + .HasForeignKey("AdvancedFromGameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Player", "AdvancedFromPlayer") + .WithMany("AdvancedToPlayers") + .HasForeignKey("AdvancedFromPlayerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.Sponsor", "Sponsor") + .WithMany("SponsoredPlayers") + .HasForeignKey("SponsorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("AdvancedFromGame"); + + b.Navigation("AdvancedFromPlayer"); + + b.Navigation("Game"); + + b.Navigation("Sponsor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedPracticeModeSettings") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "UpdatedByUserId"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.HasOne("Gameboard.Api.Data.Sponsor", "ParentSponsor") + .WithMany("ChildSponsors") + .HasForeignKey("ParentSponsorId"); + + b.Navigation("ParentSponsor"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedSupportSettings") + .HasForeignKey("Gameboard.Api.Data.SupportSettings", "UpdatedByUserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettingsAutoTag", b => + { + b.HasOne("Gameboard.Api.Data.SupportSettings", "SupportSettings") + .WithMany("AutoTags") + .HasForeignKey("SupportSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SupportSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedSystemNotifications") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotificationInteraction", b => + { + b.HasOne("Gameboard.Api.Data.SystemNotification", "SystemNotification") + .WithMany("Interactions") + .HasForeignKey("SystemNotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("SystemNotificationInteractions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemNotification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Tickets") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Creator") + .WithMany() + .HasForeignKey("CreatorId"); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Tickets") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Requester") + .WithMany() + .HasForeignKey("RequesterId"); + + b.Navigation("Assignee"); + + b.Navigation("Challenge"); + + b.Navigation("Creator"); + + b.Navigation("Player"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Ticket", "Ticket") + .WithMany("Activity") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Assignee"); + + b.Navigation("Ticket"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.HasOne("Gameboard.Api.Data.Sponsor", "Sponsor") + .WithMany("SponsoredUsers") + .HasForeignKey("SponsorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sponsor"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCompetitiveCertificate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("Game"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedPracticeCertificate", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("ChallengeSpecId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Navigation("AwardedBonuses"); + + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Submissions"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.Navigation("AwardedTo"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Bonuses"); + + b.Navigation("Feedback"); + + b.Navigation("PublishedPracticeCertificates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameHost", b => + { + b.Navigation("UsedByGames"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Navigation("UseAsFeedbackTemplateForGameChallenges"); + + b.Navigation("UseAsFeedbackTemplateForGames"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("AdvancedPlayers"); + + b.Navigation("Challenges"); + + b.Navigation("DenormalizedTeamScores"); + + b.Navigation("ExternalGameTeams"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("AdvancedToPlayers"); + + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Navigation("ChildSponsors"); + + b.Navigation("SponsoredPlayers"); + + b.Navigation("SponsoredUsers"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.Navigation("AutoTags"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.Navigation("Interactions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("CreatedFeedbackTemplates"); + + b.Navigation("CreatedSystemNotifications"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualBonuses"); + + b.Navigation("Feedback"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("PublishedPracticeCertificates"); + + b.Navigation("SystemNotificationInteractions"); + + b.Navigation("UpdatedPracticeModeSettings"); + + b.Navigation("UpdatedSupportSettings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113163813_CreateFeedbackTemplates.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113163813_CreateFeedbackTemplates.cs new file mode 100644 index 00000000..dc3378da --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113163813_CreateFeedbackTemplates.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + /// + public partial class CreateFeedbackTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GameChallengesFeedbackTemplateId", + table: "Games", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "GameFeedbackTemplateId", + table: "Games", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "FeedbackTemplates", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Content = table.Column(type: "text", nullable: false), + CreatedByUserId = table.Column(type: "character varying(40)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FeedbackTemplates", x => x.Id); + table.ForeignKey( + name: "FK_FeedbackTemplates_Users_CreatedByUserId", + column: x => x.CreatedByUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Games_GameChallengesFeedbackTemplateId", + table: "Games", + column: "GameChallengesFeedbackTemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_Games_GameFeedbackTemplateId", + table: "Games", + column: "GameFeedbackTemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackTemplates_CreatedByUserId", + table: "FeedbackTemplates", + column: "CreatedByUserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Games_FeedbackTemplates_GameChallengesFeedbackTemplateId", + table: "Games", + column: "GameChallengesFeedbackTemplateId", + principalTable: "FeedbackTemplates", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_Games_FeedbackTemplates_GameFeedbackTemplateId", + table: "Games", + column: "GameFeedbackTemplateId", + principalTable: "FeedbackTemplates", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Games_FeedbackTemplates_GameChallengesFeedbackTemplateId", + table: "Games"); + + migrationBuilder.DropForeignKey( + name: "FK_Games_FeedbackTemplates_GameFeedbackTemplateId", + table: "Games"); + + migrationBuilder.DropTable( + name: "FeedbackTemplates"); + + migrationBuilder.DropIndex( + name: "IX_Games_GameChallengesFeedbackTemplateId", + table: "Games"); + + migrationBuilder.DropIndex( + name: "IX_Games_GameFeedbackTemplateId", + table: "Games"); + + migrationBuilder.DropColumn( + name: "GameChallengesFeedbackTemplateId", + table: "Games"); + + migrationBuilder.DropColumn( + name: "GameFeedbackTemplateId", + table: "Games"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113192640_AddFeedbackTemplateHelpText.Designer.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113192640_AddFeedbackTemplateHelpText.Designer.cs new file mode 100644 index 00000000..f5f7a88d --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113192640_AddFeedbackTemplateHelpText.Designer.cs @@ -0,0 +1,2018 @@ +// +using System; +using Gameboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + [DbContext(typeof(GameboardDbContextPostgreSQL))] + [Migration("20241113192640_AddFeedbackTemplateHelpText")] + partial class AddFeedbackTemplateHelpText + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Gameboard.Api.Data.ApiKey", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ExpiresOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NULL"); + + b.Property("GeneratedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnerId") + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ArchivedChallenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Duration") + .HasColumnType("bigint"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Events") + .HasColumnType("text"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasGamespaceDeployed") + .HasColumnType("boolean"); + + b.Property("LastScoreTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("PlayerName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("Result") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("text"); + + b.Property("Submissions") + .HasColumnType("text"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamMembers") + .HasColumnType("text"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("ArchivedChallenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.AwardedChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeBonusId") + .HasColumnType("character varying(40)"); + + b.Property("ChallengeId") + .HasColumnType("character varying(40)"); + + b.Property("EnteredOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("InternalSummary") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeBonusId"); + + b.HasIndex("ChallengeId"); + + b.ToTable("AwardedChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameEngineType") + .HasColumnType("integer"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GraderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasDeployedGamespace") + .HasColumnType("boolean"); + + b.Property("LastScoreTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PendingSubmission") + .HasColumnType("text"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("double precision"); + + b.Property("SpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("text"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("WhenCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeBonusType") + .HasColumnType("integer"); + + b.Property("ChallengeSpecId") + .HasColumnType("character varying(40)"); + + b.Property("Description") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PointValue") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeSpecId"); + + b.ToTable("ChallengeBonuses"); + + b.HasDiscriminator("ChallengeBonusType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Text") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeEvents"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RequiredId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RequiredScore") + .HasColumnType("double precision"); + + b.Property("TargetId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeGates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AverageDeploySeconds") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameEngineType") + .HasColumnType("integer"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("IsHidden") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("ShowSolutionGuideInCompetitiveMode") + .HasColumnType("boolean"); + + b.Property("SolutionGuideUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("Text") + .HasColumnType("text"); + + b.Property("X") + .HasColumnType("real"); + + b.Property("Y") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeSpecs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSubmission", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Answers") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChallengeId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("double precision") + .HasDefaultValue(0.0); + + b.Property("SubmittedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeSubmissions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.DenormalizedTeamScore", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CumulativeTimeMs") + .HasColumnType("double precision"); + + b.Property("GameId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("ScoreAdvanced") + .HasColumnType("double precision"); + + b.Property("ScoreAutoBonus") + .HasColumnType("double precision"); + + b.Property("ScoreChallenge") + .HasColumnType("double precision"); + + b.Property("ScoreManualBonus") + .HasColumnType("double precision"); + + b.Property("ScoreOverall") + .HasColumnType("double precision"); + + b.Property("SolveCountComplete") + .HasColumnType("integer"); + + b.Property("SolveCountNone") + .HasColumnType("integer"); + + b.Property("SolveCountPartial") + .HasColumnType("integer"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("DenormalizedTeamScores"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Extension", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("HostUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasAlternateKey("Type"); + + b.ToTable("Extensions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameHost", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClientUrl") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DestroyResourcesOnDeployFailure") + .HasColumnType("boolean"); + + b.Property("GamespaceDeployBatchSize") + .HasColumnType("integer"); + + b.Property("HostApiKey") + .HasMaxLength(70) + .HasColumnType("character varying(70)"); + + b.Property("HostUrl") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HttpTimeoutInSeconds") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PingEndpoint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StartupEndpoint") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TeamExtendedEndpoint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("ExternalGameHosts"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameTeam", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("DeployStatus") + .HasColumnType("integer"); + + b.Property("ExternalGameUrl") + .HasColumnType("text"); + + b.Property("GameId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasAlternateKey("TeamId", "GameId"); + + b.HasIndex("GameId"); + + b.ToTable("ExternalGameTeams"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Answers") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Submitted") + .HasColumnType("boolean"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("HelpText") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("FeedbackTemplates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AllowLateStart") + .HasColumnType("boolean"); + + b.Property("AllowPreview") + .HasColumnType("boolean"); + + b.Property("AllowPublicScoreboardAccess") + .HasColumnType("boolean"); + + b.Property("AllowReset") + .HasColumnType("boolean"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("text"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExternalHostId") + .HasColumnType("character varying(40)"); + + b.Property("FeedbackConfig") + .HasColumnType("text"); + + b.Property("GameChallengesFeedbackTemplateId") + .HasColumnType("text"); + + b.Property("GameEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("GameFeedbackTemplateId") + .HasColumnType("text"); + + b.Property("GameMarkdown") + .HasColumnType("text"); + + b.Property("GameStart") + .HasColumnType("timestamp with time zone"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("integer"); + + b.Property("IsFeatured") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxTeamSize") + .HasColumnType("integer"); + + b.Property("MinTeamSize") + .HasColumnType("integer"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("RegistrationClose") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationConstraint") + .HasColumnType("text"); + + b.Property("RegistrationMarkdown") + .HasColumnType("text"); + + b.Property("RegistrationOpen") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationType") + .HasColumnType("integer"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("boolean"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("boolean"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SessionLimit") + .HasColumnType("integer"); + + b.Property("SessionMinutes") + .HasColumnType("integer"); + + b.Property("ShowOnHomePageInPracticeMode") + .HasColumnType("boolean"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalHostId"); + + b.HasIndex("GameChallengesFeedbackTemplateId"); + + b.HasIndex("GameFeedbackTemplateId"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EnteredByUserId") + .HasColumnType("character varying(40)"); + + b.Property("EnteredOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("PointValue") + .HasColumnType("double precision"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualBonuses"); + + b.HasDiscriminator("Type"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Advanced") + .HasColumnType("boolean"); + + b.Property("AdvancedFromGameId") + .HasColumnType("character varying(40)"); + + b.Property("AdvancedFromPlayerId") + .HasColumnType("character varying(40)"); + + b.Property("AdvancedFromTeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AdvancedWithScore") + .HasColumnType("double precision"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CorrectCount") + .HasColumnType("integer"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("InviteCode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("IsLateStart") + .HasColumnType("boolean"); + + b.Property("IsReady") + .HasColumnType("boolean"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PartialCount") + .HasColumnType("integer"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("SessionBegin") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionMinutes") + .HasColumnType("double precision"); + + b.Property("SponsorId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("WhenCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AdvancedFromGameId"); + + b.HasIndex("AdvancedFromPlayerId"); + + b.HasIndex("GameId"); + + b.HasIndex("SponsorId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.HasIndex("Id", "TeamId"); + + b.HasIndex("UserId", "TeamId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AttemptLimit") + .HasColumnType("integer"); + + b.Property("CertificateHtmlTemplate") + .HasColumnType("text"); + + b.Property("DefaultPracticeSessionLengthMinutes") + .HasColumnType("integer"); + + b.Property("IntroTextMarkdown") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("MaxConcurrentPracticeSessions") + .HasColumnType("integer"); + + b.Property("MaxPracticeSessionLengthMinutes") + .HasColumnType("integer"); + + b.Property("SuggestedSearches") + .HasColumnType("text"); + + b.Property("UpdatedByUserId") + .HasColumnType("character varying(40)"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique(); + + b.ToTable("PracticeModeSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCertificate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("OwnerUserId") + .HasColumnType("character varying(40)"); + + b.Property("PublishedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PublishedCertificate"); + + b.HasDiscriminator("Mode"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("Logo") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentSponsorId") + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ParentSponsorId"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SupportPageGreeting") + .HasColumnType("text"); + + b.Property("UpdatedByUserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique(); + + b.ToTable("SupportSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettingsAutoTag", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConditionType") + .HasColumnType("integer"); + + b.Property("ConditionValue") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("SupportSettingsId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("SupportSettingsId"); + + b.ToTable("SupportSettingsAutoTags"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("EndsOn") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDismissible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("StartsOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SystemNotifications"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotificationInteraction", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DismissedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("SawCalloutOn") + .HasColumnType("timestamp with time zone"); + + b.Property("SawFullNotificationOn") + .HasColumnType("timestamp with time zone"); + + b.Property("SystemNotificationId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasAlternateKey("SystemNotificationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("SystemNotificationInteractions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Key") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseSerialColumn(b.Property("Key")); + + b.Property("Label") + .HasColumnType("text"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RequesterId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("StaffCreated") + .HasColumnType("boolean"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("PlayerId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TicketId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TicketId"); + + b.HasIndex("UserId"); + + b.ToTable("TicketActivity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasDefaultSponsor") + .HasColumnType("boolean"); + + b.Property("LastLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LoginCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("0"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayAudioOnBrowserNotification") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("SponsorId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("SponsorId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonusCompleteSolveRank", b => + { + b.HasBaseType("Gameboard.Api.Data.ChallengeBonus"); + + b.Property("SolveRank") + .HasColumnType("integer"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.HasBaseType("Gameboard.Api.Data.ManualBonus"); + + b.Property("ChallengeId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.HasIndex("ChallengeId"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualTeamBonus", b => + { + b.HasBaseType("Gameboard.Api.Data.ManualBonus"); + + b.Property("TeamId") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue(1); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCompetitiveCertificate", b => + { + b.HasBaseType("Gameboard.Api.Data.PublishedCertificate"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasIndex("GameId"); + + b.HasIndex("OwnerUserId"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedPracticeCertificate", b => + { + b.HasBaseType("Gameboard.Api.Data.PublishedCertificate"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("OwnerUserId"); + + b.HasDiscriminator().HasValue(1); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ApiKey", b => + { + b.HasOne("Gameboard.Api.Data.User", "Owner") + .WithMany("ApiKeys") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.AwardedChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeBonus", "ChallengeBonus") + .WithMany("AwardedTo") + .HasForeignKey("ChallengeBonusId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + + b.Navigation("ChallengeBonus"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Challenges") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Challenges") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("Bonuses") + .HasForeignKey("ChallengeSpecId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ChallengeSpec"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Events") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Prerequisites") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Specs") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSubmission", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Submissions") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.DenormalizedTeamScore", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("DenormalizedTeamScores") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameTeam", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("ExternalGameTeams") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Feedback") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("Feedback") + .HasForeignKey("ChallengeSpecId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Feedback") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Feedback") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Feedback") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("Game"); + + b.Navigation("Player"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedFeedbackTemplates") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.HasOne("Gameboard.Api.Data.ExternalGameHost", "ExternalHost") + .WithMany("UsedByGames") + .HasForeignKey("ExternalHostId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "GameChallengesFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGames") + .HasForeignKey("GameChallengesFeedbackTemplateId"); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "GameFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGameChallenges") + .HasForeignKey("GameFeedbackTemplateId"); + + b.Navigation("ExternalHost"); + + b.Navigation("GameChallengesFeedbackTemplate"); + + b.Navigation("GameFeedbackTemplate"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualBonus", b => + { + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "AdvancedFromGame") + .WithMany("AdvancedPlayers") + .HasForeignKey("AdvancedFromGameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Player", "AdvancedFromPlayer") + .WithMany("AdvancedToPlayers") + .HasForeignKey("AdvancedFromPlayerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.Sponsor", "Sponsor") + .WithMany("SponsoredPlayers") + .HasForeignKey("SponsorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("AdvancedFromGame"); + + b.Navigation("AdvancedFromPlayer"); + + b.Navigation("Game"); + + b.Navigation("Sponsor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedPracticeModeSettings") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "UpdatedByUserId"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.HasOne("Gameboard.Api.Data.Sponsor", "ParentSponsor") + .WithMany("ChildSponsors") + .HasForeignKey("ParentSponsorId"); + + b.Navigation("ParentSponsor"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedSupportSettings") + .HasForeignKey("Gameboard.Api.Data.SupportSettings", "UpdatedByUserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettingsAutoTag", b => + { + b.HasOne("Gameboard.Api.Data.SupportSettings", "SupportSettings") + .WithMany("AutoTags") + .HasForeignKey("SupportSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SupportSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedSystemNotifications") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotificationInteraction", b => + { + b.HasOne("Gameboard.Api.Data.SystemNotification", "SystemNotification") + .WithMany("Interactions") + .HasForeignKey("SystemNotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("SystemNotificationInteractions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemNotification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Tickets") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Creator") + .WithMany() + .HasForeignKey("CreatorId"); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Tickets") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Requester") + .WithMany() + .HasForeignKey("RequesterId"); + + b.Navigation("Assignee"); + + b.Navigation("Challenge"); + + b.Navigation("Creator"); + + b.Navigation("Player"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Ticket", "Ticket") + .WithMany("Activity") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Assignee"); + + b.Navigation("Ticket"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.HasOne("Gameboard.Api.Data.Sponsor", "Sponsor") + .WithMany("SponsoredUsers") + .HasForeignKey("SponsorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sponsor"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCompetitiveCertificate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("Game"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedPracticeCertificate", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("ChallengeSpecId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Navigation("AwardedBonuses"); + + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Submissions"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.Navigation("AwardedTo"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Bonuses"); + + b.Navigation("Feedback"); + + b.Navigation("PublishedPracticeCertificates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameHost", b => + { + b.Navigation("UsedByGames"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Navigation("UseAsFeedbackTemplateForGameChallenges"); + + b.Navigation("UseAsFeedbackTemplateForGames"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("AdvancedPlayers"); + + b.Navigation("Challenges"); + + b.Navigation("DenormalizedTeamScores"); + + b.Navigation("ExternalGameTeams"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("AdvancedToPlayers"); + + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Navigation("ChildSponsors"); + + b.Navigation("SponsoredPlayers"); + + b.Navigation("SponsoredUsers"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.Navigation("AutoTags"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.Navigation("Interactions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("CreatedFeedbackTemplates"); + + b.Navigation("CreatedSystemNotifications"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualBonuses"); + + b.Navigation("Feedback"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("PublishedPracticeCertificates"); + + b.Navigation("SystemNotificationInteractions"); + + b.Navigation("UpdatedPracticeModeSettings"); + + b.Navigation("UpdatedSupportSettings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113192640_AddFeedbackTemplateHelpText.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113192640_AddFeedbackTemplateHelpText.cs new file mode 100644 index 00000000..10937ccf --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20241113192640_AddFeedbackTemplateHelpText.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + /// + public partial class AddFeedbackTemplateHelpText : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HelpText", + table: "FeedbackTemplates", + type: "character varying(200)", + maxLength: 200, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HelpText", + table: "FeedbackTemplates"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs index 5a93183b..338c875c 100644 --- a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs @@ -668,6 +668,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Feedback"); }); + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("HelpText") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("FeedbackTemplates"); + }); + modelBuilder.Entity("Gameboard.Api.Data.Game", b => { b.Property("Id") @@ -719,9 +748,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FeedbackConfig") .HasColumnType("text"); + b.Property("GameChallengesFeedbackTemplateId") + .HasColumnType("text"); + b.Property("GameEnd") .HasColumnType("timestamp with time zone"); + b.Property("GameFeedbackTemplateId") + .HasColumnType("text"); + b.Property("GameMarkdown") .HasColumnType("text"); @@ -811,6 +846,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ExternalHostId"); + b.HasIndex("GameChallengesFeedbackTemplateId"); + + b.HasIndex("GameFeedbackTemplateId"); + b.ToTable("Games"); }); @@ -1587,6 +1626,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedFeedbackTemplates") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + modelBuilder.Entity("Gameboard.Api.Data.Game", b => { b.HasOne("Gameboard.Api.Data.ExternalGameHost", "ExternalHost") @@ -1594,7 +1644,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("ExternalHostId") .OnDelete(DeleteBehavior.SetNull); + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "GameChallengesFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGames") + .HasForeignKey("GameChallengesFeedbackTemplateId"); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "GameFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGameChallenges") + .HasForeignKey("GameFeedbackTemplateId"); + b.Navigation("ExternalHost"); + + b.Navigation("GameChallengesFeedbackTemplate"); + + b.Navigation("GameFeedbackTemplate"); }); modelBuilder.Entity("Gameboard.Api.Data.ManualBonus", b => @@ -1860,6 +1922,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("UsedByGames"); }); + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Navigation("UseAsFeedbackTemplateForGameChallenges"); + + b.Navigation("UseAsFeedbackTemplateForGames"); + }); + modelBuilder.Entity("Gameboard.Api.Data.Game", b => { b.Navigation("AdvancedPlayers"); @@ -1920,6 +1989,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("ApiKeys"); + b.Navigation("CreatedFeedbackTemplates"); + b.Navigation("CreatedSystemNotifications"); b.Navigation("Enrollments"); diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113163824_CreateFeedbackTemplates.Designer.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113163824_CreateFeedbackTemplates.Designer.cs new file mode 100644 index 00000000..698c649f --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113163824_CreateFeedbackTemplates.Designer.cs @@ -0,0 +1,2014 @@ +// +using System; +using Gameboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + [DbContext(typeof(GameboardDbContextSqlServer))] + [Migration("20241113163824_CreateFeedbackTemplates")] + partial class CreateFeedbackTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Gameboard.Api.Data.ApiKey", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ExpiresOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasDefaultValueSql("NULL"); + + b.Property("GeneratedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasDefaultValueSql("NOW()"); + + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("OwnerId") + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ArchivedChallenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Duration") + .HasColumnType("bigint"); + + b.Property("EndTime") + .HasColumnType("datetimeoffset"); + + b.Property("Events") + .HasColumnType("nvarchar(max)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HasGamespaceDeployed") + .HasColumnType("bit"); + + b.Property("LastScoreTime") + .HasColumnType("datetimeoffset"); + + b.Property("LastSyncTime") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("PlayerName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("Result") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("StartTime") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Submissions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamMembers") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("ArchivedChallenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.AwardedChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeBonusId") + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .HasColumnType("nvarchar(40)"); + + b.Property("EnteredOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasDefaultValueSql("NOW()"); + + b.Property("InternalSummary") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeBonusId"); + + b.HasIndex("ChallengeId"); + + b.ToTable("AwardedChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("EndTime") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameEngineType") + .HasColumnType("int"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GraderKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HasDeployedGamespace") + .HasColumnType("bit"); + + b.Property("LastScoreTime") + .HasColumnType("datetimeoffset"); + + b.Property("LastSyncTime") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PendingSubmission") + .HasColumnType("nvarchar(max)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("float"); + + b.Property("SpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("StartTime") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeBonusType") + .HasColumnType("int"); + + b.Property("ChallengeSpecId") + .HasColumnType("nvarchar(40)"); + + b.Property("Description") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PointValue") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeSpecId"); + + b.ToTable("ChallengeBonuses"); + + b.HasDiscriminator("ChallengeBonusType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Text") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeEvents"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RequiredId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RequiredScore") + .HasColumnType("float"); + + b.Property("TargetId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeGates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AverageDeploySeconds") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Disabled") + .HasColumnType("bit"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameEngineType") + .HasColumnType("int"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsHidden") + .HasColumnType("bit"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("ShowSolutionGuideInCompetitiveMode") + .HasColumnType("bit"); + + b.Property("SolutionGuideUrl") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Text") + .HasColumnType("nvarchar(max)"); + + b.Property("X") + .HasColumnType("real"); + + b.Property("Y") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeSpecs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSubmission", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Answers") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("float") + .HasDefaultValue(0.0); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeSubmissions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.DenormalizedTeamScore", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CumulativeTimeMs") + .HasColumnType("float"); + + b.Property("GameId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Rank") + .HasColumnType("int"); + + b.Property("ScoreAdvanced") + .HasColumnType("float"); + + b.Property("ScoreAutoBonus") + .HasColumnType("float"); + + b.Property("ScoreChallenge") + .HasColumnType("float"); + + b.Property("ScoreManualBonus") + .HasColumnType("float"); + + b.Property("ScoreOverall") + .HasColumnType("float"); + + b.Property("SolveCountComplete") + .HasColumnType("int"); + + b.Property("SolveCountNone") + .HasColumnType("int"); + + b.Property("SolveCountPartial") + .HasColumnType("int"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamName") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("DenormalizedTeamScores"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Extension", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("HostUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasAlternateKey("Type"); + + b.ToTable("Extensions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameHost", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ClientUrl") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DestroyResourcesOnDeployFailure") + .HasColumnType("bit"); + + b.Property("GamespaceDeployBatchSize") + .HasColumnType("int"); + + b.Property("HostApiKey") + .HasMaxLength(70) + .HasColumnType("nvarchar(70)"); + + b.Property("HostUrl") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HttpTimeoutInSeconds") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PingEndpoint") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StartupEndpoint") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TeamExtendedEndpoint") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("ExternalGameHosts"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameTeam", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("DeployStatus") + .HasColumnType("int"); + + b.Property("ExternalGameUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("GameId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasAlternateKey("TeamId", "GameId"); + + b.HasIndex("GameId"); + + b.ToTable("ExternalGameTeams"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Answers") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Submitted") + .HasColumnType("bit"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("FeedbackTemplates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AllowLateStart") + .HasColumnType("bit"); + + b.Property("AllowPreview") + .HasColumnType("bit"); + + b.Property("AllowPublicScoreboardAccess") + .HasColumnType("bit"); + + b.Property("AllowReset") + .HasColumnType("bit"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalHostId") + .HasColumnType("nvarchar(40)"); + + b.Property("FeedbackConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("GameChallengesFeedbackTemplateId") + .HasColumnType("nvarchar(450)"); + + b.Property("GameEnd") + .HasColumnType("datetimeoffset"); + + b.Property("GameFeedbackTemplateId") + .HasColumnType("nvarchar(450)"); + + b.Property("GameMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("GameStart") + .HasColumnType("datetimeoffset"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("int"); + + b.Property("IsFeatured") + .HasColumnType("bit"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("MaxAttempts") + .HasColumnType("int"); + + b.Property("MaxTeamSize") + .HasColumnType("int"); + + b.Property("MinTeamSize") + .HasColumnType("int"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("RegistrationClose") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationConstraint") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationOpen") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationType") + .HasColumnType("int"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("bit"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("bit"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SessionLimit") + .HasColumnType("int"); + + b.Property("SessionMinutes") + .HasColumnType("int"); + + b.Property("ShowOnHomePageInPracticeMode") + .HasColumnType("bit"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalHostId"); + + b.HasIndex("GameChallengesFeedbackTemplateId"); + + b.HasIndex("GameFeedbackTemplateId"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EnteredByUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("EnteredOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasDefaultValueSql("NOW()"); + + b.Property("PointValue") + .HasColumnType("float"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualBonuses"); + + b.HasDiscriminator("Type"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Advanced") + .HasColumnType("bit"); + + b.Property("AdvancedFromGameId") + .HasColumnType("nvarchar(40)"); + + b.Property("AdvancedFromPlayerId") + .HasColumnType("nvarchar(40)"); + + b.Property("AdvancedFromTeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AdvancedWithScore") + .HasColumnType("float"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrectCount") + .HasColumnType("int"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("InviteCode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsLateStart") + .HasColumnType("bit"); + + b.Property("IsReady") + .HasColumnType("bit"); + + b.Property("Mode") + .HasColumnType("int"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PartialCount") + .HasColumnType("int"); + + b.Property("Rank") + .HasColumnType("int"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionBegin") + .HasColumnType("datetimeoffset"); + + b.Property("SessionEnd") + .HasColumnType("datetimeoffset"); + + b.Property("SessionMinutes") + .HasColumnType("float"); + + b.Property("SponsorId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AdvancedFromGameId"); + + b.HasIndex("AdvancedFromPlayerId"); + + b.HasIndex("GameId"); + + b.HasIndex("SponsorId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.HasIndex("Id", "TeamId"); + + b.HasIndex("UserId", "TeamId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AttemptLimit") + .HasColumnType("int"); + + b.Property("CertificateHtmlTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultPracticeSessionLengthMinutes") + .HasColumnType("int"); + + b.Property("IntroTextMarkdown") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("MaxConcurrentPracticeSessions") + .HasColumnType("int"); + + b.Property("MaxPracticeSessionLengthMinutes") + .HasColumnType("int"); + + b.Property("SuggestedSearches") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedByUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique() + .HasFilter("[UpdatedByUserId] IS NOT NULL"); + + b.ToTable("PracticeModeSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCertificate", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Mode") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("PublishedCertificate"); + + b.HasDiscriminator("Mode"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Approved") + .HasColumnType("bit"); + + b.Property("Logo") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentSponsorId") + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ParentSponsorId"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("SupportPageGreeting") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedByUserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique(); + + b.ToTable("SupportSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettingsAutoTag", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConditionType") + .HasColumnType("int"); + + b.Property("ConditionValue") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("SupportSettingsId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SupportSettingsId"); + + b.ToTable("SupportSettingsAutoTags"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("EndsOn") + .HasColumnType("datetimeoffset"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsDismissible") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NotificationType") + .HasColumnType("int"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SystemNotifications"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotificationInteraction", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("DismissedOn") + .HasColumnType("datetimeoffset"); + + b.Property("SawCalloutOn") + .HasColumnType("datetimeoffset"); + + b.Property("SawFullNotificationOn") + .HasColumnType("datetimeoffset"); + + b.Property("SystemNotificationId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasAlternateKey("SystemNotificationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("SystemNotificationInteractions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("CreatorId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Key") + .HasColumnType("int") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn); + + b.Property("Label") + .HasColumnType("nvarchar(max)"); + + b.Property("LastUpdated") + .HasColumnType("datetimeoffset"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RequesterId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("StaffCreated") + .HasColumnType("bit"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("PlayerId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Message") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TicketId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TicketId"); + + b.HasIndex("UserId"); + + b.ToTable("TicketActivity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HasDefaultSponsor") + .HasColumnType("bit"); + + b.Property("LastLoginDate") + .HasColumnType("datetimeoffset"); + + b.Property("LoginCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("0"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayAudioOnBrowserNotification") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("SponsorId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SponsorId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonusCompleteSolveRank", b => + { + b.HasBaseType("Gameboard.Api.Data.ChallengeBonus"); + + b.Property("SolveRank") + .HasColumnType("int"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.HasBaseType("Gameboard.Api.Data.ManualBonus"); + + b.Property("ChallengeId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.HasIndex("ChallengeId"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualTeamBonus", b => + { + b.HasBaseType("Gameboard.Api.Data.ManualBonus"); + + b.Property("TeamId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasDiscriminator().HasValue(1); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCompetitiveCertificate", b => + { + b.HasBaseType("Gameboard.Api.Data.PublishedCertificate"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasIndex("GameId"); + + b.HasIndex("OwnerUserId"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedPracticeCertificate", b => + { + b.HasBaseType("Gameboard.Api.Data.PublishedCertificate"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("OwnerUserId"); + + b.HasDiscriminator().HasValue(1); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ApiKey", b => + { + b.HasOne("Gameboard.Api.Data.User", "Owner") + .WithMany("ApiKeys") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.AwardedChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeBonus", "ChallengeBonus") + .WithMany("AwardedTo") + .HasForeignKey("ChallengeBonusId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + + b.Navigation("ChallengeBonus"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Challenges") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Challenges") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("Bonuses") + .HasForeignKey("ChallengeSpecId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ChallengeSpec"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Events") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Prerequisites") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Specs") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSubmission", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Submissions") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.DenormalizedTeamScore", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("DenormalizedTeamScores") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameTeam", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("ExternalGameTeams") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Feedback") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("Feedback") + .HasForeignKey("ChallengeSpecId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Feedback") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Feedback") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Feedback") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("Game"); + + b.Navigation("Player"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedFeedbackTemplates") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.HasOne("Gameboard.Api.Data.ExternalGameHost", "ExternalHost") + .WithMany("UsedByGames") + .HasForeignKey("ExternalHostId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "GameChallengesFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGames") + .HasForeignKey("GameChallengesFeedbackTemplateId"); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "GameFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGameChallenges") + .HasForeignKey("GameFeedbackTemplateId"); + + b.Navigation("ExternalHost"); + + b.Navigation("GameChallengesFeedbackTemplate"); + + b.Navigation("GameFeedbackTemplate"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualBonus", b => + { + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "AdvancedFromGame") + .WithMany("AdvancedPlayers") + .HasForeignKey("AdvancedFromGameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Player", "AdvancedFromPlayer") + .WithMany("AdvancedToPlayers") + .HasForeignKey("AdvancedFromPlayerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.Sponsor", "Sponsor") + .WithMany("SponsoredPlayers") + .HasForeignKey("SponsorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("AdvancedFromGame"); + + b.Navigation("AdvancedFromPlayer"); + + b.Navigation("Game"); + + b.Navigation("Sponsor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedPracticeModeSettings") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "UpdatedByUserId"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.HasOne("Gameboard.Api.Data.Sponsor", "ParentSponsor") + .WithMany("ChildSponsors") + .HasForeignKey("ParentSponsorId"); + + b.Navigation("ParentSponsor"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedSupportSettings") + .HasForeignKey("Gameboard.Api.Data.SupportSettings", "UpdatedByUserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettingsAutoTag", b => + { + b.HasOne("Gameboard.Api.Data.SupportSettings", "SupportSettings") + .WithMany("AutoTags") + .HasForeignKey("SupportSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SupportSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedSystemNotifications") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotificationInteraction", b => + { + b.HasOne("Gameboard.Api.Data.SystemNotification", "SystemNotification") + .WithMany("Interactions") + .HasForeignKey("SystemNotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("SystemNotificationInteractions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemNotification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Tickets") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Creator") + .WithMany() + .HasForeignKey("CreatorId"); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Tickets") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Requester") + .WithMany() + .HasForeignKey("RequesterId"); + + b.Navigation("Assignee"); + + b.Navigation("Challenge"); + + b.Navigation("Creator"); + + b.Navigation("Player"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Ticket", "Ticket") + .WithMany("Activity") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Assignee"); + + b.Navigation("Ticket"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.HasOne("Gameboard.Api.Data.Sponsor", "Sponsor") + .WithMany("SponsoredUsers") + .HasForeignKey("SponsorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sponsor"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCompetitiveCertificate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("Game"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedPracticeCertificate", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("ChallengeSpecId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Navigation("AwardedBonuses"); + + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Submissions"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.Navigation("AwardedTo"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Bonuses"); + + b.Navigation("Feedback"); + + b.Navigation("PublishedPracticeCertificates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameHost", b => + { + b.Navigation("UsedByGames"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Navigation("UseAsFeedbackTemplateForGameChallenges"); + + b.Navigation("UseAsFeedbackTemplateForGames"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("AdvancedPlayers"); + + b.Navigation("Challenges"); + + b.Navigation("DenormalizedTeamScores"); + + b.Navigation("ExternalGameTeams"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("AdvancedToPlayers"); + + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Navigation("ChildSponsors"); + + b.Navigation("SponsoredPlayers"); + + b.Navigation("SponsoredUsers"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.Navigation("AutoTags"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.Navigation("Interactions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("CreatedFeedbackTemplates"); + + b.Navigation("CreatedSystemNotifications"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualBonuses"); + + b.Navigation("Feedback"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("PublishedPracticeCertificates"); + + b.Navigation("SystemNotificationInteractions"); + + b.Navigation("UpdatedPracticeModeSettings"); + + b.Navigation("UpdatedSupportSettings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113163824_CreateFeedbackTemplates.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113163824_CreateFeedbackTemplates.cs new file mode 100644 index 00000000..c868b9d4 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113163824_CreateFeedbackTemplates.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + /// + public partial class CreateFeedbackTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GameChallengesFeedbackTemplateId", + table: "Games", + type: "nvarchar(450)", + nullable: true); + + migrationBuilder.AddColumn( + name: "GameFeedbackTemplateId", + table: "Games", + type: "nvarchar(450)", + nullable: true); + + migrationBuilder.CreateTable( + name: "FeedbackTemplates", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Content = table.Column(type: "nvarchar(max)", nullable: false), + CreatedByUserId = table.Column(type: "nvarchar(40)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FeedbackTemplates", x => x.Id); + table.ForeignKey( + name: "FK_FeedbackTemplates_Users_CreatedByUserId", + column: x => x.CreatedByUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Games_GameChallengesFeedbackTemplateId", + table: "Games", + column: "GameChallengesFeedbackTemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_Games_GameFeedbackTemplateId", + table: "Games", + column: "GameFeedbackTemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackTemplates_CreatedByUserId", + table: "FeedbackTemplates", + column: "CreatedByUserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Games_FeedbackTemplates_GameChallengesFeedbackTemplateId", + table: "Games", + column: "GameChallengesFeedbackTemplateId", + principalTable: "FeedbackTemplates", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_Games_FeedbackTemplates_GameFeedbackTemplateId", + table: "Games", + column: "GameFeedbackTemplateId", + principalTable: "FeedbackTemplates", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Games_FeedbackTemplates_GameChallengesFeedbackTemplateId", + table: "Games"); + + migrationBuilder.DropForeignKey( + name: "FK_Games_FeedbackTemplates_GameFeedbackTemplateId", + table: "Games"); + + migrationBuilder.DropTable( + name: "FeedbackTemplates"); + + migrationBuilder.DropIndex( + name: "IX_Games_GameChallengesFeedbackTemplateId", + table: "Games"); + + migrationBuilder.DropIndex( + name: "IX_Games_GameFeedbackTemplateId", + table: "Games"); + + migrationBuilder.DropColumn( + name: "GameChallengesFeedbackTemplateId", + table: "Games"); + + migrationBuilder.DropColumn( + name: "GameFeedbackTemplateId", + table: "Games"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113192653_AddFeedbackTemplateHelpText.Designer.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113192653_AddFeedbackTemplateHelpText.Designer.cs new file mode 100644 index 00000000..efee3394 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113192653_AddFeedbackTemplateHelpText.Designer.cs @@ -0,0 +1,2018 @@ +// +using System; +using Gameboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + [DbContext(typeof(GameboardDbContextSqlServer))] + [Migration("20241113192653_AddFeedbackTemplateHelpText")] + partial class AddFeedbackTemplateHelpText + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Gameboard.Api.Data.ApiKey", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ExpiresOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasDefaultValueSql("NULL"); + + b.Property("GeneratedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasDefaultValueSql("NOW()"); + + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("OwnerId") + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ArchivedChallenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Duration") + .HasColumnType("bigint"); + + b.Property("EndTime") + .HasColumnType("datetimeoffset"); + + b.Property("Events") + .HasColumnType("nvarchar(max)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HasGamespaceDeployed") + .HasColumnType("bit"); + + b.Property("LastScoreTime") + .HasColumnType("datetimeoffset"); + + b.Property("LastSyncTime") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("PlayerName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("Result") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("StartTime") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Submissions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamMembers") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("ArchivedChallenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.AwardedChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeBonusId") + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .HasColumnType("nvarchar(40)"); + + b.Property("EnteredOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasDefaultValueSql("NOW()"); + + b.Property("InternalSummary") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeBonusId"); + + b.HasIndex("ChallengeId"); + + b.ToTable("AwardedChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("EndTime") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameEngineType") + .HasColumnType("int"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GraderKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HasDeployedGamespace") + .HasColumnType("bit"); + + b.Property("LastScoreTime") + .HasColumnType("datetimeoffset"); + + b.Property("LastSyncTime") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PendingSubmission") + .HasColumnType("nvarchar(max)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("float"); + + b.Property("SpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("StartTime") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeBonusType") + .HasColumnType("int"); + + b.Property("ChallengeSpecId") + .HasColumnType("nvarchar(40)"); + + b.Property("Description") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PointValue") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeSpecId"); + + b.ToTable("ChallengeBonuses"); + + b.HasDiscriminator("ChallengeBonusType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Text") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeEvents"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RequiredId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RequiredScore") + .HasColumnType("float"); + + b.Property("TargetId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeGates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AverageDeploySeconds") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Disabled") + .HasColumnType("bit"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameEngineType") + .HasColumnType("int"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsHidden") + .HasColumnType("bit"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("ShowSolutionGuideInCompetitiveMode") + .HasColumnType("bit"); + + b.Property("SolutionGuideUrl") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Text") + .HasColumnType("nvarchar(max)"); + + b.Property("X") + .HasColumnType("real"); + + b.Property("Y") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeSpecs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSubmission", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Answers") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("float") + .HasDefaultValue(0.0); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeSubmissions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.DenormalizedTeamScore", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CumulativeTimeMs") + .HasColumnType("float"); + + b.Property("GameId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Rank") + .HasColumnType("int"); + + b.Property("ScoreAdvanced") + .HasColumnType("float"); + + b.Property("ScoreAutoBonus") + .HasColumnType("float"); + + b.Property("ScoreChallenge") + .HasColumnType("float"); + + b.Property("ScoreManualBonus") + .HasColumnType("float"); + + b.Property("ScoreOverall") + .HasColumnType("float"); + + b.Property("SolveCountComplete") + .HasColumnType("int"); + + b.Property("SolveCountNone") + .HasColumnType("int"); + + b.Property("SolveCountPartial") + .HasColumnType("int"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamName") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("DenormalizedTeamScores"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Extension", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("HostUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasAlternateKey("Type"); + + b.ToTable("Extensions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameHost", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ClientUrl") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DestroyResourcesOnDeployFailure") + .HasColumnType("bit"); + + b.Property("GamespaceDeployBatchSize") + .HasColumnType("int"); + + b.Property("HostApiKey") + .HasMaxLength(70) + .HasColumnType("nvarchar(70)"); + + b.Property("HostUrl") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HttpTimeoutInSeconds") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PingEndpoint") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StartupEndpoint") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TeamExtendedEndpoint") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("ExternalGameHosts"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameTeam", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("DeployStatus") + .HasColumnType("int"); + + b.Property("ExternalGameUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("GameId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasAlternateKey("TeamId", "GameId"); + + b.HasIndex("GameId"); + + b.ToTable("ExternalGameTeams"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Answers") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Submitted") + .HasColumnType("bit"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("HelpText") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("FeedbackTemplates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AllowLateStart") + .HasColumnType("bit"); + + b.Property("AllowPreview") + .HasColumnType("bit"); + + b.Property("AllowPublicScoreboardAccess") + .HasColumnType("bit"); + + b.Property("AllowReset") + .HasColumnType("bit"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalHostId") + .HasColumnType("nvarchar(40)"); + + b.Property("FeedbackConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("GameChallengesFeedbackTemplateId") + .HasColumnType("nvarchar(450)"); + + b.Property("GameEnd") + .HasColumnType("datetimeoffset"); + + b.Property("GameFeedbackTemplateId") + .HasColumnType("nvarchar(450)"); + + b.Property("GameMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("GameStart") + .HasColumnType("datetimeoffset"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("int"); + + b.Property("IsFeatured") + .HasColumnType("bit"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("MaxAttempts") + .HasColumnType("int"); + + b.Property("MaxTeamSize") + .HasColumnType("int"); + + b.Property("MinTeamSize") + .HasColumnType("int"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("RegistrationClose") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationConstraint") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationOpen") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationType") + .HasColumnType("int"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("bit"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("bit"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SessionLimit") + .HasColumnType("int"); + + b.Property("SessionMinutes") + .HasColumnType("int"); + + b.Property("ShowOnHomePageInPracticeMode") + .HasColumnType("bit"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalHostId"); + + b.HasIndex("GameChallengesFeedbackTemplateId"); + + b.HasIndex("GameFeedbackTemplateId"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EnteredByUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("EnteredOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasDefaultValueSql("NOW()"); + + b.Property("PointValue") + .HasColumnType("float"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualBonuses"); + + b.HasDiscriminator("Type"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Advanced") + .HasColumnType("bit"); + + b.Property("AdvancedFromGameId") + .HasColumnType("nvarchar(40)"); + + b.Property("AdvancedFromPlayerId") + .HasColumnType("nvarchar(40)"); + + b.Property("AdvancedFromTeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AdvancedWithScore") + .HasColumnType("float"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrectCount") + .HasColumnType("int"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("InviteCode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsLateStart") + .HasColumnType("bit"); + + b.Property("IsReady") + .HasColumnType("bit"); + + b.Property("Mode") + .HasColumnType("int"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PartialCount") + .HasColumnType("int"); + + b.Property("Rank") + .HasColumnType("int"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionBegin") + .HasColumnType("datetimeoffset"); + + b.Property("SessionEnd") + .HasColumnType("datetimeoffset"); + + b.Property("SessionMinutes") + .HasColumnType("float"); + + b.Property("SponsorId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AdvancedFromGameId"); + + b.HasIndex("AdvancedFromPlayerId"); + + b.HasIndex("GameId"); + + b.HasIndex("SponsorId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.HasIndex("Id", "TeamId"); + + b.HasIndex("UserId", "TeamId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AttemptLimit") + .HasColumnType("int"); + + b.Property("CertificateHtmlTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultPracticeSessionLengthMinutes") + .HasColumnType("int"); + + b.Property("IntroTextMarkdown") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("MaxConcurrentPracticeSessions") + .HasColumnType("int"); + + b.Property("MaxPracticeSessionLengthMinutes") + .HasColumnType("int"); + + b.Property("SuggestedSearches") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedByUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique() + .HasFilter("[UpdatedByUserId] IS NOT NULL"); + + b.ToTable("PracticeModeSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCertificate", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Mode") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("PublishedCertificate"); + + b.HasDiscriminator("Mode"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Approved") + .HasColumnType("bit"); + + b.Property("Logo") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentSponsorId") + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ParentSponsorId"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("SupportPageGreeting") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedByUserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique(); + + b.ToTable("SupportSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettingsAutoTag", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConditionType") + .HasColumnType("int"); + + b.Property("ConditionValue") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("SupportSettingsId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SupportSettingsId"); + + b.ToTable("SupportSettingsAutoTags"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("EndsOn") + .HasColumnType("datetimeoffset"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsDismissible") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NotificationType") + .HasColumnType("int"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SystemNotifications"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotificationInteraction", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("DismissedOn") + .HasColumnType("datetimeoffset"); + + b.Property("SawCalloutOn") + .HasColumnType("datetimeoffset"); + + b.Property("SawFullNotificationOn") + .HasColumnType("datetimeoffset"); + + b.Property("SystemNotificationId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasAlternateKey("SystemNotificationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("SystemNotificationInteractions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("CreatorId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Key") + .HasColumnType("int") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn); + + b.Property("Label") + .HasColumnType("nvarchar(max)"); + + b.Property("LastUpdated") + .HasColumnType("datetimeoffset"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RequesterId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("StaffCreated") + .HasColumnType("bit"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("PlayerId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Message") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TicketId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TicketId"); + + b.HasIndex("UserId"); + + b.ToTable("TicketActivity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HasDefaultSponsor") + .HasColumnType("bit"); + + b.Property("LastLoginDate") + .HasColumnType("datetimeoffset"); + + b.Property("LoginCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("0"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayAudioOnBrowserNotification") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("SponsorId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SponsorId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonusCompleteSolveRank", b => + { + b.HasBaseType("Gameboard.Api.Data.ChallengeBonus"); + + b.Property("SolveRank") + .HasColumnType("int"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.HasBaseType("Gameboard.Api.Data.ManualBonus"); + + b.Property("ChallengeId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.HasIndex("ChallengeId"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualTeamBonus", b => + { + b.HasBaseType("Gameboard.Api.Data.ManualBonus"); + + b.Property("TeamId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasDiscriminator().HasValue(1); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCompetitiveCertificate", b => + { + b.HasBaseType("Gameboard.Api.Data.PublishedCertificate"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasIndex("GameId"); + + b.HasIndex("OwnerUserId"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedPracticeCertificate", b => + { + b.HasBaseType("Gameboard.Api.Data.PublishedCertificate"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("OwnerUserId"); + + b.HasDiscriminator().HasValue(1); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ApiKey", b => + { + b.HasOne("Gameboard.Api.Data.User", "Owner") + .WithMany("ApiKeys") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.AwardedChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeBonus", "ChallengeBonus") + .WithMany("AwardedTo") + .HasForeignKey("ChallengeBonusId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + + b.Navigation("ChallengeBonus"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Challenges") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Challenges") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("Bonuses") + .HasForeignKey("ChallengeSpecId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ChallengeSpec"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Events") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Prerequisites") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Specs") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSubmission", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Submissions") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.DenormalizedTeamScore", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("DenormalizedTeamScores") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameTeam", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("ExternalGameTeams") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Feedback") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("Feedback") + .HasForeignKey("ChallengeSpecId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Feedback") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Feedback") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Feedback") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("Game"); + + b.Navigation("Player"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedFeedbackTemplates") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.HasOne("Gameboard.Api.Data.ExternalGameHost", "ExternalHost") + .WithMany("UsedByGames") + .HasForeignKey("ExternalHostId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "GameChallengesFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGames") + .HasForeignKey("GameChallengesFeedbackTemplateId"); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "GameFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGameChallenges") + .HasForeignKey("GameFeedbackTemplateId"); + + b.Navigation("ExternalHost"); + + b.Navigation("GameChallengesFeedbackTemplate"); + + b.Navigation("GameFeedbackTemplate"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualBonus", b => + { + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "AdvancedFromGame") + .WithMany("AdvancedPlayers") + .HasForeignKey("AdvancedFromGameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Player", "AdvancedFromPlayer") + .WithMany("AdvancedToPlayers") + .HasForeignKey("AdvancedFromPlayerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.Sponsor", "Sponsor") + .WithMany("SponsoredPlayers") + .HasForeignKey("SponsorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("AdvancedFromGame"); + + b.Navigation("AdvancedFromPlayer"); + + b.Navigation("Game"); + + b.Navigation("Sponsor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedPracticeModeSettings") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "UpdatedByUserId"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.HasOne("Gameboard.Api.Data.Sponsor", "ParentSponsor") + .WithMany("ChildSponsors") + .HasForeignKey("ParentSponsorId"); + + b.Navigation("ParentSponsor"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedSupportSettings") + .HasForeignKey("Gameboard.Api.Data.SupportSettings", "UpdatedByUserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettingsAutoTag", b => + { + b.HasOne("Gameboard.Api.Data.SupportSettings", "SupportSettings") + .WithMany("AutoTags") + .HasForeignKey("SupportSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SupportSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedSystemNotifications") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotificationInteraction", b => + { + b.HasOne("Gameboard.Api.Data.SystemNotification", "SystemNotification") + .WithMany("Interactions") + .HasForeignKey("SystemNotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("SystemNotificationInteractions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemNotification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Tickets") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Creator") + .WithMany() + .HasForeignKey("CreatorId"); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Tickets") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Requester") + .WithMany() + .HasForeignKey("RequesterId"); + + b.Navigation("Assignee"); + + b.Navigation("Challenge"); + + b.Navigation("Creator"); + + b.Navigation("Player"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Ticket", "Ticket") + .WithMany("Activity") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Assignee"); + + b.Navigation("Ticket"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.HasOne("Gameboard.Api.Data.Sponsor", "Sponsor") + .WithMany("SponsoredUsers") + .HasForeignKey("SponsorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sponsor"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCompetitiveCertificate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("Game"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedPracticeCertificate", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("ChallengeSpecId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Navigation("AwardedBonuses"); + + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Submissions"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.Navigation("AwardedTo"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Bonuses"); + + b.Navigation("Feedback"); + + b.Navigation("PublishedPracticeCertificates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameHost", b => + { + b.Navigation("UsedByGames"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Navigation("UseAsFeedbackTemplateForGameChallenges"); + + b.Navigation("UseAsFeedbackTemplateForGames"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("AdvancedPlayers"); + + b.Navigation("Challenges"); + + b.Navigation("DenormalizedTeamScores"); + + b.Navigation("ExternalGameTeams"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("AdvancedToPlayers"); + + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Navigation("ChildSponsors"); + + b.Navigation("SponsoredPlayers"); + + b.Navigation("SponsoredUsers"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.Navigation("AutoTags"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.Navigation("Interactions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("CreatedFeedbackTemplates"); + + b.Navigation("CreatedSystemNotifications"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualBonuses"); + + b.Navigation("Feedback"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("PublishedPracticeCertificates"); + + b.Navigation("SystemNotificationInteractions"); + + b.Navigation("UpdatedPracticeModeSettings"); + + b.Navigation("UpdatedSupportSettings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113192653_AddFeedbackTemplateHelpText.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113192653_AddFeedbackTemplateHelpText.cs new file mode 100644 index 00000000..a1c4c3f0 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20241113192653_AddFeedbackTemplateHelpText.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + /// + public partial class AddFeedbackTemplateHelpText : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HelpText", + table: "FeedbackTemplates", + type: "nvarchar(200)", + maxLength: 200, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HelpText", + table: "FeedbackTemplates"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs index a0bfc53e..0dc814e2 100644 --- a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs @@ -669,6 +669,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Feedback"); }); + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("HelpText") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("FeedbackTemplates"); + }); + modelBuilder.Entity("Gameboard.Api.Data.Game", b => { b.Property("Id") @@ -720,9 +749,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FeedbackConfig") .HasColumnType("nvarchar(max)"); + b.Property("GameChallengesFeedbackTemplateId") + .HasColumnType("nvarchar(450)"); + b.Property("GameEnd") .HasColumnType("datetimeoffset"); + b.Property("GameFeedbackTemplateId") + .HasColumnType("nvarchar(450)"); + b.Property("GameMarkdown") .HasColumnType("nvarchar(max)"); @@ -812,6 +847,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ExternalHostId"); + b.HasIndex("GameChallengesFeedbackTemplateId"); + + b.HasIndex("GameFeedbackTemplateId"); + b.ToTable("Games"); }); @@ -1587,6 +1626,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedFeedbackTemplates") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + modelBuilder.Entity("Gameboard.Api.Data.Game", b => { b.HasOne("Gameboard.Api.Data.ExternalGameHost", "ExternalHost") @@ -1594,7 +1644,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("ExternalHostId") .OnDelete(DeleteBehavior.SetNull); + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "GameChallengesFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGames") + .HasForeignKey("GameChallengesFeedbackTemplateId"); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "GameFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGameChallenges") + .HasForeignKey("GameFeedbackTemplateId"); + b.Navigation("ExternalHost"); + + b.Navigation("GameChallengesFeedbackTemplate"); + + b.Navigation("GameFeedbackTemplate"); }); modelBuilder.Entity("Gameboard.Api.Data.ManualBonus", b => @@ -1860,6 +1922,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("UsedByGames"); }); + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Navigation("UseAsFeedbackTemplateForGameChallenges"); + + b.Navigation("UseAsFeedbackTemplateForGames"); + }); + modelBuilder.Entity("Gameboard.Api.Data.Game", b => { b.Navigation("AdvancedPlayers"); @@ -1920,6 +1989,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("ApiKeys"); + b.Navigation("CreatedFeedbackTemplates"); + b.Navigation("CreatedSystemNotifications"); b.Navigation("Enrollments"); diff --git a/src/Gameboard.Api/Features/Admin/AdminController.cs b/src/Gameboard.Api/Features/Admin/AdminController.cs index 7570e63f..d6f5e04c 100644 --- a/src/Gameboard.Api/Features/Admin/AdminController.cs +++ b/src/Gameboard.Api/Features/Admin/AdminController.cs @@ -5,6 +5,7 @@ namespace Gameboard.Api.Features.Admin; +[ApiController] [Route("api/admin")] public class AdminController(IMediator mediator, IActingUserService actingUserService) : ControllerBase { diff --git a/src/Gameboard.Api/Features/Admin/AdminExternalGamesController.cs b/src/Gameboard.Api/Features/Admin/AdminExternalGamesController.cs index 3dc71bf3..6703e77f 100644 --- a/src/Gameboard.Api/Features/Admin/AdminExternalGamesController.cs +++ b/src/Gameboard.Api/Features/Admin/AdminExternalGamesController.cs @@ -7,6 +7,7 @@ namespace Gameboard.Api.Features.Admin; +[ApiController] [Route("api/admin/games/external")] [Authorize] public class AdminExternalGamesController(IMediator mediator) : ControllerBase diff --git a/src/Gameboard.Api/Features/Certificates/CertificatesController.cs b/src/Gameboard.Api/Features/Certificates/CertificatesController.cs index 4bdbaa73..70c42b9e 100644 --- a/src/Gameboard.Api/Features/Certificates/CertificatesController.cs +++ b/src/Gameboard.Api/Features/Certificates/CertificatesController.cs @@ -11,6 +11,7 @@ namespace Gameboard.Api.Features.Certificates; [Authorize] +[ApiController] [Route("/api/user")] public class CertificatesController( IActingUserService actingUser, diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusController.cs b/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusController.cs index 0679a451..03bdb3a1 100644 --- a/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusController.cs +++ b/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusController.cs @@ -7,6 +7,7 @@ namespace Gameboard.Api.Features.ChallengeBonuses; +[ApiController] [Route("api")] [Authorize] public class ChallengeBonusController(IMediator mediator) : ControllerBase() diff --git a/src/Gameboard.Api/Features/Consoles/ConsolesController.cs b/src/Gameboard.Api/Features/Consoles/ConsolesController.cs index 7ab4c21f..da4184fd 100644 --- a/src/Gameboard.Api/Features/Consoles/ConsolesController.cs +++ b/src/Gameboard.Api/Features/Consoles/ConsolesController.cs @@ -7,6 +7,7 @@ namespace Gameboard.Api.Features.Consoles; +[ApiController] [Authorize(AppConstants.ConsolePolicy)] [Route("/api/consoles")] public class ConsolesController( diff --git a/src/Gameboard.Api/Features/Feedback/Feedback.cs b/src/Gameboard.Api/Features/Feedback/Feedback.cs index 0b9255d2..4f14b56c 100644 --- a/src/Gameboard.Api/Features/Feedback/Feedback.cs +++ b/src/Gameboard.Api/Features/Feedback/Feedback.cs @@ -3,149 +3,166 @@ using System; using System.Collections.Generic; +using Gameboard.Api.Structure; -namespace Gameboard.Api +namespace Gameboard.Api.Features.Feedback; + +public class FeedbackSubmission +{ + // UserId and PlayerId are set automatically when saved + public string ChallengeId { get; set; } + public string ChallengeSpecId { get; set; } + public string GameId { get; set; } + public bool Submit { get; set; } + public QuestionSubmission[] Questions { get; set; } +} + +public class FeedbackQuestion +{ + public string Id { get; set; } + public string Prompt { get; set; } + public string ShortName { get; set; } +} + +public class QuestionSubmission : FeedbackQuestion +{ + public string Answer { get; set; } +} + +public class QuestionTemplate : FeedbackQuestion +{ + public string Type { get; set; } = "text"; // if unspecified in config + public bool Required { get; set; } = false; // if unspecified in config + + // For 'likert' type questions only + public int Min { get; set; } = 1; + public int Max { get; set; } + public string MinLabel { get; set; } + public string MaxLabel { get; set; } + + // For 'selectOne' and 'selectMany' type questions only + public string[] Options { get; set; } + // Display type + public string Display { get; set; } + // Specification for a certain answer + public QuestionSpecify Specify { get; set; } +} + +public class GameFeedbackTemplate { - public class FeedbackSubmission - { - // UserId and PlayerId are set automatically when saved - public string ChallengeId { get; set; } - public string ChallengeSpecId { get; set; } - public string GameId { get; set; } - public bool Submit { get; set; } - public QuestionSubmission[] Questions { get; set; } - } - - public class FeedbackQuestion - { - public string Id { get; set; } - public string Prompt { get; set; } - public string ShortName { get; set; } - } - - public class QuestionSubmission : FeedbackQuestion - { - public string Answer { get; set; } - } - - public class QuestionTemplate : FeedbackQuestion - { - public string Type { get; set; } = "text"; // if unspecified in config - public bool Required { get; set; } = false; // if unspecified in config - - // For 'likert' type questions only - public int Min { get; set; } = 1; - public int Max { get; set; } - public string MinLabel { get; set; } - public string MaxLabel { get; set; } - - // For 'selectOne' and 'selectMany' type questions only - public string[] Options { get; set; } - // Display type - public string Display { get; set; } - // Specification for a certain answer - public QuestionSpecify Specify { get; set; } - } - - public class GameFeedbackTemplate - { - public QuestionTemplate[] Game { get; set; } = Array.Empty(); - public QuestionTemplate[] Challenge { get; set; } = Array.Empty(); - public string Message { get; set; } - } - - - public class Feedback - { - public string Id { get; set; } - public string UserId { get; set; } - public string PlayerId { get; set; } - public string ChallengeId { get; set; } - public string ChallengeSpecId { get; set; } - public string GameId { get; set; } - public QuestionSubmission[] Questions { get; set; } - public bool Submitted { get; set; } - public DateTimeOffset Timestamp { get; set; } - } - - public class FeedbackReportDetails : Feedback - { - public string ApprovedName { get; set; } - public string ChallengeTag { get; set; } - } - - public class FeedbackSearchParams : SearchFilter - { - public const string GameType = "game"; - public const string ChallengeType = "challenge"; - public const string SortOldest = "oldest"; - public const string SortNewest = "newest"; - public const string Submitted = "submitted"; - public string GameId { get; set; } - public string ChallengeSpecId { get; set; } - public string ChallengeId { get; set; } - public string Type { get; set; } - public string SubmitStatus { get; set; } - public bool WantsGame => Type == GameType; - public bool WantsChallenge => Type == ChallengeType; - public bool WantsSpecificChallenge => Type == ChallengeType && ChallengeSpecId != null; - public bool WantsSortByTimeNewest => Sort == SortNewest; - public bool WantsSortByTimeOldest => Sort == SortOldest; - public bool WantsSubmittedOnly => SubmitStatus == Submitted; - } - - // Order of properties below determines order of columns in CSV export - public class FeedbackReportExport - { - public string UserId { get; set; } - public string PlayerId { get; set; } - public string ApprovedName { get; set; } - public string ChallengeId { get; set; } - public string ChallengeTag { get; set; } - public bool Submitted { get; set; } - public DateTimeOffset Timestamp { get; set; } - } - - public class FeedbackReportHelper : FeedbackReportExport - { - public Dictionary IdToAnswer { get; set; } = new Dictionary(); - } - - public class FeedbackStats - { - public string ChallengeSpecId { get; set; } - public int ConfiguredCount { get; set; } - public int LikertCount { get; set; } - public int TextCount { get; set; } - public int SelectOneCount { get; set; } - public int SelectManyCount { get; set; } - public int RequiredCount { get; set; } - public int ResponsesCount { get; set; } - public int MaxResponseCount { get; set; } - public int InProgressCount { get; set; } - public int SubmittedCount { get; set; } - public IEnumerable QuestionStats { get; set; } - } - - // Order of properties below determines order of columns in CSV export - public class QuestionStats - { - public string Id { get; set; } - public string Prompt { get; set; } - public string ShortName { get; set; } - public bool Required { get; set; } - public double Average { get; set; } // mean of all ratings for this question - public int ScaleMin { get; set; } // lower bound of likert scale (default 1) - public int ScaleMax { get; set; } // upper bound of likert scale - public int Count { get; set; } // how many responses for this question - public int Lowest { get; set; } // lowest rating given - public int Highest { get; set; } // highest rating given - } - - public class QuestionSpecify - { - public string Key { get; set; } - public string Prompt { get; set; } - } + public QuestionTemplate[] Game { get; set; } = []; + public QuestionTemplate[] Challenge { get; set; } = []; + public string Message { get; set; } +} + +public class Feedback +{ + public string Id { get; set; } + public string UserId { get; set; } + public string PlayerId { get; set; } + public string ChallengeId { get; set; } + public string ChallengeSpecId { get; set; } + public string GameId { get; set; } + public QuestionSubmission[] Questions { get; set; } + public bool Submitted { get; set; } + public DateTimeOffset Timestamp { get; set; } +} + +public class FeedbackReportDetails : Feedback +{ + public string ApprovedName { get; set; } + public string ChallengeTag { get; set; } +} + +public class FeedbackSearchParams : SearchFilter +{ + public const string GameType = "game"; + public const string ChallengeType = "challenge"; + public const string SortOldest = "oldest"; + public const string SortNewest = "newest"; + public const string Submitted = "submitted"; + public string GameId { get; set; } + public string ChallengeSpecId { get; set; } + public string ChallengeId { get; set; } + public string Type { get; set; } + public string SubmitStatus { get; set; } + public bool WantsGame => Type == GameType; + public bool WantsChallenge => Type == ChallengeType; + public bool WantsSpecificChallenge => Type == ChallengeType && ChallengeSpecId != null; + public bool WantsSortByTimeNewest => Sort == SortNewest; + public bool WantsSortByTimeOldest => Sort == SortOldest; + public bool WantsSubmittedOnly => SubmitStatus == Submitted; +} + +// Order of properties below determines order of columns in CSV export +public class FeedbackReportExport +{ + public string UserId { get; set; } + public string PlayerId { get; set; } + public string ApprovedName { get; set; } + public string ChallengeId { get; set; } + public string ChallengeTag { get; set; } + public bool Submitted { get; set; } + public DateTimeOffset Timestamp { get; set; } +} + +public class FeedbackReportHelper : FeedbackReportExport +{ + public Dictionary IdToAnswer { get; set; } = new Dictionary(); +} + +public class FeedbackStats +{ + public string ChallengeSpecId { get; set; } + public int ConfiguredCount { get; set; } + public int LikertCount { get; set; } + public int TextCount { get; set; } + public int SelectOneCount { get; set; } + public int SelectManyCount { get; set; } + public int RequiredCount { get; set; } + public int ResponsesCount { get; set; } + public int MaxResponseCount { get; set; } + public int InProgressCount { get; set; } + public int SubmittedCount { get; set; } + public IEnumerable QuestionStats { get; set; } +} + +// Order of properties below determines order of columns in CSV export +public class QuestionStats +{ + public string Id { get; set; } + public string Prompt { get; set; } + public string ShortName { get; set; } + public bool Required { get; set; } + public double Average { get; set; } // mean of all ratings for this question + public int ScaleMin { get; set; } // lower bound of likert scale (default 1) + public int ScaleMax { get; set; } // upper bound of likert scale + public int Count { get; set; } // how many responses for this question + public int Lowest { get; set; } // lowest rating given + public int Highest { get; set; } // highest rating given +} + +public class QuestionSpecify +{ + public string Key { get; set; } + public string Prompt { get; set; } +} + +public sealed class DuplicateFeedbackTemplateNameException : GameboardValidationException +{ + public DuplicateFeedbackTemplateNameException(string name) + : base($"""A feedback template with the name "{name}" already exists. """) { } +} + +public sealed class FeedbackTemplateView +{ + public required string Id { get; set; } + public required string Content { get; set; } + public required SimpleEntity CreatedBy { get; set; } + public required string HelpText { get; set;} + public required string Name { get; set; } + public required int ResponseCount { get; set; } + public required IEnumerable UseForGames { get; set; } + public required IEnumerable UseForGameChallenges { get; set; } } diff --git a/src/Gameboard.Api/Features/Feedback/FeedbackController.cs b/src/Gameboard.Api/Features/Feedback/FeedbackController.cs index 6a4afe74..cb624d60 100644 --- a/src/Gameboard.Api/Features/Feedback/FeedbackController.cs +++ b/src/Gameboard.Api/Features/Feedback/FeedbackController.cs @@ -1,6 +1,7 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. +using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -10,19 +11,26 @@ using Gameboard.Api.Validators; using Gameboard.Api.Common.Services; using Gameboard.Api.Features.Users; +using Gameboard.Api.Features.Feedback; +using MediatR; namespace Gameboard.Api.Controllers; +[ApiController] +[Route("api/feedback")] [Authorize] -public class FeedbackController( - IActingUserService actingUserService, - ILogger logger, - IDistributedCache cache, - FeedbackValidator validator, - FeedbackService feedbackService, - IUserRolePermissionsService permissionsService - ) : GameboardLegacyController(actingUserService, logger, cache, validator) +public class FeedbackController +( + IActingUserService actingUserService, + ILogger logger, + IDistributedCache cache, + IMediator mediator, + FeedbackValidator validator, + FeedbackService feedbackService, + IUserRolePermissionsService permissionsService + ) : GameboardLegacyController(actingUserService, logger, cache, validator) { + private readonly IMediator _mediator = mediator; private readonly IUserRolePermissionsService _permissionsService = permissionsService; FeedbackService FeedbackService { get; } = feedbackService; @@ -31,8 +39,7 @@ IUserRolePermissionsService permissionsService /// /// /// - [HttpGet("/api/feedback/list")] - [Authorize] + [HttpGet("list")] public async Task List([FromQuery] FeedbackSearchParams model) { await Authorize(_permissionsService.Can(PermissionKey.Reports_View)); @@ -44,8 +51,6 @@ public async Task List([FromQuery] FeedbackSearchParams /// /// /// - [HttpGet("api/feedback")] - [Authorize] public async Task Retrieve([FromQuery] FeedbackSearchParams model) { await Authorize(FeedbackService.UserIsEnrolled(model.GameId, Actor.Id)); @@ -57,8 +62,7 @@ public async Task Retrieve([FromQuery] FeedbackSearchParams model) /// /// /// - [HttpPut("/api/feedback/submit")] - [Authorize] + [HttpPut("submit")] public async Task Submit([FromBody] FeedbackSubmission model) { await Authorize(FeedbackService.UserIsEnrolled(model.GameId, Actor.Id)); @@ -68,4 +72,23 @@ public async Task Submit([FromBody] FeedbackSubmission model) return result; } + + /// + /// Creates a new feedback template. Feedback templates can be used to gather feedback on a game, + /// a game's challenges, or both. + /// + /// + /// The newly-created template + [HttpPost("template")] + public Task CreateTemplate([FromBody] CreateFeedbackTemplateRequest request) + => _mediator.Send(new CreateFeedbackTemplateCommand(request)); + + /// + /// List all available feedback templates, including which games they're used with and how many responses + /// they have. + /// + /// All feedback templates in the system + [HttpGet("template")] + public Task ListTemplates() + => _mediator.Send(new ListFeedbackTemplatesQuery()); } diff --git a/src/Gameboard.Api/Features/Feedback/FeedbackMapper.cs b/src/Gameboard.Api/Features/Feedback/FeedbackMapper.cs index 8d72b741..b3b0db2f 100644 --- a/src/Gameboard.Api/Features/Feedback/FeedbackMapper.cs +++ b/src/Gameboard.Api/Features/Feedback/FeedbackMapper.cs @@ -1,10 +1,10 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -using System; using System.Text.Json; using System.Text.Json.Serialization; using AutoMapper; +using Gameboard.Api.Features.Feedback; namespace Gameboard.Api.Services @@ -15,20 +15,20 @@ public FeedbackMapper() { CreateMap().ConvertUsing(str => str == null ? null : str.Trim()); - CreateMap() + CreateMap() .ForMember(d => d.Questions, opt => opt.MapFrom(s => JsonSerializer.Deserialize(s.Answers, JsonOptions)) ); - CreateMap(); + CreateMap(); - CreateMap() + CreateMap() .ForMember(d => d.Submitted, opt => opt.MapFrom(s => s.Submit)) .ForMember(d => d.Answers, opt => opt.MapFrom(s => JsonSerializer.Serialize(s.Questions, JsonOptions)) ); - CreateMap() + CreateMap() .ForMember(d => d.Questions, opt => opt.MapFrom(s => JsonSerializer.Deserialize(s.Answers, JsonOptions)) ) @@ -36,8 +36,9 @@ public FeedbackMapper() .ForMember(d => d.ChallengeTag, opt => opt.MapFrom(s => s.ChallengeSpec.Tag)); // Use a dictionary to map each question id in a response to the answer - CreateMap() - .AfterMap((src, dest) => { + CreateMap() + .AfterMap((src, dest) => + { foreach (QuestionSubmission q in src.Questions) { dest.IdToAnswer.Add(q.Id, q.Answer); } }); diff --git a/src/Gameboard.Api/Features/Feedback/FeedbackService.cs b/src/Gameboard.Api/Features/Feedback/FeedbackService.cs index 530208e6..862fdd1c 100644 --- a/src/Gameboard.Api/Features/Feedback/FeedbackService.cs +++ b/src/Gameboard.Api/Features/Feedback/FeedbackService.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using AutoMapper; using Gameboard.Api.Data; +using Gameboard.Api.Features.Feedback; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -91,7 +92,7 @@ public IEnumerable GetFeedbackQuestionStats(QuestionTemplate[] qu return questionStats; } - public async Task Retrieve(FeedbackSearchParams model, string actorId) + public async Task Retrieve(FeedbackSearchParams model, string actorId) { // for normal challenge and game feedback, we can just do simple lookups on the provided IDs. // unfortunately, for practice mode, we need a special case. @@ -121,18 +122,18 @@ public async Task Retrieve(FeedbackSearchParams model, string actorId) ) .SingleOrDefaultAsync(); - return Mapper.Map(feedback); + return Mapper.Map(feedback); } } // if we get here, we're just doing standard lookups with no special logic var lookup = MakeFeedbackLookup(model.GameId, model.ChallengeId, model.ChallengeSpecId, actorId); var entity = await LoadFeedback(lookup); - return Mapper.Map(entity); + return Mapper.Map(entity); } - public async Task Submit(FeedbackSubmission model, string actorId) + public async Task Submit(FeedbackSubmission model, string actorId) { var lookup = MakeFeedbackLookup(model.GameId, model.ChallengeId, model.ChallengeSpecId, actorId); var entity = await LoadFeedback(lookup); @@ -148,7 +149,7 @@ public async Task Submit(FeedbackSubmission model, string actorId) { if (entity.Submitted) { - return Mapper.Map(entity); + return Mapper.Map(entity); } Mapper.Map(model, entity); entity.Timestamp = DateTimeOffset.UtcNow; // always last saved/submitted @@ -169,7 +170,7 @@ public async Task Submit(FeedbackSubmission model, string actorId) await _store.Create(entity); } - return Mapper.Map(entity); + return Mapper.Map(entity); } // List feedback responses based on params such as game/challenge filtering, skip/take, and sorting diff --git a/src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs b/src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs index e422d7a8..65b841d3 100644 --- a/src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs +++ b/src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs @@ -1,10 +1,10 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -using System; using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Data; +using Gameboard.Api.Features.Feedback; namespace Gameboard.Api.Validators { diff --git a/src/Gameboard.Api/Features/Feedback/Requests/CreateFeedbackTemplate/CreateFeedbackModels.cs b/src/Gameboard.Api/Features/Feedback/Requests/CreateFeedbackTemplate/CreateFeedbackModels.cs new file mode 100644 index 00000000..7e740893 --- /dev/null +++ b/src/Gameboard.Api/Features/Feedback/Requests/CreateFeedbackTemplate/CreateFeedbackModels.cs @@ -0,0 +1,8 @@ +namespace Gameboard.Api.Features.Feedback; + +public sealed class CreateFeedbackTemplateRequest +{ + public required string Content { get; set; } + public string HelpText { get; set;} + public required string Name { get; set; } +} diff --git a/src/Gameboard.Api/Features/Feedback/Requests/CreateFeedbackTemplate/CreateFeedbackTemplate.cs b/src/Gameboard.Api/Features/Feedback/Requests/CreateFeedbackTemplate/CreateFeedbackTemplate.cs new file mode 100644 index 00000000..c79c431c --- /dev/null +++ b/src/Gameboard.Api/Features/Feedback/Requests/CreateFeedbackTemplate/CreateFeedbackTemplate.cs @@ -0,0 +1,61 @@ +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common.Services; +using Gameboard.Api.Data; +using Gameboard.Api.Structure.MediatR; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Feedback; + +public record CreateFeedbackTemplateCommand(CreateFeedbackTemplateRequest Template) : IRequest; + +internal sealed class CreateFeedbackTemplateHandler +( + IActingUserService actingUserService, + IStore store, + IValidatorService validatorService +) : IRequestHandler +{ + private readonly IActingUserService _actingUserService = actingUserService; + private readonly IStore _store = store; + private readonly IValidatorService _validatorService = validatorService; + + public async Task Handle(CreateFeedbackTemplateCommand request, CancellationToken cancellationToken) + { + await _validatorService + .Auth(c => c.RequirePermissions(Users.PermissionKey.Games_CreateEditDelete)) + .AddValidator(request.Template.Name.IsEmpty(), new MissingRequiredInput(nameof(request.Template.Name))) + .AddValidator(request.Template.Content.IsEmpty(), new MissingRequiredInput(nameof(request.Template.Content))) + .AddValidator(async ctx => + { + if (await _store.WithNoTracking().AnyAsync(t => t.Name == request.Template.Name)) + { + ctx.AddValidationException(new DuplicateFeedbackTemplateNameException(request.Template.Name)); + } + }) + .Validate(cancellationToken); + + var template = await _store + .Create(new FeedbackTemplate + { + Content = request.Template.Content.Trim(), + CreatedByUserId = _actingUserService.Get().Id, + Name = request.Template.Name.Trim(), + UseAsFeedbackTemplateForGameChallenges = [], + UseAsFeedbackTemplateForGames = [] + }); + + return new FeedbackTemplateView + { + Id = template.Id, + Content = template.Content, + CreatedBy = new SimpleEntity { Id = template.CreatedByUserId, Name = _actingUserService.Get().ApprovedName }, + HelpText = template.HelpText, + Name = template.Name, + ResponseCount = 0, + UseForGameChallenges = [], + UseForGames = [] + }; + } +} diff --git a/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs b/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs new file mode 100644 index 00000000..134d23fa --- /dev/null +++ b/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs @@ -0,0 +1,45 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Data; +using Gameboard.Api.Structure.MediatR; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Feedback; + +public record ListFeedbackTemplatesQuery() : IRequest; + +internal sealed class ListFeedbackTemplatesHandler(IStore store, IValidatorService validatorService) : IRequestHandler +{ + private readonly IStore _store = store; + private readonly IValidatorService _validatorService = validatorService; + + public async Task Handle(ListFeedbackTemplatesQuery request, CancellationToken cancellationToken) + { + await _validatorService + .Auth(c => c.RequirePermissions(Users.PermissionKey.Games_CreateEditDelete)) + .Validate(cancellationToken); + + var templates = await _store + .WithNoTracking() + .Select(t => new FeedbackTemplateView + { + Id = t.Id, + Content = t.Content, + CreatedBy = new SimpleEntity { Id = t.CreatedByUserId, Name = t.CreatedByUser.ApprovedName }, + HelpText = t.HelpText, + Name = t.Name, + ResponseCount = 0, + UseForGameChallenges = t.UseAsFeedbackTemplateForGameChallenges + .Select(s => new SimpleEntity { Id = s.Id, Name = s.Name }) + .ToArray(), + UseForGames = t.UseAsFeedbackTemplateForGames + .Select(g => new SimpleEntity { Id = g.Id, Name = g.Name }) + .ToArray() + }) + .ToArrayAsync(cancellationToken); + + return new ListFeedbackTemplatesResponse { Templates = templates }; + } +} diff --git a/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplatesModels.cs b/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplatesModels.cs new file mode 100644 index 00000000..0626c873 --- /dev/null +++ b/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplatesModels.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace Gameboard.Api.Features.Feedback; + +public sealed class ListFeedbackTemplatesResponse +{ + public required IEnumerable Templates { get; set; } +} diff --git a/src/Gameboard.Api/Features/Game/External/ExternalGamesController.cs b/src/Gameboard.Api/Features/Game/External/ExternalGamesController.cs index 611ac36a..d8ed93ea 100644 --- a/src/Gameboard.Api/Features/Game/External/ExternalGamesController.cs +++ b/src/Gameboard.Api/Features/Game/External/ExternalGamesController.cs @@ -7,6 +7,7 @@ namespace Gameboard.Api.Features.Games.External; +[ApiController] [Authorize] [Route("api/games/external")] public class ExternalGamesController : ControllerBase diff --git a/src/Gameboard.Api/Features/Game/Game.cs b/src/Gameboard.Api/Features/Game/Game.cs index 23124271..d43b83a6 100644 --- a/src/Gameboard.Api/Features/Game/Game.cs +++ b/src/Gameboard.Api/Features/Game/Game.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Gameboard.Api.Features.Feedback; namespace Gameboard.Api; diff --git a/src/Gameboard.Api/Features/Game/GameMapper.cs b/src/Gameboard.Api/Features/Game/GameMapper.cs index ac6e32bc..ac65631b 100644 --- a/src/Gameboard.Api/Features/Game/GameMapper.cs +++ b/src/Gameboard.Api/Features/Game/GameMapper.cs @@ -2,6 +2,7 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using AutoMapper; +using Gameboard.Api.Features.Feedback; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; diff --git a/src/Gameboard.Api/Features/GameEngine/GameEngineController.cs b/src/Gameboard.Api/Features/GameEngine/GameEngineController.cs index c08e5dff..0f204d38 100644 --- a/src/Gameboard.Api/Features/GameEngine/GameEngineController.cs +++ b/src/Gameboard.Api/Features/GameEngine/GameEngineController.cs @@ -6,6 +6,7 @@ namespace Gameboard.Api.Features.GameEngine; +[ApiController] [Authorize] [Route("/api/gameEngine")] public class GameEngineController(IMediator mediator) : ControllerBase diff --git a/src/Gameboard.Api/Features/Practice/PracticeController.cs b/src/Gameboard.Api/Features/Practice/PracticeController.cs index a8e9d8fa..37bf04ed 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeController.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeController.cs @@ -6,6 +6,7 @@ namespace Gameboard.Api.Features.Practice; +[ApiController] [Authorize] [Route("/api/practice")] public class PracticeController( diff --git a/src/Gameboard.Api/Features/Reports/Queries/FeedbackGameReport/FeedbackGameReportModels.cs b/src/Gameboard.Api/Features/Reports/Queries/FeedbackGameReport/FeedbackGameReportModels.cs index 1fa0752f..5bb79b91 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/FeedbackGameReport/FeedbackGameReportModels.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/FeedbackGameReport/FeedbackGameReportModels.cs @@ -1,5 +1,5 @@ -using System.Collections; using System.Collections.Generic; +using Gameboard.Api.Features.Feedback; namespace Gameboard.Api.Features.Reports; diff --git a/src/Gameboard.Api/Features/Reports/Queries/FeedbackGameReport/GetFeedbackGameReport.cs b/src/Gameboard.Api/Features/Reports/Queries/FeedbackGameReport/GetFeedbackGameReport.cs index c30cab62..ac79763c 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/FeedbackGameReport/GetFeedbackGameReport.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/FeedbackGameReport/GetFeedbackGameReport.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Gameboard.Api.Features.Feedback; using Gameboard.Api.Services; using Gameboard.Api.Structure.MediatR; using Gameboard.Api.Structure.MediatR.Validators; diff --git a/src/Gameboard.Api/Features/Reports/ReportService.cs b/src/Gameboard.Api/Features/Reports/ReportService.cs index faaf9727..fc371e62 100644 --- a/src/Gameboard.Api/Features/Reports/ReportService.cs +++ b/src/Gameboard.Api/Features/Reports/ReportService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using AutoMapper; using Gameboard.Api.Data; +using Gameboard.Api.Features.Feedback; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/src/Gameboard.Api/Features/Reports/ReportsController.cs b/src/Gameboard.Api/Features/Reports/ReportsController.cs index 1f4a0284..3f1952ef 100644 --- a/src/Gameboard.Api/Features/Reports/ReportsController.cs +++ b/src/Gameboard.Api/Features/Reports/ReportsController.cs @@ -8,6 +8,7 @@ namespace Gameboard.Api.Features.Reports; +[ApiController] [Authorize] [Route("/api/reports")] public class ReportsController(IMediator mediator, IReportsService service) : ControllerBase diff --git a/src/Gameboard.Api/Features/Reports/ReportsExportController.cs b/src/Gameboard.Api/Features/Reports/ReportsExportController.cs index 3daa6049..2c8706cb 100644 --- a/src/Gameboard.Api/Features/Reports/ReportsExportController.cs +++ b/src/Gameboard.Api/Features/Reports/ReportsExportController.cs @@ -10,6 +10,7 @@ namespace Gameboard.Api.Features.Reports; +[ApiController] [Authorize] [Route("/api/reports/export")] public class ReportsExportController : ControllerBase diff --git a/src/Gameboard.Api/Features/Reports/ReportsV1Controller.cs b/src/Gameboard.Api/Features/Reports/ReportsV1Controller.cs index ecc09ec1..4b7a51e1 100644 --- a/src/Gameboard.Api/Features/Reports/ReportsV1Controller.cs +++ b/src/Gameboard.Api/Features/Reports/ReportsV1Controller.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using Gameboard.Api.Common.Services; +using Gameboard.Api.Features.Feedback; using Gameboard.Api.Features.Users; using Gameboard.Api.Services; using Microsoft.AspNetCore.Authorization; diff --git a/src/Gameboard.Api/Features/Scores/ScoringController.cs b/src/Gameboard.Api/Features/Scores/ScoringController.cs index 8cab082d..d589ff0f 100644 --- a/src/Gameboard.Api/Features/Scores/ScoringController.cs +++ b/src/Gameboard.Api/Features/Scores/ScoringController.cs @@ -5,6 +5,7 @@ namespace Gameboard.Api.Features.Scores; +[ApiController] [Authorize] [Route("api")] public class ScoringController(IMediator mediator) : ControllerBase() diff --git a/src/Gameboard.Api/Features/Settings/SettingsController.cs b/src/Gameboard.Api/Features/Settings/SettingsController.cs index 23e3b81c..e9a2ca79 100644 --- a/src/Gameboard.Api/Features/Settings/SettingsController.cs +++ b/src/Gameboard.Api/Features/Settings/SettingsController.cs @@ -7,6 +7,7 @@ namespace Gameboard.Api.Features.Settings; +[ApiController] [Authorize] [Route("api/settings")] public class SettingsController(IMediator mediator) : ControllerBase diff --git a/src/Gameboard.Api/Features/Support/SupportController.cs b/src/Gameboard.Api/Features/Support/SupportController.cs index 0bff3334..d75c022a 100644 --- a/src/Gameboard.Api/Features/Support/SupportController.cs +++ b/src/Gameboard.Api/Features/Support/SupportController.cs @@ -7,6 +7,7 @@ namespace Gameboard.Api.Features.Support; +[ApiController] [Authorize] [Route("api/support")] public class SupportController(IMediator mediator) : ControllerBase diff --git a/src/Gameboard.Api/Features/SystemNotifications/SystemNotificationsController.cs b/src/Gameboard.Api/Features/SystemNotifications/SystemNotificationsController.cs index 52f49077..d4f48b9a 100644 --- a/src/Gameboard.Api/Features/SystemNotifications/SystemNotificationsController.cs +++ b/src/Gameboard.Api/Features/SystemNotifications/SystemNotificationsController.cs @@ -6,6 +6,7 @@ namespace Gameboard.Api.Features.SystemNotifications; +[ApiController] [Route("api")] [Authorize] public class SystemNotificationsController(IMediator mediator) : ControllerBase diff --git a/src/Gameboard.Api/Features/Teams/AdminTeamController.cs b/src/Gameboard.Api/Features/Teams/AdminTeamController.cs index a70c33b2..6721ce99 100644 --- a/src/Gameboard.Api/Features/Teams/AdminTeamController.cs +++ b/src/Gameboard.Api/Features/Teams/AdminTeamController.cs @@ -7,6 +7,7 @@ namespace Gameboard.Api.Features.Teams; +[ApiController] [Route("api/admin/team")] [Authorize] public class AdminTeamsController(IMediator mediator) : ControllerBase diff --git a/src/Gameboard.Api/Features/Teams/TeamController.cs b/src/Gameboard.Api/Features/Teams/TeamController.cs index a51f0d6b..2dcd126f 100644 --- a/src/Gameboard.Api/Features/Teams/TeamController.cs +++ b/src/Gameboard.Api/Features/Teams/TeamController.cs @@ -10,6 +10,7 @@ namespace Gameboard.Api.Features.Teams; +[ApiController] [Authorize] [Route("/api/team")] public class TeamController( diff --git a/src/Gameboard.Api/Features/User/Requests/TryCreateUsers.cs b/src/Gameboard.Api/Features/User/Requests/TryCreateUsers.cs index 9711ea4f..ab815f60 100644 --- a/src/Gameboard.Api/Features/User/Requests/TryCreateUsers.cs +++ b/src/Gameboard.Api/Features/User/Requests/TryCreateUsers.cs @@ -21,15 +21,17 @@ internal sealed class TryCreateUsersHandler EntityExistsValidator gameExists, PlayerService playerService, EntityExistsValidator sponsorExists, + ISponsorService sponsorService, IStore store, UserService userService, IValidatorService validator - ) : IRequestHandler +) : IRequestHandler { private readonly IActingUserService _actingUserService = actingUserService; private readonly EntityExistsValidator _gameExists = gameExists; private readonly PlayerService _playerService = playerService; private readonly EntityExistsValidator _sponsorExists = sponsorExists; + private readonly ISponsorService _sponsorService = sponsorService; private readonly IStore _store = store; private readonly UserService _userService = userService; private readonly IValidatorService _validator = validator; @@ -66,12 +68,15 @@ public async Task Handle(TryCreateUsersCommand request, // do the business var createdUsers = new List(); + var defaultSponsor = await _sponsorService.GetDefaultSponsor(); + foreach (var id in request.Request.UserIds.ToArray()) { createdUsers.Add(await _userService.TryCreate(new NewUser { Id = id, - SponsorId = request.Request.SponsorId, + Role = request.Request.Role ?? UserRoleKey.Member, + SponsorId = request.Request.SponsorId.IsNotEmpty() ? defaultSponsor.Id : request.Request.SponsorId, UnsetDefaultSponsorFlag = request.Request.UnsetDefaultSponsorFlag })); } @@ -127,6 +132,7 @@ await _playerService.Enroll(new NewPlayer Name = u.User.ApprovedName, IsNewUser = u.IsNewUser, EnrolledInGameId = request.Request.EnrollInGameId, + Role = u.User.Role, Sponsor = new SimpleEntity { Id = u.User.SponsorId, Name = sponsorNames[u.User.SponsorId] } }) }; diff --git a/src/Gameboard.Api/Features/User/User.cs b/src/Gameboard.Api/Features/User/User.cs index efa42a54..6aeff744 100644 --- a/src/Gameboard.Api/Features/User/User.cs +++ b/src/Gameboard.Api/Features/User/User.cs @@ -17,7 +17,7 @@ public class User public string Name { get; set; } public string NameStatus { get; set; } public string ApprovedName { get; set; } - public UserRoleKey? Role { get; set; } + public UserRoleKey Role { get; set; } public IEnumerable RolePermissions { get; set; } = []; public Player[] Enrollments { get; set; } public DateTimeOffset CreatedOn { get; set; } @@ -33,6 +33,7 @@ public class NewUser { public required string Id { get; set; } public string DefaultName { get; set; } + public UserRoleKey Role { get; set; } = UserRoleKey.Member; public string SponsorId { get; set; } public bool UnsetDefaultSponsorFlag { get; set; } } @@ -100,9 +101,9 @@ public class TryCreateUsersRequest { public required bool AllowSubsetCreation { get; set; } public string EnrollInGameId { get; set; } - [TypeConverter(typeof(JsonStringEnumConverter))] - public UserRoleKey Role { get; set; } = UserRoleKey.Member; - public required string SponsorId { get; set; } + [TypeConverter(typeof(JsonStringEnumConverter))] + public UserRoleKey? Role { get; set; } = UserRoleKey.Member; + public string SponsorId { get; set; } public required bool UnsetDefaultSponsorFlag { get; set; } public required IEnumerable UserIds { get; set; } } @@ -117,6 +118,7 @@ public sealed class TryCreateUsersResponseUser public required string Id { get; set; } public required string EnrolledInGameId { get; set; } public required string Name { get; set; } + public required UserRoleKey Role { get; set; } public required SimpleEntity Sponsor { get; set; } public required bool IsNewUser { get; set; } } diff --git a/src/Gameboard.Api/Features/User/UserController.cs b/src/Gameboard.Api/Features/User/UserController.cs index d39da95a..2fb339b7 100644 --- a/src/Gameboard.Api/Features/User/UserController.cs +++ b/src/Gameboard.Api/Features/User/UserController.cs @@ -23,8 +23,10 @@ namespace Gameboard.Api.Controllers; +[ApiController] [Authorize] -public class UserController( +public class UserController +( IActingUserService actingUserService, IGuidService guids, ILogger logger, @@ -34,15 +36,14 @@ public class UserController( CoreOptions options, IMediator mediator, IUserRolePermissionsService permissionsService - ) : GameboardLegacyController(actingUserService, logger, cache, validator) +) : GameboardLegacyController(actingUserService, logger, cache, validator) { private readonly IActingUserService _actingUserService = actingUserService; private readonly IGuidService _guids = guids; + private readonly CoreOptions _options = options; private readonly IMediator _mediator = mediator; private readonly IUserRolePermissionsService _permissionsService = permissionsService; - - UserService UserService { get; } = userService; - CoreOptions Options { get; } = options; + private readonly UserService _userService = userService; /// /// Register a new user @@ -59,7 +60,7 @@ await AuthorizeAny () => _permissionsService.Can(PermissionKey.Users_CreateEditDelete) ); - var result = await UserService.TryCreate(model); + var result = await _userService.TryCreate(model); await HttpContext.SignInAsync( AppConstants.MksCookie, @@ -106,7 +107,7 @@ await AuthorizeAny ); await Validate(new Entity { Id = id }); - return await UserService.Retrieve(id); + return await _userService.Retrieve(id); } /// @@ -125,7 +126,7 @@ public async Task Update([FromBody] UpdateUser model) ); await Validate(model); - return await UserService.Update(model, canAdminUsers); + return await _userService.Update(model, canAdminUsers); } /// @@ -138,7 +139,7 @@ public async Task Delete([FromRoute] string id) { await Authorize(_permissionsService.Can(PermissionKey.Users_CreateEditDelete)); await Validate(new Entity { Id = id }); - await UserService.Delete(id); + await _userService.Delete(id); } /// @@ -148,7 +149,7 @@ public async Task Delete([FromRoute] string id) /// [HttpGet("/api/user/{userId}/challenges/active")] public Task GetUserActiveChallenges([FromRoute] string userId) - => _mediator.Send(new GetUserActiveChallengesQuery(userId)); + => _mediator.Send(new GetUserActiveChallengesQuery(userId)); /// /// Find users @@ -159,7 +160,7 @@ public Task GetUserActiveChallenges([FromRoute] public async Task> List([FromQuery] UserSearch model) { await Authorize(_permissionsService.Can(PermissionKey.Admin_View)); - return await UserService.List(model); + return await _userService.List(model); } /// @@ -171,7 +172,7 @@ public async Task> List([FromQuery] UserSearch model) public async Task ListSupport([FromQuery] SearchFilter model) { await Authorize(_permissionsService.Can(PermissionKey.Support_ManageTickets)); - return await UserService.ListSupport(model); + return await _userService.ListSupport(model); } /// @@ -182,7 +183,7 @@ public async Task ListSupport([FromQuery] SearchFilter model) [HttpPost("/api/user/ticket")] public async Task GetTicket() { - string ticket = _guids.Generate(); + var ticket = _guids.Generate(); await Cache.SetStringAsync( $"{TicketAuthentication.TicketCachePrefix}{ticket}", @@ -219,8 +220,9 @@ public async Task Logout() [AllowAnonymous] public string[] GetDocList() { - var result = Directory.GetFiles(Options.DocFolder, "*", SearchOption.AllDirectories) - .Select(x => x.Replace(Options.DocFolder, "")) + var result = Directory + .GetFiles(_options.DocFolder, "*", SearchOption.AllDirectories) + .Select(x => x.Replace(_options.DocFolder, "")) .ToArray(); return result; diff --git a/src/Gameboard.Api/Features/User/UserService.cs b/src/Gameboard.Api/Features/User/UserService.cs index 748e73b9..8700d023 100644 --- a/src/Gameboard.Api/Features/User/UserService.cs +++ b/src/Gameboard.Api/Features/User/UserService.cs @@ -63,7 +63,9 @@ public async Task TryCreate(NewUser model) // first user gets admin if (!await _store.WithNoTracking().AnyAsync(u => u.Id != model.Id)) + { entity.Role = UserRoleKey.Admin; + } // record creation date and first login if (entity.CreatedOn.IsEmpty()) diff --git a/src/Gameboard.Api/Structure/Auth/ClaimsPrincipalExtensions.cs b/src/Gameboard.Api/Structure/Auth/ClaimsPrincipalExtensions.cs index 79f18801..107699e1 100644 --- a/src/Gameboard.Api/Structure/Auth/ClaimsPrincipalExtensions.cs +++ b/src/Gameboard.Api/Structure/Auth/ClaimsPrincipalExtensions.cs @@ -26,7 +26,7 @@ public static async Task ToActor(this ClaimsPrincipal principal, IUserRole Id = principal.Subject(), Name = principal.FindFirstValue(AppConstants.NameClaimName), ApprovedName = principal.FindFirstValue(AppConstants.ApprovedNameClaimName), - Role = finalRole, + Role = finalRole.HasValue ? finalRole.Value : UserRoleKey.Member, RolePermissions = await userRolePermissionsService.GetPermissions(role), SponsorId = principal.FindFirstValue(AppConstants.SponsorClaimName) }; diff --git a/src/Gameboard.Api/Structure/Exceptions.cs b/src/Gameboard.Api/Structure/Exceptions.cs index 53c3dc89..1cb6e264 100644 --- a/src/Gameboard.Api/Structure/Exceptions.cs +++ b/src/Gameboard.Api/Structure/Exceptions.cs @@ -9,6 +9,7 @@ namespace Gameboard.Api { internal class MissingRequiredInput : GameboardValidationException { + internal MissingRequiredInput(string inputName) : this(inputName, default) { } internal MissingRequiredInput(string inputName, T input) : base($"""Your input for "{inputName}" was either missing or incorrectly formed (found \"{input}\").""") { } } diff --git a/src/Gameboard.Api/Structure/MediatR/Validators/UserRolePermissionsValidator.cs b/src/Gameboard.Api/Structure/MediatR/Validators/UserRolePermissionsValidator.cs index d6a9eb70..bb6de034 100644 --- a/src/Gameboard.Api/Structure/MediatR/Validators/UserRolePermissionsValidator.cs +++ b/src/Gameboard.Api/Structure/MediatR/Validators/UserRolePermissionsValidator.cs @@ -32,10 +32,6 @@ internal async Task> GetAuthValidation // if there are no required permissions, validation always passes if (_requiredPermissions is not null && _requiredPermissions.Any()) { - // if the user doesn't have a role, this won't work - if (user.Role is null) - throw new UnauthorizedAccessException("This operation requires a role."); - // if the user is on the whitelist, let em through if (_unlessUserIdIn is not null && _unlessUserIdIn.Any(uId => uId == user.Id)) return []; @@ -59,7 +55,7 @@ internal async Task> GetAuthValidation if (missingPermissions.Any()) { - retVal.Add(new UserRolePermissionException(user.Role.Value, missingPermissions)); + retVal.Add(new UserRolePermissionException(user.Role, missingPermissions)); if (_unless is not null && _unlessException is not null) { From 74b604df99b2ae38aa2630c8912c2db793c83c1c Mon Sep 17 00:00:00 2001 From: Ben Stein <115497763+sei-bstein@users.noreply.github.com> Date: Fri, 15 Nov 2024 09:56:09 -0500 Subject: [PATCH 4/7] Add'l team start tests (#531) - Fixed a bug that could prevent players from starting a session if they hadn't made a user name change request - Added additional test coverage for team session start --- .../Features/Teams/StartTeamSessionTests.cs | 74 ++++++++++++++++++- .../StartTeamSessions/StartTeamSessions.cs | 2 +- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs index c2367353..0eab61e4 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs @@ -1,5 +1,7 @@ using Gameboard.Api.Common; +using Gameboard.Api.Features.Teams; using Gameboard.Api.Structure; +using ServiceStack; namespace Gameboard.Api.Tests.Integration.Teams; @@ -10,7 +12,6 @@ public class TeamControllerStartTeamSessionTests(GameboardTestContext testContex [Theory, GbIntegrationAutoData] public async Task TeamGame_WithSinglePlayer_CantStart ( - string gameId, string playerId, string userId, IFixture fixture @@ -21,7 +22,7 @@ await _testContext.WithDataState(state => { state.Add(new Data.Game { - Id = gameId, + Id = fixture.Create(), MinTeamSize = 2, MaxTeamSize = 5, GameStart = DateTimeOffset.UtcNow, @@ -46,7 +47,6 @@ await _testContext [Theory, GbIntegrationAutoData] public async Task TeamGame_WithTwoPlayers_CanStart ( - string gameId, string playerId, string userId, string teamId, @@ -58,7 +58,7 @@ await _testContext.WithDataState(state => { state.Add(new Data.Game { - Id = gameId, + Id = fixture.Create(), MinTeamSize = 2, MaxTeamSize = 5, GameStart = DateTimeOffset.UtcNow, @@ -93,4 +93,70 @@ await _testContext.WithDataState(state => // then we should get a player back with a nonempty session start result.SessionBegin.ShouldBeGreaterThan(DateTimeOffset.MinValue); } + + [Theory, GbIntegrationAutoData] + public async Task TeamGame_WithCaptainPromotion_CanStart + ( + string finalCaptainPlayerId, + string finalCaptainUserId, + string initialCaptainPlayerId, + string initialCaptainUserId, + string teamId, + IFixture fixture + ) + { + // given a team game and a registered player with no teammates + await _testContext.WithDataState(state => + { + state.Add(new Data.Game + { + Id = fixture.Create(), + MinTeamSize = 2, + MaxTeamSize = 5, + GameStart = DateTimeOffset.UtcNow, + GameEnd = DateTimeOffset.UtcNow.AddDays(1), + Mode = GameEngineMode.Standard, + Players = + [ + state.Build(fixture, p => + { + p.Id = initialCaptainPlayerId; + p.Role = PlayerRole.Manager; + p.TeamId = teamId; + p.User = state.Build(fixture, u => u.Id = initialCaptainUserId); + }), + state.Build(fixture, p => + { + p.Id = finalCaptainPlayerId; + p.Role = PlayerRole.Member; + p.TeamId = teamId; + p.User = state.Build(fixture, u => u.Id = finalCaptainUserId); + }) + ] + }); + }); + + // when they promote a new captain and then start + var httpClient = _testContext.CreateHttpClientWithActingUser(u => u.Id = initialCaptainUserId); + + await httpClient + .PutAsync($"api/team/{teamId}/manager/{finalCaptainPlayerId}", new PromoteToManagerRequest + { + CurrentManagerPlayerId = initialCaptainPlayerId, + NewManagerPlayerId = finalCaptainPlayerId, + TeamId = teamId + }.ToJsonBody()); + + var result = await _testContext + .CreateHttpClientWithActingUser(u => u.Id = finalCaptainUserId) + .PutAsync($"api/player/{finalCaptainPlayerId}/start", null) + .DeserializeResponseAs(); + + // then we should get a player back with a nonempty session start + result.SessionBegin.ShouldBeGreaterThan(DateTimeOffset.MinValue); + } + + // Users can team up, leave the team, join a different team, then start sessions on the original and the new team + // Admins can start sessions for non-admins + // Non-admins can't start sessions for other teams } diff --git a/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessions.cs b/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessions.cs index 39e0d3f9..88e668db 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessions.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessions.cs @@ -114,7 +114,7 @@ await _permissionsService.Can(PermissionKey.Play_IgnoreExecutionWindow), ); // start all sessions - // (note that this effective synchronizes all teams starting in this command) + // (note that this effectively synchronizes all teams starting in this command) _logger.LogInformation($"Starting sessions for {request.TeamIds.Count()} teams..."); await _teamService.SetSessionWindow(request.TeamIds, sessionWindow, cancellationToken); _logger.LogInformation($"Sessions started."); From 101ad5798598b3e5045e93cac5b314fd287cdd77 Mon Sep 17 00:00:00 2001 From: Ben Stein <115497763+sei-bstein@users.noreply.github.com> Date: Fri, 15 Nov 2024 13:02:02 -0500 Subject: [PATCH 5/7] 3.24.4 - Fixed an issue that caused the late start banner to be insanely precise (resolves #437) - Fixed a team-up bug - Fixed an issue with a few of the support tools links --- src/Gameboard.Api/Features/Player/PlayerController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Gameboard.Api/Features/Player/PlayerController.cs b/src/Gameboard.Api/Features/Player/PlayerController.cs index dd60c725..fbda0e65 100644 --- a/src/Gameboard.Api/Features/Player/PlayerController.cs +++ b/src/Gameboard.Api/Features/Player/PlayerController.cs @@ -206,7 +206,7 @@ public async Task Invite([FromRoute] string id) { await AuthorizeAny ( - () => _permissionsService.IsActingUserAsync(id), + () => IsSelf(id), () => _permissionsService.Can(PermissionKey.Teams_Enroll) ); From 87cd6bd43b725546eccb43f000fe1833d0efdaf3 Mon Sep 17 00:00:00 2001 From: Ben Stein <115497763+sei-bstein@users.noreply.github.com> Date: Mon, 2 Dec 2024 14:15:13 -0500 Subject: [PATCH 6/7] v3.25.0 (#552) * Fix teamup bug * Clarify that the regrade permission also applies to reranking games * Resolve #549 * Various bug fixes for competitive and version 3.25.0 * Pending user names count as any unresolved name request. * Fix test fails. * Fix a bug in practice challenge search * Resolves #543 * Resolves #540 * Fix prac challenge visibility * Cleanup and fix test * Fix/improve various session and challenge launch logic * Fix #539 * Fix promotion bug --- .../Practice/SearchPracticeChallengesTests.cs | 7 +- .../Features/Teams/StartTeamSessionTests.cs | 47 +++++- .../Challenges/ChallengeServiceTests.cs | 3 - .../Features/Player/PlayerServiceTests.cs | 14 -- .../Practice/SearchPracticeChallengesTests.cs | 15 +- .../GetGameCenterContext.cs | 50 +++---- .../GetGameCenterContextModels.cs | 1 + .../Features/Challenge/Challenge.cs | 12 ++ .../Features/Challenge/ChallengeMapper.cs | 8 +- .../Challenge/Services/ChallengeService.cs | 73 ++++++---- .../ChallengeSpec/ChallengeSpecMapper.cs | 4 +- .../Features/Game/GameExceptions.cs | 9 +- .../Features/Game/GameService.cs | 7 +- .../GetGamePlayState/GetGamePlayState.cs | 4 +- .../Features/Player/PlayerController.cs | 22 +-- .../Features/Player/PlayerExceptions.cs | 1 + .../Features/Player/PlayerMapper.cs | 20 ++- .../Features/Player/PlayerValidator.cs | 6 +- .../Features/Player/Services/PlayerService.cs | 62 ++------ .../Features/Practice/PracticeModels.cs | 5 - .../SearchPracticeChallenges.cs | 45 +++++- .../SearchPracticeChallengesModels.cs | 29 ++++ .../PlayersReport/PlayersReportService.cs | 13 +- .../AddPlayerToTeam/AddPlayerToTeam.cs | 6 +- .../Teams/Requests/AddToTeam/AddToTeam.cs | 137 ++++++++++++++++++ .../Requests/AddToTeam/AddToTeamModels.cs | 18 +++ .../AdminEnrollTeam/AdminEnrollTeam.cs | 48 +++--- .../Requests/RemoveFromTeam/RemoveFromTeam.cs | 94 ++++++++++++ .../RemoveFromTeam/RemoveFromTeamModels.cs | 9 ++ .../StartTeamSessionsValidator.cs | 9 +- .../Features/Teams/Services/TeamService.cs | 38 ++++- .../Features/Teams/TeamController.cs | 8 + .../Features/Teams/TeamExceptions.cs | 19 ++- .../Features/Teams/TeamsModels.cs | 9 +- .../Features/Ticket/TicketController.cs | 11 +- .../Features/Ticket/TicketService.cs | 71 ++++----- .../Permissions/UserRolePermissionsService.cs | 9 +- .../RequestNameChange/RequestNameChange.cs | 2 +- .../Features/User/UserService.cs | 5 +- src/Gameboard.Api/Structure/AppSettings.cs | 20 +-- 40 files changed, 685 insertions(+), 285 deletions(-) rename src/Gameboard.Api/Features/Practice/{ => Requests/SearchPracticeChallenges}/SearchPracticeChallenges.cs (69%) create mode 100644 src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallengesModels.cs create mode 100644 src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeam.cs create mode 100644 src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeamModels.cs create mode 100644 src/Gameboard.Api/Features/Teams/Requests/RemoveFromTeam/RemoveFromTeam.cs create mode 100644 src/Gameboard.Api/Features/Teams/Requests/RemoveFromTeam/RemoveFromTeamModels.cs diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Practice/SearchPracticeChallengesTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Practice/SearchPracticeChallengesTests.cs index 7cefe95c..b8ddd970 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Practice/SearchPracticeChallengesTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Practice/SearchPracticeChallengesTests.cs @@ -31,9 +31,11 @@ await _testContext.WithDataState state.Add(fixture, game => { + game.IsPublished = true; + game.PlayerMode = PlayerMode.Practice; + game.Specs = state.Build(fixture, spec => { - game.PlayerMode = PlayerMode.Practice; spec.Tags = tag; }).ToCollection(); }); @@ -63,6 +65,9 @@ await _testContext.WithDataState // note there are no suggested searches in this db state.Add(fixture, game => { + game.PlayerMode = PlayerMode.Practice; + game.IsPublished = true; + game.Specs = state.Build(fixture, spec => { game.PlayerMode = PlayerMode.Practice; diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs index 0eab61e4..14f7d047 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs @@ -1,7 +1,7 @@ using Gameboard.Api.Common; using Gameboard.Api.Features.Teams; using Gameboard.Api.Structure; -using ServiceStack; +using StackExchange.Redis; namespace Gameboard.Api.Tests.Integration.Teams; @@ -142,7 +142,7 @@ await _testContext.WithDataState(state => await httpClient .PutAsync($"api/team/{teamId}/manager/{finalCaptainPlayerId}", new PromoteToManagerRequest { - CurrentManagerPlayerId = initialCaptainPlayerId, + CurrentCaptainId = initialCaptainPlayerId, NewManagerPlayerId = finalCaptainPlayerId, TeamId = teamId }.ToJsonBody()); @@ -159,4 +159,47 @@ await httpClient // Users can team up, leave the team, join a different team, then start sessions on the original and the new team // Admins can start sessions for non-admins // Non-admins can't start sessions for other teams + [Theory, GbIntegrationAutoData] + public async Task Team_WhenStartingOtherTeamSession_FailsValidation + ( + string actingTeamId, + string actingUserId, + string targetPlayerId, + IFixture fixture + ) + { + // given two players registered for the same game + await _testContext.WithDataState(state => + { + state.Add(fixture, game => + { + game.Players = + [ + // the person who's starting a session + state.Build(fixture, p => + { + p.Id = fixture.Create(); + p.Role = PlayerRole.Manager; + p.TeamId = actingTeamId; + p.User = state.Build(fixture, u => u.Id = actingUserId); + }), + state.Build(fixture, p => + { + p.Id = targetPlayerId; + p.Role = PlayerRole.Manager; + p.TeamId = actingTeamId; + p.User = state.Build(fixture); + }) + ]; + }); + }); + + // when the first player tries to start the second's session + var response = await _testContext + .CreateHttpClientWithActingUser(u => u.Id = actingUserId) + .PutAsync($"api/player/{targetPlayerId}/start", null); + + // then the response should have a failure code + response.IsSuccessStatusCode.ShouldBeFalse(); + } } diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs index 02b3f52c..9f413127 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs @@ -8,7 +8,6 @@ using Gameboard.Api.Features.Users; using Gameboard.Api.Services; using MediatR; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace Gameboard.Api.Tests.Unit; @@ -113,7 +112,6 @@ string userId A.Fake>(), A.Fake(), A.Fake(), - A.Fake(), A.Fake(), A.Fake(), A.Fake(), @@ -237,7 +235,6 @@ string userId A.Fake>(), A.Fake(), A.Fake(), - A.Fake(), A.Fake(), A.Fake(), A.Fake(), diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs index bea26a32..69454eec 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs @@ -5,20 +5,6 @@ namespace Gameboard.Api.Tests.Unit; public class PlayerServiceTests { - [Theory, GameboardAutoData] - public async Task Standings_WhenGameIdIsEmpty_ReturnsEmptyArray(IFixture fixture) - { - // arrange - var sut = fixture.Create(); - var filterParams = A.Fake(); - - // act - var result = await sut.Standings(filterParams); - - // assert - result.ShouldBe(Array.Empty()); - } - [Theory, GameboardAutoData] public async Task MakeCertificates_WhenScoreZero_ReturnsEmptyArray(IFixture fixture) { diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/SearchPracticeChallengesTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/SearchPracticeChallengesTests.cs index 533ec870..e08b000f 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/SearchPracticeChallengesTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/SearchPracticeChallengesTests.cs @@ -1,9 +1,9 @@ -using AutoMapper; using Gameboard.Api.Common; using Gameboard.Api.Common.Services; using Gameboard.Api.Data; using Gameboard.Api.Features.Challenges; using Gameboard.Api.Features.Practice; +using Gameboard.Api.Features.Users; using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Tests.Unit; @@ -32,9 +32,8 @@ public async Task SearchPracticeChallenges_WithDisabled_ReturnsEmpty(IFixture fi var sut = GetSutWithResults(fixture, disabledSpec); // when a query for all challenges is issued - var result = await sut - .BuildQuery(string.Empty, Array.Empty()) - .ToArrayAsync(CancellationToken.None); + var query = await sut.BuildQuery(string.Empty, []); + var result = await query.ToArrayAsync(CancellationToken.None); // then we expect no results result.Length.ShouldBe(0); @@ -53,6 +52,7 @@ public async Task SearchPracticeChallenges_WithEnabled_Returns(IFixture fixture) Disabled = false, Game = new Data.Game { + IsPublished = true, Name = fixture.Create(), PlayerMode = PlayerMode.Practice } @@ -61,9 +61,8 @@ public async Task SearchPracticeChallenges_WithEnabled_Returns(IFixture fixture) var sut = GetSutWithResults(fixture, enabledSpec); // when a query for all challenges is issued - var result = await sut - .BuildQuery(string.Empty, Array.Empty()) - .ToArrayAsync(CancellationToken.None); + var query = await sut.BuildQuery(string.Empty, []); + var result = await query.ToArrayAsync(CancellationToken.None); // then we expect one result result.Length.ShouldBe(1); @@ -81,8 +80,8 @@ private SearchPracticeChallengesHandler GetSutWithResults(IFixture fixture, para var sut = new SearchPracticeChallengesHandler ( A.Fake(), - A.Fake(), A.Fake(), + A.Fake(), A.Fake(), A.Fake(), store diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs index fc7fc20e..0a47b954 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs @@ -16,32 +16,22 @@ namespace Gameboard.Api.Features.Admin; public record GetGameCenterContextQuery(string GameId) : IRequest; -internal class GetGameCenterContextHandler : IRequestHandler +internal class GetGameCenterContextHandler +( + EntityExistsValidator gameExists, + INowService now, + IStore store, + ITeamService teamService, + TicketService ticketService, + IValidatorService validator +) : IRequestHandler { - private readonly EntityExistsValidator _gameExists; - private readonly INowService _now; - private readonly IStore _store; - private readonly ITeamService _teamService; - private readonly TicketService _ticketService; - private readonly IValidatorService _validator; - - public GetGameCenterContextHandler - ( - EntityExistsValidator gameExists, - INowService now, - IStore store, - ITeamService teamService, - TicketService ticketService, - IValidatorService validator - ) - { - _gameExists = gameExists; - _now = now; - _store = store; - _teamService = teamService; - _ticketService = ticketService; - _validator = validator; - } + private readonly EntityExistsValidator _gameExists = gameExists; + private readonly INowService _now = now; + private readonly IStore _store = store; + private readonly ITeamService _teamService = teamService; + private readonly TicketService _ticketService = ticketService; + private readonly IValidatorService _validator = validator; public async Task Handle(GetGameCenterContextQuery request, CancellationToken cancellationToken) { @@ -86,8 +76,11 @@ await _validator }) .SingleOrDefaultAsync(cancellationToken); - var openTicketCount = await _ticketService - .GetGameOpenTickets(request.GameId) + var gameTotalTicketCount = await _ticketService + .GetGameTicketsQuery(request.GameId) + .CountAsync(cancellationToken); + var gameOpenTicketCount = await _ticketService + .GetGameOpenTicketsQuery(request.GameId) .CountAsync(cancellationToken); var topScore = await _store @@ -185,7 +178,8 @@ await _validator // aggregates ChallengeCount = challengeData?.ChallengeCount ?? 0, PointsAvailable = challengeData?.PointsAvailable ?? 0, - OpenTicketCount = openTicketCount + OpenTicketCount = gameOpenTicketCount, + TotalTicketCount = gameTotalTicketCount }; } } diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs index f2f7f3c5..de52fb7d 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs @@ -15,6 +15,7 @@ public sealed class GameCenterContext public required int ChallengeCount { get; set; } public required int OpenTicketCount { get; set; } + public required int TotalTicketCount { get; set; } public required bool HasScoreboard { get; set; } public required bool IsExternal { get; set; } diff --git a/src/Gameboard.Api/Features/Challenge/Challenge.cs b/src/Gameboard.Api/Features/Challenge/Challenge.cs index e166ee09..ff7b1454 100644 --- a/src/Gameboard.Api/Features/Challenge/Challenge.cs +++ b/src/Gameboard.Api/Features/Challenge/Challenge.cs @@ -49,6 +49,18 @@ public class ChallengeSummary public bool IsActive { get; set; } } +public sealed class ChallengeLaunchCacheEntry +{ + public required string TeamId { get; set; } + public required IList Specs { get; set; } = []; +} + +public sealed class ChallengeLaunchCacheEntrySpec +{ + public required string GameId { get; set; } + public required string SpecId { get; set; } +} + public class UserActiveChallenge { public required string Id { get; set; } diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeMapper.cs b/src/Gameboard.Api/Features/Challenge/ChallengeMapper.cs index c797641f..1f50d46f 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeMapper.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeMapper.cs @@ -49,15 +49,13 @@ public ChallengeMapper() .ForMember(d => d.LastScoreTime, opt => opt.MapFrom(s => s.Challenge.LastScoreTime)) .ForMember(d => d.Score, opt => opt.MapFrom(s => s.Challenge.Score)) .ForMember(d => d.HasDeployedGamespace, opt => opt.MapFrom(s => s.Vms != null && s.Vms.Any())) - .ForMember(d => d.State, opt => opt.MapFrom(s => JsonSerializer.Serialize(s, JsonOptions))) - ; + .ForMember(d => d.State, opt => opt.MapFrom(s => JsonSerializer.Serialize(s, JsonOptions))); CreateMap() .ForMember(d => d.Score, opt => opt.MapFrom(s => (int)Math.Floor(s.Score))) .ForMember(d => d.State, opt => opt.MapFrom(s => JsonSerializer.Deserialize(s.State, JsonOptions)) - ) - ; + ); CreateMap() .ForMember(cp => cp.IsManager, o => o.MapFrom(p => p.Role == PlayerRole.Manager)); @@ -67,7 +65,7 @@ public ChallengeMapper() .ForMember(d => d.Events, o => o.MapFrom(c => c.Events.OrderBy(e => e.Timestamp))) .ForMember(s => s.Players, o => o.MapFrom(d => new ChallengePlayer[] { - new ChallengePlayer + new() { Id = d.PlayerId, Name = d.Player.Name, diff --git a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs index ed4aded3..30514148 100644 --- a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs +++ b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs @@ -2,9 +2,9 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; using AutoMapper; @@ -12,15 +12,13 @@ using Gameboard.Api.Data; using Gameboard.Api.Features.Challenges; using Gameboard.Api.Features.GameEngine; +using Gameboard.Api.Features.Practice; using Gameboard.Api.Features.Teams; using Gameboard.Api.Features.Scores; using Gameboard.Api.Features.Users; using MediatR; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using ServiceStack; -using Gameboard.Api.Features.Practice; namespace Gameboard.Api.Services; @@ -39,7 +37,6 @@ public partial class ChallengeService ILogger logger, IMapper mapper, IMediator mediator, - IMemoryCache memCache, INowService now, IPracticeService practiceService, IUserRolePermissionsService permissionsService, @@ -53,9 +50,9 @@ ITeamService teamService private readonly IGameEngineService _gameEngine = gameEngine; private readonly IGuidService _guids = guids; private readonly IJsonService _jsonService = jsonService; + private readonly static ConcurrentDictionary _launchCache = new(); private readonly IMapper _mapper = mapper; private readonly IMediator _mediator = mediator; - private readonly IMemoryCache _memCache = memCache; private readonly INowService _now = now; private readonly IPracticeService _practiceService = practiceService; private readonly IUserRolePermissionsService _permissionsService = permissionsService; @@ -75,6 +72,16 @@ public async Task GetOrCreate(NewChallenge model, string actorId, str return await Create(model, actorId, graderUrl, CancellationToken.None); } + public int GetDeployingChallengeCount(string teamId) + { + if (!_launchCache.TryGetValue(teamId, out var entry)) + { + return 0; + } + + return entry.Specs.Count; + } + public IEnumerable GetTags(Data.ChallengeSpec spec) { if (spec.Tags.IsEmpty()) @@ -98,11 +105,17 @@ public async Task Create(NewChallenge model, string actorId, string g .Include(g => g.Prerequisites) .SingleAsync(g => g.Id == player.GameId, cancellationToken); - if (await _teamService.IsAtGamespaceLimit(player.TeamId, game, cancellationToken)) + var teamActiveChallenges = await _teamService.GetChallengesWithActiveGamespace(player.TeamId, game.Id, cancellationToken); + var activePlusPendingChallengeCount = teamActiveChallenges.Count() + GetDeployingChallengeCount(player.TeamId); + if (activePlusPendingChallengeCount >= game.GamespaceLimitPerSession) + { throw new GamespaceLimitReached(game.Id, player.TeamId); + } if (!await IsUnlocked(player, game, model.SpecId)) + { throw new ChallengeLocked(); + } // if we're outside the execution window, we need to be sure the acting person is an admin if (game.IsCompetitionMode && now > game.GameEnd) @@ -116,22 +129,28 @@ public async Task Create(NewChallenge model, string actorId, string g throw new CantStartBecauseGameExecutionPeriodIsOver(model.SpecId, model.PlayerId, game.GameEnd, now); } - var lockkey = $"{player.TeamId}{model.SpecId}"; - var lockval = _guids.Generate(); - var locked = _memCache.GetOrCreate(lockkey, entry => + _launchCache.EnsureKey(player.TeamId, new ChallengeLaunchCacheEntry { - entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60); - return lockval; + TeamId = player.TeamId, + Specs = [] }); - if (locked != lockval) + _launchCache.TryGetValue(player.TeamId, out var entry); + + if (entry.Specs.Any(s => s.SpecId == model.SpecId)) + { throw new ChallengeStartPending(); + } + else + { + entry.Specs.Add(new ChallengeLaunchCacheEntrySpec { GameId = game.Id, SpecId = model.SpecId }); + } var spec = await _store .WithNoTracking() .SingleAsync(s => s.Id == model.SpecId, cancellationToken); - int playerCount = 1; + var playerCount = 1; if (game.AllowTeam) { playerCount = await _store @@ -150,12 +169,12 @@ public async Task Create(NewChallenge model, string actorId, string g } catch (Exception ex) { - Logger.LogWarning(message: $"Challenge registration failure: {ex.GetType().Name} -- {ex.Message}"); + Logger.LogWarning(message: "Challenge registration failure: {exName} -- {exMessage}", ex.GetType().Name, ex.Message); throw; } finally { - _memCache.Remove(lockkey); + entry.Specs = entry.Specs.Where(s => s.SpecId != model.SpecId).ToList(); } } @@ -277,12 +296,14 @@ public async Task ListArchived(SearchFilter model) if (model.Term.NotEmpty()) { var term = model.Term.ToLower(); - q = q.Where(c => - c.Id.StartsWith(term) || // Challenge Id - c.Tag.ToLower().StartsWith(term) || // Challenge Tag - c.UserId.StartsWith(term) || // User Id - c.Name.ToLower().Contains(term) || // Challenge Title - c.PlayerName.ToLower().Contains(term) // Team Name (or indiv. Player Name) + q = q.Where + ( + c => + c.Id.StartsWith(term) || // Challenge Id + c.Tag.ToLower().StartsWith(term) || // Challenge Tag + c.UserId.StartsWith(term) || // User Id + c.Name.ToLower().Contains(term) || // Challenge Title + c.PlayerName.ToLower().Contains(term) // Team Name (or indiv. Player Name) ); } @@ -470,7 +491,7 @@ public async Task Regrade(string id) // load and regrade var challenge = await _challengeStore.Retrieve(id); // preserve the score prior to regrade - double currentScore = challenge.Score; + var currentScore = challenge.Score; // who's regrading? var actingUserId = _actingUserService.Get()?.Id; @@ -533,7 +554,7 @@ private async Task ArchiveChallenges(IEnumerable challenges) if (challenges == null || !challenges.Any()) return; - Logger.LogInformation($"Archiving {challenges.Count()} challenges."); + Logger.LogInformation("Archiving {challengeCount} challenges.", challenges.Count()); var toArchiveIds = challenges.Select(c => c.Id).ToArray(); var teamMemberMap = await _store @@ -556,12 +577,12 @@ private async Task ArchiveChallenges(IEnumerable challenges) } catch (Exception ex) { - Logger.LogWarning($"Exception thrown during attempted cleanup of gamespace (type: {ex.GetType().Name}, message: {ex.Message})"); + Logger.LogWarning("Exception thrown during attempted cleanup of gamespace (type: {exType}, message: {message})", ex.GetType().Name, ex.Message); } var mappedChallenge = _mapper.Map(challenge); mappedChallenge.Submissions = submissions; - mappedChallenge.TeamMembers = teamMemberMap.TryGetValue(challenge.TeamId, out List value) ? value.ToArray() : []; + mappedChallenge.TeamMembers = teamMemberMap.TryGetValue(challenge.TeamId, out List value) ? [.. value] : []; return mappedChallenge; }).ToArray(); diff --git a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecMapper.cs b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecMapper.cs index 1b2a0509..f25d077f 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecMapper.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecMapper.cs @@ -37,10 +37,10 @@ public ChallengeSpecMapper() private static partial Regex TagsSplitRegex(); // EF advises to make this mapping a static method to avoid memory leaks - private static IEnumerable StringTagsToEnumerableStringTags(string tagsIn) + public static IEnumerable StringTagsToEnumerableStringTags(string tagsIn) { if (tagsIn.IsEmpty()) - return Array.Empty(); + return []; return TagsSplitRegex().Split(tagsIn); } diff --git a/src/Gameboard.Api/Features/Game/GameExceptions.cs b/src/Gameboard.Api/Features/Game/GameExceptions.cs index 28ee8f0c..7e687efc 100644 --- a/src/Gameboard.Api/Features/Game/GameExceptions.cs +++ b/src/Gameboard.Api/Features/Game/GameExceptions.cs @@ -87,7 +87,14 @@ public PracticeSessionLimitReached() : base($"No practice sessions are available internal class SessionLimitReached : GameboardValidationException { - public SessionLimitReached(string teamId, string gameId, int sessions, int sessionLimit) : base($"Can't start a new game ({gameId}) for team \"{teamId}\". The session limit is {sessionLimit}, and the team has {sessions} sessions.") { } + public SessionLimitReached(string teamId, SimpleEntity game, int sessions, int sessionLimit) + : base($"""Can't start a new "{game.Name}" session for for team \"{teamId}\". The session limit is {sessionLimit}, and the team has {sessions} sessions.""") { } +} + +internal class GameSessionLimitReached : GameboardValidationException +{ + public GameSessionLimitReached(SimpleEntity game, int sessionLimit, int currentSessionCount) + : base($"""Can't start new session(s) for "{game.Name}". There are {currentSessionCount} session active, and its limit is {sessionLimit}.""") { } } internal class SpecNotFound : GameboardException diff --git a/src/Gameboard.Api/Features/Game/GameService.cs b/src/Gameboard.Api/Features/Game/GameService.cs index 561ed15d..3f30aec8 100644 --- a/src/Gameboard.Api/Features/Game/GameService.cs +++ b/src/Gameboard.Api/Features/Game/GameService.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -14,8 +16,6 @@ using Gameboard.Api.Common.Services; using Microsoft.AspNetCore.Http; using Gameboard.Api.Data; -using System.IO; -using System.Threading; using Gameboard.Api.Features.Users; namespace Gameboard.Api.Services; @@ -112,15 +112,14 @@ public async Task> GetTeamsWithActiveSession(string gameId, { var gameSessionData = await _store .WithNoTracking() - .Include(g => g.Players) .Where(g => g.Id == gameId) - .Where(g => g.Players.Any(p => _now.Get() < p.SessionEnd)) .Select(g => new { g.Id, g.SessionLimit, Teams = g .Players + .Where(p => _now.Get() < p.SessionEnd) .Select(p => p.TeamId) .Distinct() }) diff --git a/src/Gameboard.Api/Features/Game/Requests/GetGamePlayState/GetGamePlayState.cs b/src/Gameboard.Api/Features/Game/Requests/GetGamePlayState/GetGamePlayState.cs index 7dd730ec..67cfb04d 100644 --- a/src/Gameboard.Api/Features/Game/Requests/GetGamePlayState/GetGamePlayState.cs +++ b/src/Gameboard.Api/Features/Game/Requests/GetGamePlayState/GetGamePlayState.cs @@ -48,7 +48,9 @@ await _validatorService .AddValidator((req, ctx) => { if (gameId.IsEmpty()) - ctx.AddValidationException(new TeamHasNoPlayersException(request.TeamId)); + { + throw new ResourceNotFound(request.TeamId); + } }) .Validate(request, cancellationToken); diff --git a/src/Gameboard.Api/Features/Player/PlayerController.cs b/src/Gameboard.Api/Features/Player/PlayerController.cs index fbda0e65..78926f08 100644 --- a/src/Gameboard.Api/Features/Player/PlayerController.cs +++ b/src/Gameboard.Api/Features/Player/PlayerController.cs @@ -21,7 +21,8 @@ namespace Gameboard.Api.Controllers; [Authorize] -public class PlayerController( +public class PlayerController +( IActingUserService actingUserService, ILogger logger, IDistributedCache cache, @@ -31,7 +32,7 @@ public class PlayerController( IMapper _mapper, IUserRolePermissionsService permissionsService, ITeamService teamService - ) : GameboardLegacyController(actingUserService, logger, cache, validator) +) : GameboardLegacyController(actingUserService, logger, cache, validator) { private readonly IMapper Mapper = _mapper; private readonly IMediator _mediator = mediator; @@ -86,23 +87,23 @@ public async Task Update([FromBody] ChangedPlayer model) { await AuthorizeAny ( - () => _permissionsService.IsActingUserAsync(model.Id), + () => IsSelf(model.Id), () => _permissionsService.Can(PermissionKey.Teams_ApproveNameChanges) ); await Validate(model); - var result = await PlayerService.Update(model, Actor, await _permissionsService.Can(PermissionKey.Teams_ApproveNameChanges)); + var result = await PlayerService.Update(model, Actor); return Mapper.Map(result); } - [HttpPut("api/player/{playerId}/ready")] [Authorize] + [HttpPut("api/player/{playerId}/ready")] public Task UpdatePlayerReady([FromRoute] string playerId, [FromBody] PlayerReadyUpdate readyUpdate) => _mediator.Send(new UpdatePlayerReadyStateCommand(playerId, readyUpdate.IsReady, Actor)); - [HttpPut("api/player/{playerId}/start")] [Authorize] + [HttpPut("api/player/{playerId}/start")] public async Task Start(string playerId) { await AuthorizeAny @@ -120,12 +121,11 @@ await AuthorizeAny /// Delete a player enrollment /// /// - /// /// /// - [HttpDelete("/api/player/{playerId}")] [Authorize] - public async Task Unenroll([FromRoute] string playerId, [FromQuery] bool asAdmin, CancellationToken cancellationToken) + [HttpDelete("/api/player/{playerId}")] + public async Task Unenroll([FromRoute] string playerId, CancellationToken cancellationToken) { await AuthorizeAny ( @@ -233,13 +233,13 @@ public async Task PromoteToCaptain(string teamId, string playerId, [FromBody] Pr await AuthorizeAny ( () => _permissionsService.Can(PermissionKey.Teams_Enroll), - () => IsSelf(promoteRequest.CurrentManagerPlayerId) + () => IsSelf(promoteRequest.CurrentCaptainId) ); var model = new PromoteToManagerRequest { Actor = Actor, - CurrentManagerPlayerId = promoteRequest.CurrentManagerPlayerId, + CurrentCaptainId = promoteRequest.CurrentCaptainId, NewManagerPlayerId = playerId, TeamId = teamId }; diff --git a/src/Gameboard.Api/Features/Player/PlayerExceptions.cs b/src/Gameboard.Api/Features/Player/PlayerExceptions.cs index 92a2e4c1..29620fb9 100644 --- a/src/Gameboard.Api/Features/Player/PlayerExceptions.cs +++ b/src/Gameboard.Api/Features/Player/PlayerExceptions.cs @@ -83,6 +83,7 @@ internal RegistrationIsClosed(string gameId, string addlMessage = null) : internal class SessionAlreadyStarted : GameboardValidationException { internal SessionAlreadyStarted(string playerId, string why) : base($"Player {playerId}'s session was started. {why}.") { } + internal SessionAlreadyStarted(string teamId) : base($"Session for team {teamId} already started.") { } } internal class SessionNotActive : GameboardException diff --git a/src/Gameboard.Api/Features/Player/PlayerMapper.cs b/src/Gameboard.Api/Features/Player/PlayerMapper.cs index 44a35f62..80955f5e 100644 --- a/src/Gameboard.Api/Features/Player/PlayerMapper.cs +++ b/src/Gameboard.Api/Features/Player/PlayerMapper.cs @@ -44,14 +44,22 @@ public PlayerMapper() .ForMember(vm => vm.PreUpdateName, opts => opts.Ignore()); CreateMap() - .AfterMap((player, team) => team.Members = new List + .AfterMap((player, team) => { - new() + team.Members = + [ + new() + { + Id = player.Id, + ApprovedName = player.ApprovedName, + Role = player.Role, + UserId = player.UserId + } + ]; + + if (team.Members.Any() && !team.Members.Any(p => p.Role == PlayerRole.Manager)) { - Id = player.Id, - ApprovedName = player.ApprovedName, - Role = player.Role, - UserId = player.UserId + team.Members.OrderBy(p => p.ApprovedName).First().Role = PlayerRole.Manager; } }); } diff --git a/src/Gameboard.Api/Features/Player/PlayerValidator.cs b/src/Gameboard.Api/Features/Player/PlayerValidator.cs index cbe887b4..8f6b8ec0 100644 --- a/src/Gameboard.Api/Features/Player/PlayerValidator.cs +++ b/src/Gameboard.Api/Features/Player/PlayerValidator.cs @@ -133,11 +133,11 @@ private async Task _validate(PromoteToManagerRequest model) // INDEPENDENT OF ADMIN var currentManager = await _store .WithNoTracking() - .SingleOrDefaultAsync(p => p.Id == model.CurrentManagerPlayerId) - ?? throw new ResourceNotFound(model.CurrentManagerPlayerId, $"Couldn't resolve the player record for current manager {model.CurrentManagerPlayerId}."); + .SingleOrDefaultAsync(p => p.Id == model.CurrentCaptainId) + ?? throw new ResourceNotFound(model.CurrentCaptainId, $"Couldn't resolve the player record for current manager {model.CurrentCaptainId}."); if (!currentManager.IsManager) - throw new PlayerIsntManager(model.CurrentManagerPlayerId, "Calls to this endpoint must supply the correct ID of the current manager."); + throw new PlayerIsntManager(model.CurrentCaptainId, "Calls to this endpoint must supply the correct ID of the current manager."); var newManager = await _store .WithNoTracking() diff --git a/src/Gameboard.Api/Features/Player/Services/PlayerService.cs b/src/Gameboard.Api/Features/Player/Services/PlayerService.cs index 0a64ac8f..aab37897 100644 --- a/src/Gameboard.Api/Features/Player/Services/PlayerService.cs +++ b/src/Gameboard.Api/Features/Player/Services/PlayerService.cs @@ -163,19 +163,17 @@ public async Task Retrieve(string id) return _mapper.Map(await _store.WithNoTracking().SingleAsync(p => p.Id == id)); } - public async Task Update(ChangedPlayer model, User actor, bool sudo = false) + public async Task Update(ChangedPlayer model, User actor) { var player = await _store .WithNoTracking() .SingleAsync(p => p.Id == model.Id); var prev = _mapper.Map(player); - if (!sudo) + // people with the appropriate permissions can hard-set their names + if (!await _permissionsService.Can(PermissionKey.Teams_ApproveNameChanges)) { - _mapper.Map( - _mapper.Map(model), - player - ); + _mapper.Map(_mapper.Map(model), player); } else { @@ -189,13 +187,15 @@ public async Task Update(ChangedPlayer model, User actor, bool sudo = fa if (prev.Name != player.Name) { // check uniqueness - bool found = await _store + var found = await _store .WithNoTracking() - .AnyAsync(p => - p.GameId == player.GameId && - p.TeamId != player.TeamId && - p.Name == player.Name - ); + .AnyAsync + ( + p => + p.GameId == player.GameId && + p.TeamId != player.TeamId && + (p.Name == player.Name || p.ApprovedName == player.Name) + ); if (found) player.NameStatus = AppConstants.NameStatusNotUnique; @@ -264,36 +264,6 @@ public async Task List(PlayerDataFilter model, bool sudo = false) return players; } - public async Task Standings(PlayerDataFilter model) - { - if (model.gid.IsEmpty()) - return []; - - model.Filter = [.. model.Filter, PlayerDataFilter.FilterScoredOnly]; - model.mode = PlayerMode.Competition.ToString(); - var q = BuildListQuery(model); - var standings = await _mapper.ProjectTo(q).ToArrayAsync(); - - // as a temporary workaround until we get the new scoreboard, we need to manually - // set the Sponsors property to accommodate multisponsor teams. - var allTeamIds = standings.Select(s => s.TeamId); - var allSponsors = await _store.WithNoTracking() - .ToDictionaryAsync(s => s.Id, s => s); - - var teamsWithSponsors = await _store - .WithNoTracking() - .Where(p => allTeamIds.Contains(p.TeamId)) - .GroupBy(p => p.TeamId) - .ToDictionaryAsync(g => g.Key, g => g.Select(p => p.SponsorId).ToArray()); - - foreach (var standing in standings) - { - var distinctSponsors = teamsWithSponsors[standing.TeamId].Distinct().Select(s => allSponsors[s]); - standing.TeamSponsors = _mapper.Map(distinctSponsors); - } - return standings; - } - private IQueryable BuildListQuery(PlayerDataFilter model) { var ts = _now.Get(); @@ -303,7 +273,7 @@ public async Task Standings(PlayerDataFilter model) .Include(p => p.User) .Include(p => p.Sponsor) .Include(p => p.AdvancedFromGame) - .AsNoTracking(); + .AsQueryable(); if (model.WantsMode) q = q.Where(p => p.Mode == Enum.Parse(model.mode, true)); @@ -443,11 +413,11 @@ public async Task GenerateInvitation(string id) if (player.Role != PlayerRole.Manager) throw new ActionForbidden(); - byte[] buffer = new byte[16]; - + var buffer = new byte[16]; new Random().NextBytes(buffer); - var code = Convert.ToBase64String(buffer) + var code = Convert + .ToBase64String(buffer) .Replace("+", string.Empty) .Replace("/", string.Empty) .Replace("=", string.Empty); diff --git a/src/Gameboard.Api/Features/Practice/PracticeModels.cs b/src/Gameboard.Api/Features/Practice/PracticeModels.cs index 2ae9ac7d..22e3244f 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeModels.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeModels.cs @@ -20,11 +20,6 @@ public sealed class PracticeSession public required string UserId { get; set; } } -public sealed class SearchPracticeChallengesResult -{ - public required PagedEnumerable Results { get; set; } -} - public sealed class PracticeModeSettingsApiModel { public int? AttemptLimit { get; set; } diff --git a/src/Gameboard.Api/Features/Practice/SearchPracticeChallenges.cs b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs similarity index 69% rename from src/Gameboard.Api/Features/Practice/SearchPracticeChallenges.cs rename to src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs index 8ff275ad..2c9fa02a 100644 --- a/src/Gameboard.Api/Features/Practice/SearchPracticeChallenges.cs +++ b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs @@ -2,10 +2,11 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using AutoMapper; using Gameboard.Api.Common.Services; using Gameboard.Api.Data; using Gameboard.Api.Features.Challenges; +using Gameboard.Api.Features.Users; +using Gameboard.Api.Services; using MediatR; using Microsoft.EntityFrameworkCore; @@ -16,16 +17,16 @@ public record SearchPracticeChallengesQuery(SearchFilter Filter) : IRequest { private readonly IChallengeDocsService _challengeDocsService = challengeDocsService; - private readonly IMapper _mapper = mapper; private readonly IPagingService _pagingService = pagingService; + private readonly IUserRolePermissionsService _permissionsService = permissionsService; private readonly IPracticeService _practiceService = practiceService; private readonly ISlugService _slugger = slugger; private readonly IStore _store = store; @@ -36,8 +37,27 @@ public async Task Handle(SearchPracticeChallenge var settings = await _practiceService.GetSettings(cancellationToken); var sluggedSuggestedSearches = settings.SuggestedSearches.Select(search => _slugger.Get(search)); - var query = BuildQuery(request.Filter.Term, sluggedSuggestedSearches); - var results = await _mapper.ProjectTo(query).ToArrayAsync(cancellationToken); + var query = await BuildQuery(request.Filter.Term, sluggedSuggestedSearches); + var results = await query + .Select(s => new PracticeChallengeView + { + Id = s.Id, + Name = s.Name, + Description = s.Description, + Text = s.Text, + AverageDeploySeconds = s.AverageDeploySeconds, + IsHidden = s.IsHidden, + SolutionGuideUrl = s.SolutionGuideUrl, + Tags = ChallengeSpecMapper.StringTagsToEnumerableStringTags(s.Tags), + Game = new PracticeChallengeViewGame + { + Id = s.Game.Id, + Name = s.Game.Name, + Logo = s.Game.Logo, + IsHidden = !s.Game.IsPublished + } + }) + .ToArrayAsync(cancellationToken); foreach (var result in results) { @@ -69,14 +89,23 @@ public async Task Handle(SearchPracticeChallenge /// /// /// - internal IQueryable BuildQuery(string filterTerm, IEnumerable sluggedSuggestedSearches) + internal async Task> BuildQuery(string filterTerm, IEnumerable sluggedSuggestedSearches) { + var canViewHidden = await _permissionsService.Can(PermissionKey.Games_ViewUnpublished); + var q = _store .WithNoTracking() .Include(s => s.Game) .Where(s => s.Game.PlayerMode == PlayerMode.Practice) - .Where(s => !s.Disabled) - .Where(s => !s.IsHidden); + .Where(s => !s.Disabled); + + if (!canViewHidden) + { + // without the permission, neither spec nor the game can be hidden + q = q + .Where(s => !s.IsHidden) + .Where(s => s.Game.IsPublished); + } if (filterTerm.IsNotEmpty()) { diff --git a/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallengesModels.cs b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallengesModels.cs new file mode 100644 index 00000000..0007c035 --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallengesModels.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace Gameboard.Api.Features.Practice; + +public sealed class SearchPracticeChallengesResult +{ + public required PagedEnumerable Results { get; set; } +} + +public sealed class PracticeChallengeView +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string Description { get; set; } + public required string Text { get; set; } + public required PracticeChallengeViewGame Game { get; set; } + public required int AverageDeploySeconds { get; set; } + public required bool IsHidden { get; set; } + public required string SolutionGuideUrl { get; set; } + public required IEnumerable Tags { get; set; } +} + +public sealed class PracticeChallengeViewGame +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string Logo { get; set; } + public required bool IsHidden { get; set; } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportService.cs index 95d8db47..2abd7dc3 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportService.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportService.cs @@ -2,7 +2,6 @@ using System.Linq; using Gameboard.Api.Data; using Microsoft.EntityFrameworkCore; -using ServiceStack; namespace Gameboard.Api.Features.Reports; @@ -11,16 +10,10 @@ public interface IPlayersReportService IQueryable GetQuery(PlayersReportParameters parameters); } -internal class PlayersReportService : IPlayersReportService +internal class PlayersReportService(IReportsService reportsService, IStore store) : IPlayersReportService { - private readonly IReportsService _reportsService; - private readonly IStore _store; - - public PlayersReportService(IReportsService reportsService, IStore store) - { - _reportsService = reportsService; - _store = store; - } + private readonly IReportsService _reportsService = reportsService; + private readonly IStore _store = store; public IQueryable GetQuery(PlayersReportParameters parameters) { diff --git a/src/Gameboard.Api/Features/Teams/Requests/AddPlayerToTeam/AddPlayerToTeam.cs b/src/Gameboard.Api/Features/Teams/Requests/AddPlayerToTeam/AddPlayerToTeam.cs index 5f097ff1..48eb6867 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/AddPlayerToTeam/AddPlayerToTeam.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/AddPlayerToTeam/AddPlayerToTeam.cs @@ -17,7 +17,8 @@ namespace Gameboard.Api.Features.Teams; public record AddPlayerToTeamCommand(string PlayerId, string InvitationCode) : IRequest; -internal class AddPlayerToTeamHandler( +internal class AddPlayerToTeamHandler +( IActingUserService actingUserService, IInternalHubBus hubBus, IMapper mapper, @@ -27,7 +28,8 @@ internal class AddPlayerToTeamHandler( EntityExistsValidator playerExists, IStore store, ITeamService teamService, - IValidatorService validatorService) : IRequestHandler + IValidatorService validatorService +) : IRequestHandler { private readonly IActingUserService _actingUserService = actingUserService; private readonly IInternalHubBus _hubBus = hubBus; diff --git a/src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeam.cs b/src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeam.cs new file mode 100644 index 00000000..4bc67e58 --- /dev/null +++ b/src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeam.cs @@ -0,0 +1,137 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common.Services; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Games; +using Gameboard.Api.Features.Player; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using MediatR; +using Microsoft.EntityFrameworkCore; +using ServiceStack; + +namespace Gameboard.Api.Features.Teams; + +public record AddToTeamCommand(string TeamId, string UserId) : IRequest; + +internal sealed class AddToTeamCommandHandler +( + IActingUserService actingUser, + PlayerService playerService, + IStore store, + ITeamService teamService, + IValidatorService validator +) : IRequestHandler +{ + private readonly IActingUserService _actingUser = actingUser; + private readonly PlayerService _playerService = playerService; + private readonly IStore _store = store; + private readonly ITeamService _teamService = teamService; + private readonly IValidatorService _validator = validator; + + public async Task Handle(AddToTeamCommand request, CancellationToken cancellationToken) + { + await _validator + .Auth + ( + c => c + .RequirePermissions(Users.PermissionKey.Teams_Enroll) + .Unless + ( + async () => await _store + .WithNoTracking() + .Where(p => p.TeamId == request.TeamId && p.Role == PlayerRole.Manager) + .Where(p => p.UserId == _actingUser.Get().Id) + .AnyAsync(cancellationToken) + ) + ) + .AddValidator(async ctx => + { + // team hasn't started playing + var team = await _teamService.GetTeam(request.TeamId); + if (team.SessionBegin.IsNotEmpty()) + { + ctx.AddValidationException(new SessionAlreadyStarted(request.TeamId)); + } + + // team's current roster has to be < max + var gameId = await _teamService.GetGameId(request.TeamId, cancellationToken); + var maxTeamSize = await _store + .WithNoTracking() + .Where(g => g.Id == gameId) + .Select(g => g.MaxTeamSize) + .SingleAsync(cancellationToken); + + if (team.Members.Count() >= maxTeamSize) + { + ctx.AddValidationException(new TeamIsFull(new SimpleEntity { Id = team.TeamId, Name = team.ApprovedName }, team.Members.Count(), maxTeamSize)); + } + + // if the player is joining a competitive team, they can't have played this game + // competitively before + if (team.Mode == PlayerMode.Competition) + { + var priorPlayer = await _store + .WithNoTracking() + .Where(p => p.UserId == request.UserId && p.TeamId != team.TeamId) + .Where(p => p.GameId == team.GameId) + .Where(p => p.Mode == PlayerMode.Competition) + .WhereDateIsNotEmpty(p => p.SessionBegin) + .Select(p => new + { + p.Id, + p.ApprovedName, + Game = new SimpleEntity { Id = p.GameId, Name = p.Game.Name }, + User = new SimpleEntity { Id = p.UserId, Name = p.User.ApprovedName }, + p.SessionBegin, + p.TeamId + }) + .SingleOrDefaultAsync(cancellationToken); + + if (priorPlayer is not null) + { + ctx.AddValidationException(new UserAlreadyPlayed(priorPlayer.User, priorPlayer.Game, priorPlayer.TeamId, priorPlayer.SessionBegin)); + } + } + }) + // + .Validate(cancellationToken); + + // first find the team they're meant to join + var team = await _teamService.GetTeam(request.TeamId); + + // first ensure the person is enrolled + var existingPlayerId = await _store + .WithNoTracking() + .Where(p => p.UserId == request.UserId) + .Where(p => p.TeamId != request.TeamId) + .WhereDateIsEmpty(p => p.SessionBegin) + .Where(p => p.GameId == team.GameId) + .Select(p => p.Id) + .SingleOrDefaultAsync(cancellationToken); + + if (existingPlayerId.IsEmpty()) + { + var existingPlayer = await _playerService.Enroll + ( + new NewPlayer { GameId = team.GameId, UserId = request.UserId }, + _actingUser.Get(), + cancellationToken + ); + + existingPlayerId = existingPlayer.Id; + } + + var players = await _teamService.AddPlayers(request.TeamId, cancellationToken, existingPlayerId); + var addedPlayer = players.Single(); + + return new AddToTeamResponse + { + Game = new SimpleEntity { Id = addedPlayer.GameId, Name = addedPlayer.GameName }, + Player = new SimpleEntity { Id = addedPlayer.Id, Name = addedPlayer.ApprovedName }, + Team = new SimpleEntity { Id = team.TeamId, Name = team.ApprovedName }, + User = new SimpleEntity { Id = addedPlayer.UserId, Name = addedPlayer.UserApprovedName } + }; + } +} diff --git a/src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeamModels.cs b/src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeamModels.cs new file mode 100644 index 00000000..f3467107 --- /dev/null +++ b/src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeamModels.cs @@ -0,0 +1,18 @@ +using System; +using Gameboard.Api.Structure; + +namespace Gameboard.Api.Features.Teams; + +public sealed class AddToTeamResponse +{ + public required SimpleEntity Game { get; set; } + public required SimpleEntity Player { get; set; } + public required SimpleEntity Team { get; set; } + public required SimpleEntity User { get; set; } +} + +internal sealed class UserAlreadyPlayed : GameboardValidationException +{ + public UserAlreadyPlayed(SimpleEntity user, SimpleEntity game, string teamId, DateTimeOffset sessionStart) + : base($"""User "{user.Name}" already played game {game.Name} on {sessionStart} (team {teamId})""") { } +} diff --git a/src/Gameboard.Api/Features/Teams/Requests/AdminEnrollTeam/AdminEnrollTeam.cs b/src/Gameboard.Api/Features/Teams/Requests/AdminEnrollTeam/AdminEnrollTeam.cs index 71316ee0..1f8094c8 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/AdminEnrollTeam/AdminEnrollTeam.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/AdminEnrollTeam/AdminEnrollTeam.cs @@ -27,32 +27,22 @@ public record AdminEnrollTeamRequest PlayerMode PlayerMode = PlayerMode.Competition ) : IRequest; -internal class AdminEnrollTeamHandler : IRequestHandler +internal class AdminEnrollTeamHandler +( + IActingUserService actingUserService, + IGuidService guids, + PlayerService playerService, + IStore store, + ITeamService teamService, + IGameboardRequestValidator validator +) : IRequestHandler { - private readonly IActingUserService _actingUserService; - private readonly IGuidService _guids; - private readonly PlayerService _playerService; - private readonly IStore _store; - private readonly ITeamService _teamService; - private readonly IGameboardRequestValidator _validator; - - public AdminEnrollTeamHandler - ( - IActingUserService actingUserService, - IGuidService guids, - PlayerService playerService, - IStore store, - ITeamService teamService, - IGameboardRequestValidator validator - ) - { - _actingUserService = actingUserService; - _guids = guids; - _playerService = playerService; - _store = store; - _teamService = teamService; - _validator = validator; - } + private readonly IActingUserService _actingUserService = actingUserService; + private readonly IGuidService _guids = guids; + private readonly PlayerService _playerService = playerService; + private readonly IStore _store = store; + private readonly ITeamService _teamService = teamService; + private readonly IGameboardRequestValidator _validator = validator; public async Task Handle(AdminEnrollTeamRequest request, CancellationToken cancellationToken) { @@ -91,9 +81,13 @@ await _store } // team everyone up - // TODO: kinda yucky. Want to share logic about what it means to be added to a team, but all the validation around + // TODO: kinda yucky. Want to share logic about what it means to be added to a team, but all the validation around that + // is in teamService var playersToAdd = createdPlayers.Where(p => p.Id != captainPlayer.Id).Select(p => p.Id).ToArray(); - await _teamService.AddPlayers(captainPlayer.TeamId, cancellationToken, playersToAdd); + if (playersToAdd.Length > 0) + { + await _teamService.AddPlayers(captainPlayer.TeamId, cancellationToken, playersToAdd); + } // make the captain the actual captain await _teamService.PromoteCaptain(captainPlayer.TeamId, captainPlayer.Id, actingUser, cancellationToken); diff --git a/src/Gameboard.Api/Features/Teams/Requests/RemoveFromTeam/RemoveFromTeam.cs b/src/Gameboard.Api/Features/Teams/Requests/RemoveFromTeam/RemoveFromTeam.cs new file mode 100644 index 00000000..c4f0e2ae --- /dev/null +++ b/src/Gameboard.Api/Features/Teams/Requests/RemoveFromTeam/RemoveFromTeam.cs @@ -0,0 +1,94 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common.Services; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Player; +using Gameboard.Api.Features.Users; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Teams; + +public record RemoveFromTeamCommand(string PlayerId) : IRequest; + +internal sealed class RemoveFromTeamHandler +( + IGuidService guids, + EntityExistsValidator playerExists, + IStore store, + IValidatorService validatorService +) : IRequestHandler +{ + private readonly IGuidService _guids = guids; + private readonly EntityExistsValidator _playerExists = playerExists; + private readonly IStore _store = store; + private readonly IValidatorService _validator = validatorService; + + public async Task Handle(RemoveFromTeamCommand request, CancellationToken cancellationToken) + { + await _validator + .Auth(c => c.RequirePermissions(PermissionKey.Teams_Enroll)) + .AddValidator(_playerExists.UseValue(request.PlayerId)) + .AddValidator(async ctx => + { + var playerData = await _store + .WithNoTracking() + .Where(p => p.Id == request.PlayerId) + .Select(p => new + { + p.Id, + p.ApprovedName, + p.SessionBegin, + p.Role, + p.TeamId + }) + .SingleOrDefaultAsync(cancellationToken); + + // if they started the session already, tough nuggets + if (!playerData.SessionBegin.IsEmpty()) + { + ctx.AddValidationException(new SessionAlreadyStarted(request.PlayerId, "This player can't be removed from the team.")); + } + + // you can't remove the captain (unenroll them instead) + if (playerData.Role == PlayerRole.Manager) + { + ctx.AddValidationException(new CantRemoveCaptain(new SimpleEntity { Id = playerData.Id, Name = playerData.ApprovedName }, playerData.TeamId)); + } + + // in theory the last remaining player should be the captain and should get caught by above, + // but because the schema is weird (shoutout #553), check anyway + var hasRemainingTeammates = await _store + .WithNoTracking() + .Where(p => p.TeamId == playerData.TeamId) + .Where(p => p.Id != request.PlayerId) + .AnyAsync(cancellationToken); + + if (!hasRemainingTeammates) + { + ctx.AddValidationException(new CantRemoveLastTeamMember(new SimpleEntity { Id = playerData.Id, Name = playerData.ApprovedName }, playerData.TeamId)); + } + }) + .Validate(cancellationToken); + + await _store + .WithNoTracking() + .Where(p => p.Id == request.PlayerId) + .ExecuteUpdateAsync(up => up.SetProperty(p => p.TeamId, _guids.Generate()), cancellationToken); + + return await _store + .WithNoTracking() + .Where(p => p.Id == request.PlayerId) + .Select(p => new RemoveFromTeamResponse + { + Player = new SimpleEntity { Id = p.Id, Name = p.ApprovedName }, + Game = new SimpleEntity { Id = p.GameId, Name = p.Game.Name }, + TeamId = p.TeamId, + UserId = new SimpleEntity { Id = p.UserId, Name = p.User.ApprovedName } + }) + .SingleAsync(cancellationToken); + } +} diff --git a/src/Gameboard.Api/Features/Teams/Requests/RemoveFromTeam/RemoveFromTeamModels.cs b/src/Gameboard.Api/Features/Teams/Requests/RemoveFromTeam/RemoveFromTeamModels.cs new file mode 100644 index 00000000..21ad9e98 --- /dev/null +++ b/src/Gameboard.Api/Features/Teams/Requests/RemoveFromTeam/RemoveFromTeamModels.cs @@ -0,0 +1,9 @@ +namespace Gameboard.Api.Features.Teams; + +public record RemoveFromTeamResponse +{ + public required SimpleEntity Game { get; set; } + public required SimpleEntity Player { get; set; } + public required string TeamId { get; set; } + public required SimpleEntity UserId { get; set; } +} diff --git a/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessionsValidator.cs b/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessionsValidator.cs index 0f2926ae..aff68d6b 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessionsValidator.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessionsValidator.cs @@ -117,6 +117,11 @@ public async Task Validate(StartTeamSessionsCommand request, CancellationToken c if (team.Value.Length < game.MinTeamSize || team.Value.Length > game.MaxTeamSize) ctx.AddValidationException(new InvalidTeamSize(team.Key, team.Value.Length, game.MinTeamSize, game.MaxTeamSize)); + // NOTE: this currently doesn't care about the team's challenge count. while it really should, + // we super need to refactor the team session start path into this. right now, team service + // knows how to determine gamespace limit, and only challenge service knows about pending challenge starts + // 🙄🙄🙄 + // can only play active games if (game.GameStart.IsEmpty() || game.GameStart > now || game.GameEnd < now) ctx.AddValidationException(new GameNotActive(game.Id, game.GameStart, game.GameEnd)); @@ -124,7 +129,9 @@ public async Task Validate(StartTeamSessionsCommand request, CancellationToken c // can't exceed the legal session limit if established var activeSessions = await _gameService.GetTeamsWithActiveSession(game.Id, cancellationToken); if (game.SessionLimit > 0 && activeSessions.Count() >= game.SessionLimit) - ctx.AddValidationException(new SessionLimitReached(request.TeamIds.First(), game.Id, activeSessions.Count(), game.SessionLimit)); + { + ctx.AddValidationException(new GameSessionLimitReached(new SimpleEntity { Id = game.Id, Name = game.Name }, game.SessionLimit, activeSessions.Count())); + } // can't start late if late start disabled var sessionWindow = _sessionWindow.Calculate(game.SessionMinutes, game.GameEnd, await _permissionsService.Can(PermissionKey.Play_IgnoreExecutionWindow), now); diff --git a/src/Gameboard.Api/Features/Teams/Services/TeamService.cs b/src/Gameboard.Api/Features/Teams/Services/TeamService.cs index df3559f4..7d24be24 100644 --- a/src/Gameboard.Api/Features/Teams/Services/TeamService.cs +++ b/src/Gameboard.Api/Features/Teams/Services/TeamService.cs @@ -52,7 +52,7 @@ internal class TeamService IInternalHubBus teamHubService, IPracticeService practiceService, IStore store - ) : ITeamService +) : ITeamService { private readonly IActingUserService _actingUserService = actingUserService; private readonly IGameEngineService _gameEngine = gameEngine; @@ -352,7 +352,7 @@ public async Task> GetTeams(IEnumerable ids) .ToDictionaryAsync(gr => gr.Key, gr => gr.ToArray()); if (teamPlayers.Count == 0) - return Array.Empty(); + return []; foreach (var teamId in teamPlayers.Keys) { @@ -408,7 +408,9 @@ public async Task PromoteCaptain(string teamId, string newCaptainPlayerId, User .ToListAsync(cancellationToken); if (teamPlayers.Count == 0) - throw new TeamHasNoPlayersException(teamId); + { + throw new ResourceNotFound(teamId); + } var captainFound = false; foreach (var player in teamPlayers) @@ -441,6 +443,18 @@ public async Task PromoteCaptain(string teamId, string newCaptainPlayerId, User .Where(p => p.TeamId == teamId) .ToArrayAsync(cancellationToken); + // if we get here, something is not awesome - this only can theoretically happen + // because of a database schema flaw. fix it up in the meantime + if (players.Length > 0 && players.Count(p => p.Role == PlayerRole.Manager) != 1) + { + var adjustedCaptain = players.OrderBy(p => p.WhenCreated).First(); + + await _store + .WithNoTracking() + .Where(p => p.TeamId == teamId) + .ExecuteUpdateAsync(up => up.SetProperty(p => p.Role, p => p.Id == adjustedCaptain.Id ? PlayerRole.Manager : PlayerRole.Member)); + } + return ResolveCaptain(players); } @@ -457,15 +471,23 @@ public Data.Player ResolveCaptain(IEnumerable players) // if the team has a captain (manager), yay // if they have too many, boo (pick one by name which is stupid but stupid things happen sometimes) - // if they don't have one, pick by name among all players + // if they don't have one, pick by registration date among all players var captains = players.Where(p => p.IsManager); if (captains.Count() == 1) - return captains.Single(); - else if (captains.Count() > 1) - return captains.OrderBy(c => c.ApprovedName).First(); + { + return captains.First(); + } + { + // ensure we end up with exactly one captain + var captainToPromote = players.OrderBy(p => p.WhenCreated).First(); + foreach (var player in players) + { + player.Role = captainToPromote.Id == player.Id ? PlayerRole.Manager : PlayerRole.Member; + } - return players.OrderBy(p => p.ApprovedName).First(); + return captainToPromote; + } } public async Task> ResolveCaptains(IEnumerable teamIds, CancellationToken cancellationToken) diff --git a/src/Gameboard.Api/Features/Teams/TeamController.cs b/src/Gameboard.Api/Features/Teams/TeamController.cs index 2dcd126f..a244e255 100644 --- a/src/Gameboard.Api/Features/Teams/TeamController.cs +++ b/src/Gameboard.Api/Features/Teams/TeamController.cs @@ -23,6 +23,14 @@ ITeamService teamService private readonly IMediator _mediator = mediator; private readonly ITeamService _teamService = teamService; + [HttpDelete("{teamId}/players/{playerId}")] + public Task RemovePlayer([FromRoute] string teamId, [FromRoute] string playerId) + => _mediator.Send(new RemoveFromTeamCommand(playerId)); + + [HttpPut("{teamId}/players")] + public Task AddUser([FromRoute] string teamId, [FromBody] AddToTeamCommand request) + => _mediator.Send(request); + [HttpGet("{teamId}")] public async Task GetTeam(string teamId) => await _mediator.Send(new GetTeamQuery(teamId, _actingUserService.Get())); diff --git a/src/Gameboard.Api/Features/Teams/TeamExceptions.cs b/src/Gameboard.Api/Features/Teams/TeamExceptions.cs index d26f1732..15ac6034 100644 --- a/src/Gameboard.Api/Features/Teams/TeamExceptions.cs +++ b/src/Gameboard.Api/Features/Teams/TeamExceptions.cs @@ -15,6 +15,18 @@ public CantJoinTeamBecausePlayerCount(string gameId, int playersToJoin, int team : base($"Can't add {playersToJoin} player(s) to the team. This team has {teamSizeCurrent} player(s) (min team size is {teamSizeMin}, max team size is {teamSizeMax}).") { } } +internal class CantRemoveCaptain : GameboardValidationException +{ + public CantRemoveCaptain(SimpleEntity player, string teamId) + : base($"Can't remove player {player.Name} from the team {teamId} - they're the captain.") { } +} + +internal class CantRemoveLastTeamMember : GameboardValidationException +{ + public CantRemoveLastTeamMember(SimpleEntity player, string teamId) + : base($"""Can't remove the last member ("{player.Name}") of a team {teamId}.""") { } +} + internal class CantResolveTeamFromCode : GameboardValidationException { internal CantResolveTeamFromCode(string code, string[] teamIds) @@ -65,11 +77,6 @@ internal RequiresSameSponsor(string gameId, string managerPlayerId, string manag : base($"Game {gameId} requires that all players have the same sponsor. The inviting player {managerPlayerId} has sponsor {managerSponsor}, while player {playerId} has sponsor {playerSponsor}.") { } } -internal class TeamHasNoPlayersException : GameboardValidationException -{ - public TeamHasNoPlayersException(string teamId) : base($"Team {teamId} has no players.") { } -} - internal class TeamsAreFromMultipleGames : GameboardException { public TeamsAreFromMultipleGames(IEnumerable teamIds, IEnumerable gameIds) @@ -78,6 +85,8 @@ public TeamsAreFromMultipleGames(IEnumerable teamIds, IEnumerable Challenges { get; set; } = new List(); - public IEnumerable Members { get; set; } = new List(); - public IEnumerable Sponsors { get; set; } = new List(); + public IEnumerable Challenges { get; set; } = []; + public IEnumerable Members { get; set; } = []; + public IEnumerable Sponsors { get; set; } = []; } public class TeamSummary diff --git a/src/Gameboard.Api/Features/Ticket/TicketController.cs b/src/Gameboard.Api/Features/Ticket/TicketController.cs index 44eb23ae..73d2365e 100644 --- a/src/Gameboard.Api/Features/Ticket/TicketController.cs +++ b/src/Gameboard.Api/Features/Ticket/TicketController.cs @@ -1,7 +1,6 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -using System; using System.Collections.Generic; using System.Threading.Tasks; using AutoMapper; @@ -19,7 +18,8 @@ namespace Gameboard.Api.Controllers; [Authorize] -public class TicketController( +public class TicketController +( IActingUserService actingUserService, ILogger logger, IDistributedCache cache, @@ -29,7 +29,7 @@ public class TicketController( TicketService ticketService, IHubContext hub, IMapper mapper - ) : GameboardLegacyController(actingUserService, logger, cache, validator) +) : GameboardLegacyController(actingUserService, logger, cache, validator) { private readonly IUserRolePermissionsService _permissionsService = permissionsService; TicketService TicketService { get; } = ticketService; @@ -41,10 +41,11 @@ IMapper mapper /// Gets ticket details /// /// + /// The direction in which activity on this ticket will be ordered (by timestamp) /// [HttpGet("api/ticket/{id}")] [Authorize] - public async Task Retrieve([FromRoute] int id) + public async Task Retrieve([FromRoute] int id, [FromQuery] SortDirection sortDirection) { await AuthorizeAny ( @@ -52,7 +53,7 @@ await AuthorizeAny () => TicketService.IsOwnerOrTeamMember(id, Actor.Id) ); - return await TicketService.Retrieve(id); + return await TicketService.Retrieve(id, sortDirection); } diff --git a/src/Gameboard.Api/Features/Ticket/TicketService.cs b/src/Gameboard.Api/Features/Ticket/TicketService.cs index 2e72e322..8bac3fa8 100644 --- a/src/Gameboard.Api/Features/Ticket/TicketService.cs +++ b/src/Gameboard.Api/Features/Ticket/TicketService.cs @@ -53,11 +53,11 @@ ITeamService teamService public string GetFullKey(int key) => $"{(Options.KeyPrefix.IsEmpty() ? "GB" : Options.KeyPrefix)}-{key}"; - public Task Retrieve(string id) - => LoadTicketDto(id); + public Task Retrieve(string id, SortDirection activitySortDirection = SortDirection.Asc) + => LoadTicketDto(id, activitySortDirection); - public Task Retrieve(int id) - => LoadTicketDto(id); + public Task Retrieve(int id, SortDirection activitySortDirection = SortDirection.Asc) + => LoadTicketDto(id, activitySortDirection); public IQueryable BuildTicketSearchQuery(string term) { @@ -150,13 +150,13 @@ await _mediator.Publish(new TicketCreatedNotification return createdTicketModel; } - public IQueryable GetGameOpenTickets(string gameId) - { - return _store + public IQueryable GetGameTicketsQuery(string gameId) + => _store .WithNoTracking() - .Where(t => t.Challenge.GameId == gameId || t.Player.Challenges.Any(c => c.GameId == gameId)) - .Where(t => t.Status != "Closed"); - } + .Where(t => t.Challenge.GameId == gameId || t.Player.Challenges.Any(c => c.GameId == gameId)); + + public IQueryable GetGameOpenTicketsQuery(string gameId) + => GetGameTicketsQuery(gameId).Where(t => t.Status != "Closed"); public IQueryable GetTeamTickets(IEnumerable teamIds) => _store @@ -384,14 +384,6 @@ public async Task ListLabels(SearchFilter model) return b; } - public async Task UserIsEnrolled(string gameId, string userId) - { - return await _store.AnyAsync(u => - u.Id == userId && - u.Enrollments.Any(e => e.GameId == gameId) - , CancellationToken.None); - } - public async Task IsOwnerOrTeamMember(int ticketId, string userId) { var ticket = await _store @@ -444,15 +436,6 @@ public async Task IsOwnerOrTeamMember(string ticketId, string userId) , CancellationToken.None); } - public async Task IsOwner(string ticketId, string userId) - { - return await _store - .WithNoTracking() - .Where(t => t.Id == ticketId) - .Where(t => t.RequesterId == userId) - .AnyAsync(); - } - public async Task UserCanUpdate(string ticketId, string userId) { var ticket = await BuildTicketSearchQuery(ticketId).SingleOrDefaultAsync(); @@ -551,13 +534,13 @@ private void AddActivity(Data.Ticket entity, string actorId, bool statusChanged, } } - private async Task LoadTicketDto(int ticketKey) - => await BuildTicketDto(await BuildTicketQueryBase().SingleOrDefaultAsync(t => t.Key == ticketKey)); + private async Task LoadTicketDto(int ticketKey, SortDirection activitySortDirection = SortDirection.Asc) + => await BuildTicketDto(await BuildTicketQueryBase().SingleOrDefaultAsync(t => t.Key == ticketKey), activitySortDirection); - private async Task LoadTicketDto(string ticketId) - => await BuildTicketDto(await BuildTicketQueryBase().SingleOrDefaultAsync(t => t.Id == ticketId)); + private async Task LoadTicketDto(string ticketId, SortDirection activitySortDirection = SortDirection.Asc) + => await BuildTicketDto(await BuildTicketQueryBase().SingleOrDefaultAsync(t => t.Id == ticketId), activitySortDirection); - private async Task BuildTicketDto(Data.Ticket ticketEntity) + private async Task BuildTicketDto(Data.Ticket ticketEntity, SortDirection activitySortDirection) { var ticket = Mapper.Map(ticketEntity); ticket.FullKey = GetFullKey(ticket.Key); @@ -565,11 +548,22 @@ private async Task BuildTicketDto(Data.Ticket ticketEntity) ticket.Assignee = await BuildTicketUser(ticketEntity.Assignee); ticket.Creator = await BuildTicketUser(ticketEntity.Creator); ticket.Requester = await BuildTicketUser(ticketEntity.Requester); + ticket.TeamName = "(deleted team)"; if (ticket.TeamId.IsNotEmpty()) { - var team = await _teamService.GetTeam(ticket.TeamId); - ticket.TeamName = team.ApprovedName; + // have to do this delicately in case the team is deleted (reset) + // https://github.com/cmu-sei/Gameboard/issues/553 can't come soon enough + var captain = await _store + .WithNoTracking() + .Where(p => p.TeamId == ticket.TeamId) + .OrderBy(p => p.Role == PlayerRole.Manager ? 0 : 1) + .FirstOrDefaultAsync(); + + if (captain is not null) + { + ticket.TeamName = captain.ApprovedName; + } } if (ticket.Player is not null) @@ -582,6 +576,15 @@ private async Task BuildTicketDto(Data.Ticket ticketEntity) ticket.IsTeamGame = ticket.Challenge.AllowTeam; } + if (activitySortDirection == SortDirection.Asc) + { + ticket.Activity = [.. ticket.Activity.OrderBy(a => a.Timestamp)]; + } + else + { + ticket.Activity = [.. ticket.Activity.OrderByDescending(a => a.Timestamp)]; + } + return ticket; } diff --git a/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsService.cs b/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsService.cs index 6ce0e8dc..6e8050c8 100644 --- a/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsService.cs +++ b/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Common.Services; @@ -58,8 +59,8 @@ internal class UserRolePermissionsService(IActingUserService actingUserService, { Group = PermissionKeyGroup.Games, Key = PermissionKey.Games_ViewUnpublished, - Name = "View hidden games", - Description = "View games which have been hidden from players by their creator" + Name = "View hidden games and practice challenges", + Description = "View games and practice challenges which have been hidden from players by their creator" }, new() { @@ -107,8 +108,8 @@ internal class UserRolePermissionsService(IActingUserService actingUserService, { Group = PermissionKeyGroup.Scoring, Key = PermissionKey.Scores_RegradeAndRerank, - Name = "Regrade challenges", - Description = "Manually initiate regrading of challenges" + Name = "Revise scores", + Description = "Manually initiate reranking of games and regrading of challenges" }, new() { diff --git a/src/Gameboard.Api/Features/User/Requests/RequestNameChange/RequestNameChange.cs b/src/Gameboard.Api/Features/User/Requests/RequestNameChange/RequestNameChange.cs index a247f717..1654a625 100644 --- a/src/Gameboard.Api/Features/User/Requests/RequestNameChange/RequestNameChange.cs +++ b/src/Gameboard.Api/Features/User/Requests/RequestNameChange/RequestNameChange.cs @@ -61,7 +61,7 @@ await _validatorService.Auth } else { - finalStatus = canRenameWithoutApproval ? string.Empty : "pending"; + finalStatus = canRenameWithoutApproval ? string.Empty : AppConstants.NameStatusPending; } await _store diff --git a/src/Gameboard.Api/Features/User/UserService.cs b/src/Gameboard.Api/Features/User/UserService.cs index 8700d023..650d28e6 100644 --- a/src/Gameboard.Api/Features/User/UserService.cs +++ b/src/Gameboard.Api/Features/User/UserService.cs @@ -9,6 +9,7 @@ using Gameboard.Api.Common.Services; using Gameboard.Api.Data; using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Features.Teams; using Gameboard.Api.Features.Users; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; @@ -24,6 +25,7 @@ public class UserService IMapper mapper, IMemoryCache cache, INameService namesvc, + ITeamService teamService, IUserRolePermissionsService permissionsService ) { @@ -34,6 +36,7 @@ IUserRolePermissionsService permissionsService private readonly IStore _userStore = userStore; private readonly IMemoryCache _localcache = cache; private readonly INameService _namesvc = namesvc; + private readonly ITeamService _teamService = teamService; private readonly IUserRolePermissionsService _permissionsService = permissionsService; /// @@ -195,7 +198,7 @@ public async Task> List(UserSearch model) where query = query.Where(u => ((int)u.Role) > 0); if (model.WantsPending) - query = query.Where(u => u.NameStatus.Equals(AppConstants.NameStatusPending) && u.Name != u.ApprovedName); + query = query.Where(u => u.Name != u.ApprovedName); if (model.WantsDisallowed) query = query.Where(u => !string.IsNullOrEmpty(u.NameStatus) && !u.NameStatus.Equals(AppConstants.NameStatusPending)); diff --git a/src/Gameboard.Api/Structure/AppSettings.cs b/src/Gameboard.Api/Structure/AppSettings.cs index 4f95eeac..99e0977e 100644 --- a/src/Gameboard.Api/Structure/AppSettings.cs +++ b/src/Gameboard.Api/Structure/AppSettings.cs @@ -122,9 +122,9 @@ public class SecurityHeaderOptions public class CorsPolicyOptions { public string Name { get; set; } = "default"; - public string[] Origins { get; set; } = Array.Empty(); - public string[] Methods { get; set; } = Array.Empty(); - public string[] Headers { get; set; } = Array.Empty(); + public string[] Origins { get; set; } = []; + public string[] Methods { get; set; } = []; + public string[] Headers { get; set; } = []; public bool AllowCredentials { get; set; } public CorsPolicy Build() @@ -132,20 +132,20 @@ public CorsPolicy Build() var policy = new CorsPolicyBuilder(); var origins = Origins.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); - if (origins.Any()) + if (origins.Length > 0) { if (origins.First() == "*") policy.AllowAnyOrigin(); else policy.WithOrigins(origins); if (AllowCredentials && origins.First() != "*") policy.AllowCredentials(); else policy.DisallowCredentials(); } var methods = Methods.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); - if (methods.Any()) + if (methods.Length > 0) { if (methods.First() == "*") policy.AllowAnyMethod(); else policy.WithMethods(methods); } var headers = Headers.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); - if (headers.Any()) + if (headers.Length > 0) { if (headers.First() == "*") policy.AllowAnyHeader(); else policy.WithHeaders(headers); } @@ -220,9 +220,11 @@ public static DateTimeOffset[][] GetShifts(string[][] shiftStrings) // Create a new DateTimeOffset representation for every string time given for (int i = 0; i < shiftStrings.Length; i++) { - offsets[i] = new DateTimeOffset[] { - ConvertTime(shiftStrings[i][0], ShiftTimezoneFallback), - ConvertTime(shiftStrings[i][1], ShiftTimezoneFallback) }; + offsets[i] = + [ + ConvertTime(shiftStrings[i][0], ShiftTimezoneFallback), + ConvertTime(shiftStrings[i][1], ShiftTimezoneFallback) + ]; } return offsets; } From 301fd46b580b739a0f4f10abad66ba372ce6e2d0 Mon Sep 17 00:00:00 2001 From: Ben Stein <115497763+sei-bstein@users.noreply.github.com> Date: Wed, 4 Dec 2024 12:34:10 -0500 Subject: [PATCH 7/7] v3.25.1 (#560) * Fix a bug that could cause the challenges report to throw errors when retrieving performance for some challenges. * Fix a permissions issue with game card image changing * Misc cleanup --- .../GetCompetitiveModeCertificateHtml.cs | 34 ++++++--------- .../GetPracticeModeCertificateHtml.cs | 6 ++- .../Services/ChallengeSyncService.cs | 42 +++++++------------ .../Features/ChallengeSpec/ChallengeSpec.cs | 5 +++ .../ChallengeSpec/ChallengeSpecController.cs | 5 ++- .../ChallengeSpec/ChallengeSpecService.cs | 10 +++-- .../Features/Game/GameService.cs | 4 +- .../Features/Player/Services/PlayerService.cs | 4 +- 8 files changed, 53 insertions(+), 57 deletions(-) diff --git a/src/Gameboard.Api/Features/Certificates/Requests/GetCompetitiveModeCertificateHtml.cs b/src/Gameboard.Api/Features/Certificates/Requests/GetCompetitiveModeCertificateHtml.cs index 33277837..06509068 100644 --- a/src/Gameboard.Api/Features/Certificates/Requests/GetCompetitiveModeCertificateHtml.cs +++ b/src/Gameboard.Api/Features/Certificates/Requests/GetCompetitiveModeCertificateHtml.cs @@ -12,32 +12,24 @@ namespace Gameboard.Api.Features.Certificates; public record GetCompetitiveModeCertificateHtmlQuery(string GameId, string OwnerUserId, string ActingUserId) : IRequest; -internal class GetCompetitiveModeCertificateHtmlHandler : IRequestHandler +internal class GetCompetitiveModeCertificateHtmlHandler +( + EntityExistsValidator gameExists, + PlayerService playerService, + IStore store, + IValidatorService validatorService +) : IRequestHandler { - private readonly EntityExistsValidator _gameExists; - private readonly PlayerService _playerService; - private readonly IStore _store; - private readonly IValidatorService _validatorService; - - public GetCompetitiveModeCertificateHtmlHandler - ( - EntityExistsValidator gameExists, - PlayerService playerService, - IStore store, - IValidatorService validatorService - ) - { - _gameExists = gameExists; - _playerService = playerService; - _store = store; - _validatorService = validatorService; - } + private readonly EntityExistsValidator _gameExists = gameExists; + private readonly PlayerService _playerService = playerService; + private readonly IStore _store = store; + private readonly IValidatorService _validatorService = validatorService; public async Task Handle(GetCompetitiveModeCertificateHtmlQuery request, CancellationToken cancellationToken) { var isPublished = await _store .WithNoTracking() - .AnyAsync(c => c.GameId == request.GameId && c.OwnerUserId == request.OwnerUserId); + .AnyAsync(c => c.GameId == request.GameId && c.OwnerUserId == request.OwnerUserId, cancellationToken); await _validatorService .AddValidator(_gameExists.UseProperty(r => r.GameId)) @@ -54,7 +46,7 @@ await _validatorService .WithNoTracking() .Where(p => p.UserId == request.OwnerUserId) .Where(p => p.GameId == request.GameId) - .FirstAsync(); + .FirstAsync(cancellationToken); var certificate = await _playerService.MakeCertificate(player.Id); return certificate.Html; diff --git a/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificateHtml/GetPracticeModeCertificateHtml.cs b/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificateHtml/GetPracticeModeCertificateHtml.cs index d8fa373c..de533068 100644 --- a/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificateHtml/GetPracticeModeCertificateHtml.cs +++ b/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificateHtml/GetPracticeModeCertificateHtml.cs @@ -49,10 +49,12 @@ await _validatorService .Validate(request, cancellationToken); if (certificate is null) + { throw new ResourceNotFound(request.ChallengeSpecId, $"Couldn't resolve a certificate for owner {request.CertificateOwnerUserId} and challenge spec {request.ChallengeSpecId}"); + } - // load the outer template from this application (this is custom crafted by us to ensure we end up) - // with a consistent HTML-compliant base + // load the outer template from this application (this is custom crafted by us to ensure we end up + // with a consistent HTML-compliant base) var outerTemplatePath = Path.Combine(_coreOptions.TemplatesDirectory, "practice-certificate.template.html"); var outerTemplate = File.ReadAllText(outerTemplatePath); diff --git a/src/Gameboard.Api/Features/Challenge/Services/ChallengeSyncService.cs b/src/Gameboard.Api/Features/Challenge/Services/ChallengeSyncService.cs index e9729b5d..57363868 100644 --- a/src/Gameboard.Api/Features/Challenge/Services/ChallengeSyncService.cs +++ b/src/Gameboard.Api/Features/Challenge/Services/ChallengeSyncService.cs @@ -23,32 +23,22 @@ public interface IChallengeSyncService /// /// Used by the Job service to update challenges which have expired /// -internal class ChallengeSyncService : IChallengeSyncService +internal class ChallengeSyncService +( + ConsoleActorMap consoleActorMap, + IGameEngineService gameEngine, + ILogger logger, + IMapper mapper, + INowService now, + IStore store +) : IChallengeSyncService { - private readonly ConsoleActorMap _consoleActorMap; - private readonly IGameEngineService _gameEngine; - private readonly ILogger _logger; - private readonly IMapper _mapper; - private readonly INowService _now; - private readonly IStore _store; - - public ChallengeSyncService - ( - ConsoleActorMap consoleActorMap, - IGameEngineService gameEngine, - ILogger logger, - IMapper mapper, - INowService now, - IStore store - ) - { - _consoleActorMap = consoleActorMap; - _logger = logger; - _gameEngine = gameEngine; - _mapper = mapper; - _now = now; - _store = store; - } + private readonly ConsoleActorMap _consoleActorMap = consoleActorMap; + private readonly IGameEngineService _gameEngine = gameEngine; + private readonly ILogger _logger = logger; + private readonly IMapper _mapper = mapper; + private readonly INowService _now = now; + private readonly IStore _store = store; public Task Sync(Data.Challenge challenge, GameEngineGameState state, string actingUserId, CancellationToken cancellationToken) => Sync(cancellationToken, new SyncEntry(actingUserId, challenge, state)); @@ -104,7 +94,7 @@ public async Task SyncExpired(CancellationToken cancellationToken) .Where(p => playerIds.Contains(p.Id)) .ToDictionaryAsync(p => p.Id, p => p.SessionEnd, cancellationToken); - _logger.LogInformation($"The ChallengeSyncService is synchronizing {challenges.Count()} challenges..."); + _logger.LogInformation("The ChallengeSyncService is synchronizing {syncCount} challenges...", challenges.Count()); foreach (var challenge in challenges) { try diff --git a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpec.cs b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpec.cs index b396c058..3d8ed4c5 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpec.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpec.cs @@ -81,6 +81,11 @@ public sealed class ChallengeSpecSummary public required IEnumerable Tags { get; set; } } +public sealed class ChallengeSpecSectionPerformance +{ + public required string Text { get; set; } +} + public sealed class ChallengeSpecQuestionPerformance { public required int QuestionRank { get; set; } diff --git a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecController.cs b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecController.cs index a3d42efd..5188b310 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecController.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecController.cs @@ -17,7 +17,8 @@ namespace Gameboard.Api.Controllers; [Authorize] -public class ChallengeSpecController( +public class ChallengeSpecController +( IActingUserService actingUserService, ILogger logger, IDistributedCache cache, @@ -25,7 +26,7 @@ public class ChallengeSpecController( ChallengeSpecValidator validator, ChallengeSpecService challengespecService, IUserRolePermissionsService permissionsService - ) : GameboardLegacyController(actingUserService, logger, cache, validator) +) : GameboardLegacyController(actingUserService, logger, cache, validator) { private readonly IMediator _mediator = mediator; private readonly IUserRolePermissionsService _permissionsService = permissionsService; diff --git a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecService.cs b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecService.cs index c225140a..632d9981 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecService.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecService.cs @@ -117,8 +117,6 @@ public async Task> ListByGame() public async Task> GetQuestionPerformance(string challengeSpecId, CancellationToken cancellationToken) { var results = await GetQuestionPerformance([challengeSpecId], cancellationToken); - if (!results.Any()) - throw new ArgumentException($"Couldn't load performance for specId {challengeSpecId}", nameof(challengeSpecId)); return results[challengeSpecId]; } @@ -151,7 +149,13 @@ public async Task v.Any())) - return null; + { + return challengeSpecIds.ToDictionary + ( + specId => specId, + specId => Array.Empty().OrderBy(c => c.CountCorrect) + ); + } // deserialize states var questionPerformance = new Dictionary>(); diff --git a/src/Gameboard.Api/Features/Game/GameService.cs b/src/Gameboard.Api/Features/Game/GameService.cs index 3f30aec8..e66d4132 100644 --- a/src/Gameboard.Api/Features/Game/GameService.cs +++ b/src/Gameboard.Api/Features/Game/GameService.cs @@ -329,7 +329,9 @@ public async Task DeleteGameCardImage(string gameId) var files = Directory.GetFiles(Options.ImageFolder, fileSearchPattern); foreach (var cardImageFile in files) + { File.Delete(cardImageFile); + } await UpdateImage(gameId, "card", string.Empty); } @@ -342,7 +344,7 @@ public async Task SaveGameCardImage(string gameId, IFormFile file) var fileName = $"{GetGameCardFileNameBase(gameId)}{Path.GetExtension(file.FileName.ToLower())}"; var path = Path.Combine(Options.ImageFolder, fileName); - using var stream = new FileStream(path, FileMode.Create); + using var stream = new FileStream(path, FileMode.OpenOrCreate); await file.CopyToAsync(stream); await UpdateImage(gameId, "card", fileName); diff --git a/src/Gameboard.Api/Features/Player/Services/PlayerService.cs b/src/Gameboard.Api/Features/Player/Services/PlayerService.cs index aab37897..7c535ab4 100644 --- a/src/Gameboard.Api/Features/Player/Services/PlayerService.cs +++ b/src/Gameboard.Api/Features/Player/Services/PlayerService.cs @@ -681,7 +681,7 @@ private PlayerCertificate CertificateFromTemplate(Data.Player player, int player certificateHTML = certificateHTML.Replace("{{player_count}}", playerCount.ToString()); certificateHTML = certificateHTML.Replace("{{team_count}}", teamCount.ToString()); - return new Api.PlayerCertificate + return new PlayerCertificate { Game = _mapper.Map(player.Game), PublishedOn = player.User.PublishedCompetitiveCertificates.FirstOrDefault(c => c.GameId == player.Game.Id)?.PublishedOn, @@ -726,7 +726,7 @@ private async Task RegisterPracticeSession(NewPlayer model, Data.User us { int count = await _store .WithNoTracking() - .CountAsync + .CountAsync ( p => p.Mode == PlayerMode.Practice &&