From ec7f820d4cd38974d64d01b0d96c8ebaf9bbc2c4 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Fri, 24 May 2024 09:38:52 -0400 Subject: [PATCH 01/12] Misc cleanup and add snippets (for fun) --- .vscode/extensions.json | 7 --- .vscode/gameboard.code-snippets | 50 +++++++++++++++++++ .../Features/Games/GameControllerTests.cs | 3 +- .../Game/GameModes/ExternalGameModeService.cs | 2 +- 4 files changed, 53 insertions(+), 9 deletions(-) delete mode 100644 .vscode/extensions.json create mode 100644 .vscode/gameboard.code-snippets diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 6b6ac57b..00000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "recommendations": [ - "ms-azuretools.vscode-docker", - "ms-dotnettools.csharp", - "ms-dotnettools.csdevkit" - ] -} diff --git a/.vscode/gameboard.code-snippets b/.vscode/gameboard.code-snippets new file mode 100644 index 00000000..8604fdee --- /dev/null +++ b/.vscode/gameboard.code-snippets @@ -0,0 +1,50 @@ +{ + // see https://code.visualstudio.com/docs/editor/userdefinedsnippets + "Create Gameboard Unit Test Suite": { + "scope": "csharp", + "description": "Create a Gameboard unit test suite", + "prefix": "test-suite-unit", + "body": [ + "namespace Gameboard.Api.Tests.Unit;", + "", + "public class ${TM_FILENAME/\\.cs//g}", + "{", + "\t$0", + "}" + ] + }, + "Create Gameboard Unit Test": { + "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)", + "{", + "\t\/\/ given", + "\t$4", + "\t\/\/ when", + "\t\/\/ var sut = new ${TM_FILENAME/Tests\\.cs//g}(...)", + "", + "\t\/\/ then", + "}" + ] + // }, + "Create Gameboard Integration Test Suite": { + "scope": "csharp", + "description": "Create a Gameboard integration test suite", + "prefix": "test-suite-int", + "body": [ + "namespace Gameboard.Api.Tests.Integration;", + "", + "public class ${0:Some}ControllerTests : IClassFixture", + "{", + "\tprivate readonly GameboardTestContext _testContext;", + "", + "\tpublic ${0:Some}ControllerTests(GameboardTestContext testContext", + "\t\t=> _testContext = testContext;", + "}" + ] + } +} diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Games/GameControllerTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Games/GameControllerTests.cs index e703569f..d9d774f8 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Games/GameControllerTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Games/GameControllerTests.cs @@ -13,6 +13,7 @@ public GameControllerTests(GameboardTestContext testContext) public async Task GameController_Create_ReturnsGame() { // arrange + var game = new NewGame() { Name = "Test game", @@ -31,7 +32,7 @@ public async Task GameController_Create_ReturnsGame() var responseGame = await _testContext .CreateHttpClientWithAuthRole(UserRole.Designer) .PostAsync("/api/game", game.ToJsonBody()) - .WithContentDeserializedAs(); + .WithContentDeserializedAs(); // assert responseGame?.Name.ShouldBe(game.Name); diff --git a/src/Gameboard.Api/Features/Game/GameModes/ExternalGameModeService.cs b/src/Gameboard.Api/Features/Game/GameModes/ExternalGameModeService.cs index ed31c8c2..c842fb0b 100644 --- a/src/Gameboard.Api/Features/Game/GameModes/ExternalGameModeService.cs +++ b/src/Gameboard.Api/Features/Game/GameModes/ExternalGameModeService.cs @@ -34,7 +34,7 @@ public Task GetGamePlayState(string gameId, CancellationToken can public Task GetGamePlayStateForTeam(string teamId, CancellationToken cancellationToken) => GetGamePlayStateForGameAndTeam(null, teamId, cancellationToken); - private async Task GetGamePlayStateForGameAndTeam(string gameId, string teamId, CancellationToken cancellationToken) + internal async Task GetGamePlayStateForGameAndTeam(string gameId, string teamId, CancellationToken cancellationToken) { if (teamId.IsNotEmpty()) gameId = await _teamService.GetGameId(teamId, cancellationToken); From 9e1462e0dc28594c62bc7f5ce38cb8b180d91e3d Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Fri, 24 May 2024 11:26:04 -0400 Subject: [PATCH 02/12] Make site usage report sponsors sort by count descending --- .../Queries/SiteUsageReport/GetSiteUsageReportSponsors.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Gameboard.Api/Features/Reports/Queries/SiteUsageReport/GetSiteUsageReportSponsors.cs b/src/Gameboard.Api/Features/Reports/Queries/SiteUsageReport/GetSiteUsageReportSponsors.cs index 1aa0d5a3..0b0b990e 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/SiteUsageReport/GetSiteUsageReportSponsors.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/SiteUsageReport/GetSiteUsageReportSponsors.cs @@ -59,6 +59,7 @@ public async Task> Handle(GetSiteUsageReport ParentName = gr.Key.ParentName, PlayerCount = gr.Select(p => p.UserId).Distinct().Count() }) + .OrderByDescending(s => s.PlayerCount) .ToArrayAsync(cancellationToken); } } From 3ee55cb26e17117748c3f455e67f01f7e4c6b1b9 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Fri, 24 May 2024 11:36:16 -0400 Subject: [PATCH 03/12] minor cleanup --- .../Features/Challenge/Services/ChallengeService.cs | 9 +++------ .../Queries/SiteUsageReport/GetSiteUsageReportPlayers.cs | 1 - 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs index ff09ab68..5b807c83 100644 --- a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs +++ b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs @@ -650,11 +650,7 @@ public ConsoleActor GetConsoleActor(string userId) public async Task GetChallengeUserMaps(IQueryable query, CancellationToken cancellationToken) { var teamChallengeIds = await query - .Select(c => new - { - c.Id, - c.TeamId - }) + .Select(c => new { c.Id, c.TeamId }) .GroupBy(c => c.TeamId) .ToDictionaryAsync(gr => gr.Key, gr => gr.Select(c => c.Id).ToArray(), cancellationToken); @@ -670,7 +666,8 @@ public async Task GetChallengeUserMaps(IQueryable gr.Key, gr => gr.Select(thing => thing.TeamId).Distinct(), cancellationToken); var userIdChallengeIds = userTeamIds - .ToDictionary(gr => gr.Key, gr => gr.Value.SelectMany(tId => teamChallengeIds[tId])); + .ToDictionary(gr => gr.Key, gr => gr.Value + .SelectMany(tId => teamChallengeIds[tId])); var challengeIdUserIds = new Dictionary>(); foreach (var kv in userIdChallengeIds) diff --git a/src/Gameboard.Api/Features/Reports/Queries/SiteUsageReport/GetSiteUsageReportPlayers.cs b/src/Gameboard.Api/Features/Reports/Queries/SiteUsageReport/GetSiteUsageReportPlayers.cs index 4d8781dc..9de9578e 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/SiteUsageReport/GetSiteUsageReportPlayers.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/SiteUsageReport/GetSiteUsageReportPlayers.cs @@ -45,7 +45,6 @@ public async Task> Handle(GetSiteUsageRep paging.PageNumber ??= 0; paging.PageSize ??= 20; - var challengeIdUserIdMaps = await _challengeService.GetChallengeUserMaps(_reportService.GetBaseQuery(request.ReportParameters), cancellationToken); var challengeData = await _store .WithNoTracking() From 25a66b917f1b6656f3aaf39e03670428dc2dc9cb Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Fri, 24 May 2024 11:52:32 -0400 Subject: [PATCH 04/12] Perf optimizations for site usage report. --- src/Gameboard.Api/Data/GameboardDbContext.cs | 3 +++ .../Features/Challenge/Services/ChallengeService.cs | 9 ++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Gameboard.Api/Data/GameboardDbContext.cs b/src/Gameboard.Api/Data/GameboardDbContext.cs index 5e2826dc..2751af1d 100644 --- a/src/Gameboard.Api/Data/GameboardDbContext.cs +++ b/src/Gameboard.Api/Data/GameboardDbContext.cs @@ -271,6 +271,9 @@ protected override void OnModelCreating(ModelBuilder builder) b.Property(p => p.InviteCode).HasMaxLength(40); b.Property(p => p.AdvancedFromTeamId).HasStandardGuidLength(); + // performance-oriented indices + b.HasIndex(p => new { p.UserId, p.TeamId }); + // nav properties b.HasOne(p => p.User).WithMany(u => u.Enrollments).OnDelete(DeleteBehavior.Cascade); b diff --git a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs index 5b807c83..feecc847 100644 --- a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs +++ b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs @@ -657,11 +657,10 @@ public async Task GetChallengeUserMaps(IQueryable() - .Include(c => c.Player) - .Where(c => c.Player.UserId != null && c.Player.UserId != string.Empty) - .Where(c => teamIds.Contains(c.TeamId)) - .Select(c => new { c.Player.UserId, c.TeamId }) + .WithNoTracking() + .Where(p => p.UserId != null && p.UserId != string.Empty) + .Where(p => teamIds.Contains(p.TeamId)) + .Select(p => new { p.UserId, p.TeamId }) .GroupBy(p => p.UserId) .ToDictionaryAsync(gr => gr.Key, gr => gr.Select(thing => thing.TeamId).Distinct(), cancellationToken); From a1b4bc239689ac6215763d00d345babb9c3e0abf Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Fri, 24 May 2024 12:46:41 -0400 Subject: [PATCH 05/12] Add server-side validation for game start/end, registration start/end, and team size. Resolves #250. --- .../Features/Game/GameController.cs | 2 +- .../Features/Game/GameExceptions.cs | 6 ++ .../Features/Game/GameService.cs | 22 +++-- .../Features/Game/GameValidator.cs | 86 +++++++++++-------- 4 files changed, 72 insertions(+), 44 deletions(-) diff --git a/src/Gameboard.Api/Features/Game/GameController.cs b/src/Gameboard.Api/Features/Game/GameController.cs index 5cd77362..dcd3046a 100644 --- a/src/Gameboard.Api/Features/Game/GameController.cs +++ b/src/Gameboard.Api/Features/Game/GameController.cs @@ -103,7 +103,7 @@ public Task DeployResources([FromRoute] string gameId, [FromBody] IEnumerable Create(NewGame model) model.CertificateTemplate = _defaults.CertificateTemplate; } - // default to standard-mode challenges + // defaults: standard, 60 minutes, scoreboard access, etc. if (model.Mode.IsEmpty()) model.Mode = GameEngineMode.Standard; - // by default, enable public scoreboard access (after the game has ended) + // default to a session length of 60 minutes + if (model.SessionMinutes == 0) + model.SessionMinutes = 60; + + if (model.MinTeamSize == 0) + model.MinTeamSize = 1; + + if (model.MaxTeamSize == 0) + model.MaxTeamSize = 1; + model.AllowPublicScoreboardAccess = true; var entity = Mapper.Map(model); - await _gameStore.Create(entity); - return Mapper.Map(entity); } @@ -111,10 +118,9 @@ public async Task Update(ChangedGame game) await _gameStore.Update(entity); } - public async Task Delete(string id) - { - await _gameStore.Delete(id); - } + public Task Delete(string id) + => _gameStore.Delete(id); + public IQueryable BuildQuery(GameSearchFilter model = null, bool sudo = false) { diff --git a/src/Gameboard.Api/Features/Game/GameValidator.cs b/src/Gameboard.Api/Features/Game/GameValidator.cs index 300aa87f..a48a74f3 100644 --- a/src/Gameboard.Api/Features/Game/GameValidator.cs +++ b/src/Gameboard.Api/Features/Game/GameValidator.cs @@ -1,45 +1,61 @@ // 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.Tasks; -using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Games; +using Microsoft.EntityFrameworkCore; -namespace Gameboard.Api.Validators +namespace Gameboard.Api.Validators; + +public class GameValidator : IModelValidator { - public class GameValidator : IModelValidator + private readonly IStore _store; + + public GameValidator(IStore store) + { + _store = store; + } + + public Task Validate(object model) + { + if (model is Entity) + return _validate(model as Entity); + + if (model is ChangedGame) + return _validate(model as ChangedGame); + + throw new ValidationTypeFailure(model.GetType()); + } + + private Task _validate(ChangedGame game) { - private readonly IGameStore _store; - - public GameValidator( - IGameStore store - ) - { - _store = store; - } - - public Task Validate(object model) - { - if (model is Entity) - return _validate(model as Entity); - - throw new ValidationTypeFailure(model.GetType()); - } - - private async Task _validate(Entity model) - { - if ((await Exists(model.Id)).Equals(false)) - throw new ResourceNotFound(model.Id); - - await Task.CompletedTask; - } - - private async Task Exists(string id) - { - return - id.NotEmpty() && - (await _store.Retrieve(id)) is not null - ; - } + if (game.MinTeamSize > game.MaxTeamSize) + throw new InvalidTeamSize(game.Id, game.Name, game.MinTeamSize, game.MaxTeamSize); + if (game.GameStart.IsNotEmpty() && game.GameEnd.IsNotEmpty() && game.GameStart > game.GameEnd) + throw new InvalidDateRange(new DateRange(game.GameStart, game.GameEnd)); + + if (game.RegistrationType == GameRegistrationType.Open && game.RegistrationOpen > game.RegistrationClose) + throw new InvalidDateRange(new DateRange(game.RegistrationOpen, game.RegistrationClose)); + + return Task.CompletedTask; + } + + private async Task _validate(Entity model) + { + if ((await Exists(model.Id)).Equals(false)) + throw new ResourceNotFound(model.Id); + + await Task.CompletedTask; } + + private async Task Exists(string id) + { + return id.IsNotEmpty() && await _store + .WithNoTracking() + .AnyAsync(g => g.Id == id); + } + } From d8b60ccb5bc7586c644e0102f8f9af4dd80aac5c Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 28 May 2024 11:24:05 -0400 Subject: [PATCH 06/12] Don't show plaintext host API key in response (and only optionally update during upsert operation). --- .../Game/External/ExternalGamesModels.cs | 2 +- .../Requests/UpsertExternalGameHost.cs | 18 ++++++++++++++++++ .../Services/ExternalGameHostService.cs | 3 +-- .../External/Services/ExternalGameService.cs | 3 --- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/Gameboard.Api/Features/Game/External/ExternalGamesModels.cs b/src/Gameboard.Api/Features/Game/External/ExternalGamesModels.cs index 0e26ef2c..5b001e92 100644 --- a/src/Gameboard.Api/Features/Game/External/ExternalGamesModels.cs +++ b/src/Gameboard.Api/Features/Game/External/ExternalGamesModels.cs @@ -17,7 +17,7 @@ public sealed class GetExternalGameHostsResponseHost public required string ClientUrl { get; set; } public required bool DestroyResourcesOnDeployFailure { get; set; } public required int? GamespaceDeployBatchSize { get; set; } - public required string HostApiKey { get; set; } + public required bool HasApiKey { get; set; } public required string HostUrl { get; set; } public required string PingEndpoint { get; set; } public required string StartupEndpoint { get; set; } diff --git a/src/Gameboard.Api/Features/Game/External/Requests/UpsertExternalGameHost.cs b/src/Gameboard.Api/Features/Game/External/Requests/UpsertExternalGameHost.cs index 064eac01..97f8fb39 100644 --- a/src/Gameboard.Api/Features/Game/External/Requests/UpsertExternalGameHost.cs +++ b/src/Gameboard.Api/Features/Game/External/Requests/UpsertExternalGameHost.cs @@ -1,9 +1,11 @@ +using System.Linq; using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Data; using Gameboard.Api.Structure.MediatR; using Gameboard.Api.Structure.MediatR.Validators; using MediatR; +using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Features.Games.External; @@ -79,7 +81,23 @@ public async Task Handle(UpsertExternalGameHostCommand request if (request.Host.Id.IsEmpty()) retVal = await _store.Create(retVal); else + { + // we only update the API key if the request explicitly asks us to. if it's empty, we make + // the assumption that the user isn't changing it (to avoid serving the actual API key in the response + // when they edit a host) + if (request.Host.HostApiKey.IsEmpty()) + { + var currentApiKey = await _store + .WithNoTracking() + .Where(h => h.Id == request.Host.Id) + .Select(h => h.HostApiKey) + .SingleAsync(cancellationToken); + + retVal.HostApiKey = currentApiKey; + } + retVal = await _store.SaveUpdate(retVal, cancellationToken); + } return retVal; } diff --git a/src/Gameboard.Api/Features/Game/External/Services/ExternalGameHostService.cs b/src/Gameboard.Api/Features/Game/External/Services/ExternalGameHostService.cs index 6778c288..4cb8197d 100644 --- a/src/Gameboard.Api/Features/Game/External/Services/ExternalGameHostService.cs +++ b/src/Gameboard.Api/Features/Game/External/Services/ExternalGameHostService.cs @@ -11,7 +11,6 @@ using Gameboard.Api.Features.Teams; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using ServiceStack; namespace Gameboard.Api.Features.Games.External; @@ -95,7 +94,7 @@ public IQueryable GetHosts() ClientUrl = h.ClientUrl, DestroyResourcesOnDeployFailure = h.DestroyResourcesOnDeployFailure, GamespaceDeployBatchSize = h.GamespaceDeployBatchSize, - HostApiKey = h.HostApiKey, + HasApiKey = h.HostApiKey != null && h.HostApiKey != string.Empty, HostUrl = h.HostUrl, PingEndpoint = h.PingEndpoint, StartupEndpoint = h.StartupEndpoint, diff --git a/src/Gameboard.Api/Features/Game/External/Services/ExternalGameService.cs b/src/Gameboard.Api/Features/Game/External/Services/ExternalGameService.cs index d87b85fd..32081f88 100644 --- a/src/Gameboard.Api/Features/Game/External/Services/ExternalGameService.cs +++ b/src/Gameboard.Api/Features/Game/External/Services/ExternalGameService.cs @@ -33,7 +33,6 @@ internal class ExternalGameService : IExternalGameService, INotificationHandler, INotificationHandler { - private readonly IGameModeServiceFactory _gameModeServiceFactory; private readonly IGuidService _guids; private readonly ILogger _logger; private readonly INowService _now; @@ -43,7 +42,6 @@ internal class ExternalGameService : IExternalGameService, public ExternalGameService ( - IGameModeServiceFactory gameModeServiceFactory, IGuidService guids, ILogger logger, INowService now, @@ -52,7 +50,6 @@ public ExternalGameService ITeamService teamService ) { - _gameModeServiceFactory = gameModeServiceFactory; _guids = guids; _logger = logger; _now = now; From e72f4ce291ba7ced9ccffd4aa916ccd7d7582c12 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 29 May 2024 15:04:22 -0400 Subject: [PATCH 07/12] Initial work on game center. --- .../Features/Admin/AdminController.cs | 4 + .../GetGameCenterContext.cs | 113 ++++++++++++++++++ .../GetGameCenterContextModels.cs | 23 ++++ .../Features/Ticket/TicketService.cs | 8 ++ 4 files changed, 148 insertions(+) create mode 100644 src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs create mode 100644 src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs diff --git a/src/Gameboard.Api/Features/Admin/AdminController.cs b/src/Gameboard.Api/Features/Admin/AdminController.cs index 524c77fe..f670be83 100644 --- a/src/Gameboard.Api/Features/Admin/AdminController.cs +++ b/src/Gameboard.Api/Features/Admin/AdminController.cs @@ -26,6 +26,10 @@ public Task GetActiveTeams() public Task CreateAnnouncement([FromBody] SendAnnouncementCommand request) => _mediator.Send(request); + [HttpGet("games/{gameId}/game-center")] + public Task GetGameCenterContext([FromRoute] string gameId) + => _mediator.Send(new GetGameCenterContextQuery(gameId)); + [HttpGet("stats")] public Task GetAppOverviewStats() => _mediator.Send(new GetAppOverviewStatsQuery()); diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs new file mode 100644 index 00000000..b9746063 --- /dev/null +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs @@ -0,0 +1,113 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common.Services; +using Gameboard.Api.Data; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Authorizers; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Admin; + +public record GetGameCenterContextQuery(string GameId) : IRequest; + +internal class GetGameCenterContextHandler : IRequestHandler +{ + private readonly EntityExistsValidator _gameExists; + private readonly INowService _now; + private readonly IStore _store; + private readonly TicketService _ticketService; + private readonly UserRoleAuthorizer _userRole; + private readonly IValidatorService _validator; + + public GetGameCenterContextHandler + ( + EntityExistsValidator gameExists, + INowService now, + IStore store, + TicketService ticketService, + UserRoleAuthorizer userRole, + IValidatorService validator + ) + { + _gameExists = gameExists; + _now = now; + _store = store; + _ticketService = ticketService; + _userRole = userRole; + _validator = validator; + } + + public async Task Handle(GetGameCenterContextQuery request, CancellationToken cancellationToken) + { + _userRole + .AllowAllElevatedRoles() + .Authorize(); + + _validator.AddValidator(_gameExists.UseProperty(r => r.GameId)); + await _validator.Validate(request, cancellationToken); + + var nowish = _now.Get(); + var gameData = await _store + .WithNoTracking() + .Select(g => new + { + g.Id, + g.Name, + g.GameStart, + g.GameEnd, + g.Competition, + g.Season, + g.Track, + g.Logo, + IsExternal = g.Mode == "external", + IsLive = g.GameStart <= nowish && g.GameEnd >= nowish, + g.IsPracticeMode, + IsRegistrationActive = g.RegistrationType == GameRegistrationType.Open && g.RegistrationOpen <= nowish && g.RegistrationClose >= nowish, + IsTeamGame = g.MinTeamSize > 1 + }) + .SingleAsync(g => g.Id == request.GameId, cancellationToken); + + var challengeData = await _store + .WithNoTracking() + .Where(s => s.GameId == request.GameId) + .Where(s => !s.Disabled) + .Where(s => !s.IsHidden) + .GroupBy(s => s.GameId) + .Select(gr => new + { + ChallengeCount = gr.Count(), + PointsAvailable = gr.Sum(s => s.Points), + }) + .SingleOrDefaultAsync(cancellationToken); + + var openTicketCount = await _ticketService + .GetGameOpenTickets(request.GameId) + .CountAsync(cancellationToken); + + return new GameCenterContext + { + // the game + Id = gameData.Id, + Name = gameData.Name, + Logo = gameData.Logo, + ExecutionWindow = new DateRange(gameData.GameStart, gameData.GameEnd), + Competition = gameData.Competition, + Season = gameData.Season, + Track = gameData.Track, + IsExternal = gameData.IsExternal, + IsLive = gameData.IsLive, + IsPractice = gameData.IsPracticeMode, + IsRegistrationActive = gameData.IsRegistrationActive, + IsTeamGame = gameData.IsTeamGame, + + // aggregates + ChallengeCount = challengeData?.ChallengeCount ?? 0, + PointsAvailable = challengeData?.PointsAvailable ?? 0, + OpenTicketCount = openTicketCount + }; + } +} diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs new file mode 100644 index 00000000..2dabc5a9 --- /dev/null +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs @@ -0,0 +1,23 @@ +namespace Gameboard.Api.Features.Admin; + +public sealed class GameCenterContext +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string Logo { get; set; } + public required DateRange ExecutionWindow { get; set; } + public required double PointsAvailable { get; set; } + + public required string Competition { get; set; } + public required string Season { get; set; } + public required string Track { get; set; } + + public required int ChallengeCount { get; set; } + public required int OpenTicketCount { get; set; } + + public required bool IsExternal { get; set; } + public required bool IsLive { get; set; } + public required bool IsPractice { get; set; } + public required bool IsRegistrationActive { get; set; } + public required bool IsTeamGame { get; set; } +} diff --git a/src/Gameboard.Api/Features/Ticket/TicketService.cs b/src/Gameboard.Api/Features/Ticket/TicketService.cs index 56fc6528..ab6423e3 100644 --- a/src/Gameboard.Api/Features/Ticket/TicketService.cs +++ b/src/Gameboard.Api/Features/Ticket/TicketService.cs @@ -172,6 +172,14 @@ await _mediator.Publish(new TicketCreatedNotification return createdTicketModel; } + public IQueryable GetGameOpenTickets(string gameId) + { + return _store + .WithNoTracking() + .Where(t => t.Challenge.GameId == gameId || t.Player.Challenges.Any(c => c.GameId == gameId)) + .Where(t => t.Status != "Closed"); + } + public async Task Update(ChangedTicket model, string actorId, bool sudo) { // need the creator to send updates From 3ba7165ccc2e20a55b7a1bb7928b157eeae696f6 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 30 May 2024 10:52:26 -0400 Subject: [PATCH 08/12] Add batch creation of users by ID through the API. Corrected a bug that could cause the app to attempt to deploy resources for all teams when predeployment was issued from admin -> game -> players. --- .../Players/PlayerControllerEnrollTests.cs | 3 +- .../Admin/AdminExternalGamesController.cs | 4 +- .../External/Services/ExternalGameService.cs | 2 +- src/Gameboard.Api/Features/Game/Game.cs | 2 + .../Features/Game/GameController.cs | 4 +- .../ResourceDeployment/DeployGameResources.cs | 5 +- .../GameEngine/GameEngineExceptions.cs | 2 +- src/Gameboard.Api/Features/Player/Player.cs | 2 - .../Features/Teams/AdminTeamController.cs | 4 +- .../Features/Teams/TeamController.cs | 10 +- .../Features/User/Requests/TryCreateUsers.cs | 149 ++++++++++++++++++ src/Gameboard.Api/Features/User/User.cs | 29 +++- .../Features/User/UserController.cs | 5 + .../Features/User/UserExceptions.cs | 10 ++ .../Features/User/UserService.cs | 15 +- 15 files changed, 222 insertions(+), 24 deletions(-) create mode 100644 src/Gameboard.Api/Features/User/Requests/TryCreateUsers.cs diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerEnrollTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerEnrollTests.cs index a1224833..bf43ec8b 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerEnrollTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerEnrollTests.cs @@ -40,8 +40,7 @@ await _testContext.WithDataState(state => var enrollRequest = new NewPlayer() { UserId = userId, - GameId = gameId, - Name = fixture.Create() + GameId = gameId }; var httpClient = _testContext.CreateHttpClientWithActingUser(u => u.Id = userId); ; diff --git a/src/Gameboard.Api/Features/Admin/AdminExternalGamesController.cs b/src/Gameboard.Api/Features/Admin/AdminExternalGamesController.cs index 11660e4e..2695fd1a 100644 --- a/src/Gameboard.Api/Features/Admin/AdminExternalGamesController.cs +++ b/src/Gameboard.Api/Features/Admin/AdminExternalGamesController.cs @@ -23,6 +23,6 @@ public Task GetExternalGameAdminContext([FromRoute] string ga => _mediator.Send(new GetExternalGameAdminContextRequest(gameId)); [HttpPost("{gameId}/pre-deploy")] - public Task PreDeployGame([FromRoute] string gameId, [FromBody] ExternalGameDeployTeamResourcesRequest request) - => _mediator.Send(new DeployGameResourcesCommand(gameId, request.TeamIds)); + public Task PreDeployGame([FromRoute] string gameId, [FromBody] DeployGameResourcesBody body) + => _mediator.Send(new DeployGameResourcesCommand(gameId, body?.TeamIds)); } diff --git a/src/Gameboard.Api/Features/Game/External/Services/ExternalGameService.cs b/src/Gameboard.Api/Features/Game/External/Services/ExternalGameService.cs index 32081f88..55af8da2 100644 --- a/src/Gameboard.Api/Features/Game/External/Services/ExternalGameService.cs +++ b/src/Gameboard.Api/Features/Game/External/Services/ExternalGameService.cs @@ -257,7 +257,7 @@ private async Task InitTeams(IEnumerable teamIds, CancellationToken canc .WithNoTracking() .Where(p => teamIds.Contains(p.TeamId)) .GroupBy(p => p.TeamId) - .ToDictionaryAsync(kv => kv.Key, kv => kv.Select(p => p.GameId), cancellationToken); + .ToDictionaryAsync(kv => kv.Key, kv => kv.Select(p => p.GameId).Distinct(), cancellationToken); if (teamGameIds.Values.Any(gIds => gIds.Count() > 1)) throw new InvalidOperationException("One of the teams to be created is tied to more than one game."); diff --git a/src/Gameboard.Api/Features/Game/Game.cs b/src/Gameboard.Api/Features/Game/Game.cs index 175abe51..d3ec4c7d 100644 --- a/src/Gameboard.Api/Features/Game/Game.cs +++ b/src/Gameboard.Api/Features/Game/Game.cs @@ -139,3 +139,5 @@ public class GameGroup public int Month { get; set; } public Game[] Games { get; set; } } + +public sealed record DeployGameResourcesBody(IEnumerable TeamIds); diff --git a/src/Gameboard.Api/Features/Game/GameController.cs b/src/Gameboard.Api/Features/Game/GameController.cs index dcd3046a..ef1c00e0 100644 --- a/src/Gameboard.Api/Features/Game/GameController.cs +++ b/src/Gameboard.Api/Features/Game/GameController.cs @@ -91,8 +91,8 @@ public async Task GetSessionForecast([FromRoute] string id) [HttpPost("api/game/{gameId}/resources")] [Authorize] - public Task DeployResources([FromRoute] string gameId, [FromBody] IEnumerable teamIds) - => _mediator.Send(new DeployGameResourcesCommand(gameId, teamIds)); + public Task DeployResources([FromRoute] string gameId, [FromBody] DeployGameResourcesBody body) + => _mediator.Send(new DeployGameResourcesCommand(gameId, body?.TeamIds)); /// /// Change game diff --git a/src/Gameboard.Api/Features/Game/ResourceDeployment/DeployGameResources.cs b/src/Gameboard.Api/Features/Game/ResourceDeployment/DeployGameResources.cs index 8c30ad0a..e09070a9 100644 --- a/src/Gameboard.Api/Features/Game/ResourceDeployment/DeployGameResources.cs +++ b/src/Gameboard.Api/Features/Game/ResourceDeployment/DeployGameResources.cs @@ -14,7 +14,7 @@ namespace Gameboard.Api.Features.Games; -public record DeployGameResourcesCommand(string GameId, IEnumerable TeamIds = null) : IRequest; +public record DeployGameResourcesCommand(string GameId, IEnumerable TeamIds) : IRequest; internal class DeployGameResourcesHandler : IRequestHandler { @@ -64,12 +64,13 @@ public async Task Handle(DeployGameResourcesCommand request, CancellationToken c _backgroundTaskContext.ActingUser = _actingUserService.Get(); _backgroundTaskContext.AppBaseUrl = _appUrlService.GetBaseUrl(); - var finalTeamIds = request.TeamIds.IsEmpty() ? Array.Empty() : request.TeamIds; + var finalTeamIds = request is null || request.TeamIds.IsEmpty() ? Array.Empty() : request.TeamIds; if (finalTeamIds.IsEmpty() && request.GameId.IsNotEmpty()) finalTeamIds = await _store .WithNoTracking() .Where(p => p.GameId == request.GameId) .Select(p => p.TeamId) + .Distinct() .ToArrayAsync(cancellationToken); await _backgroundTaskQueue.QueueBackgroundWorkItemAsync diff --git a/src/Gameboard.Api/Features/GameEngine/GameEngineExceptions.cs b/src/Gameboard.Api/Features/GameEngine/GameEngineExceptions.cs index c9491593..b1e732c8 100644 --- a/src/Gameboard.Api/Features/GameEngine/GameEngineExceptions.cs +++ b/src/Gameboard.Api/Features/GameEngine/GameEngineExceptions.cs @@ -5,7 +5,7 @@ namespace Gameboard.Api.Features.GameEngine; internal class GradingFailed : GameboardException { - public GradingFailed(string challengeId, Exception innerException) : base($"Grading failed for challenge {challengeId}.", innerException) { } + public GradingFailed(string challengeId, Exception innerException) : base($"Grading failed for challenge {challengeId}: {innerException?.Message}", innerException) { } } internal class GamespaceStartFailure : GameboardException diff --git a/src/Gameboard.Api/Features/Player/Player.cs b/src/Gameboard.Api/Features/Player/Player.cs index 5febc1f6..fdd8b025 100644 --- a/src/Gameboard.Api/Features/Player/Player.cs +++ b/src/Gameboard.Api/Features/Player/Player.cs @@ -47,8 +47,6 @@ public class NewPlayer { public string UserId { get; set; } public string GameId { get; set; } - // Looks unused - public string Name { get; set; } } public class ChangedPlayer diff --git a/src/Gameboard.Api/Features/Teams/AdminTeamController.cs b/src/Gameboard.Api/Features/Teams/AdminTeamController.cs index bbb8ded9..655a2a6b 100644 --- a/src/Gameboard.Api/Features/Teams/AdminTeamController.cs +++ b/src/Gameboard.Api/Features/Teams/AdminTeamController.cs @@ -14,9 +14,7 @@ public class AdminTeamsController : ControllerBase private readonly IMediator _mediator; public AdminTeamsController(IMediator mediator) - { - _mediator = mediator; - } + => _mediator = mediator; [HttpPost] public Task Create([FromBody] AdminEnrollTeamRequest request) diff --git a/src/Gameboard.Api/Features/Teams/TeamController.cs b/src/Gameboard.Api/Features/Teams/TeamController.cs index 257577d3..1566133c 100644 --- a/src/Gameboard.Api/Features/Teams/TeamController.cs +++ b/src/Gameboard.Api/Features/Teams/TeamController.cs @@ -63,6 +63,11 @@ public Task UpdateSession([FromBody] SessionChangeRequest model, CancellationTok public Task GetTeamGamePlayState([FromRoute] string teamId) => _mediator.Send(new GetGamePlayStateQuery(teamId, _actingUserService.Get()?.Id)); + [HttpPut("{teamId}/ready")] + [Authorize] + public Task UpdateTeamReadyState([FromRoute] string teamId, [FromBody] UpdateIsReadyRequest isReadyCommand) + => _mediator.Send(new UpdateTeamReadyStateCommand(teamId, isReadyCommand.IsReady)); + [HttpPut("{teamId}/session")] public Task ResetSession([FromRoute] string teamId, [FromBody] ResetTeamSessionCommand request, CancellationToken cancellationToken) => _mediator.Send(new ResetTeamSessionCommand(teamId, request.ResetType, _actingUserService.Get()), cancellationToken); @@ -74,9 +79,4 @@ public Task GetTeamEventHorizon([FromRoute] string teamId) // [HttpPost("{teamId}/session")] // public Task StartSessions([FromBody] StartTeamSessionsCommand request, CancellationToken cancellationToken) // => _mediator.Send(request, cancellationToken); - - [HttpPut("{teamId}/ready")] - [Authorize] - public Task UpdateTeamReadyState([FromRoute] string teamId, [FromBody] UpdateIsReadyRequest isReadyCommand) - => _mediator.Send(new UpdateTeamReadyStateCommand(teamId, isReadyCommand.IsReady)); } diff --git a/src/Gameboard.Api/Features/User/Requests/TryCreateUsers.cs b/src/Gameboard.Api/Features/User/Requests/TryCreateUsers.cs new file mode 100644 index 00000000..5d89d725 --- /dev/null +++ b/src/Gameboard.Api/Features/User/Requests/TryCreateUsers.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common.Services; +using Gameboard.Api.Data; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Authorizers; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Users; + +public sealed record TryCreateUsersCommand(TryCreateUsersRequest Request) : IRequest; + +internal sealed class TryCreateUsersHandler : IRequestHandler +{ + private readonly IActingUserService _actingUserService; + private readonly EntityExistsValidator _gameExists; + private readonly PlayerService _playerService; + private readonly EntityExistsValidator _sponsorExists; + private readonly IStore _store; + private readonly UserRoleAuthorizer _userRole; + private readonly UserService _userService; + private readonly IValidatorService _validator; + + public TryCreateUsersHandler + ( + IActingUserService actingUserService, + EntityExistsValidator gameExists, + PlayerService playerService, + EntityExistsValidator sponsorExists, + IStore store, + UserRoleAuthorizer userRole, + UserService userService, + IValidatorService validator + ) + { + _actingUserService = actingUserService; + _gameExists = gameExists; + _playerService = playerService; + _sponsorExists = sponsorExists; + _store = store; + _userRole = userRole; + _userService = userService; + _validator = validator; + } + + public async Task Handle(TryCreateUsersCommand request, CancellationToken cancellationToken) + { + // validate/authorize + _userRole.AllowRoles(UserRole.Admin).Authorize(); + + // optionally throw if the caller doesn't want to ignore the fact that some users exist already + if (!request.Request.AllowSubsetCreation) + { + _validator.AddValidator(async (req, ctx) => + { + var userIds = req.Request.UserIds.ToArray(); + var existingUserIds = await _store + .WithNoTracking() + .Where(u => userIds.Contains(u.Id)) + .Select(u => u.Id) + .ToArrayAsync(cancellationToken); + + if (existingUserIds.Any()) + ctx.AddValidationException(new CantCreateExistingUsers(existingUserIds)); + }); + } + + if (request.Request.EnrollInGameId.IsNotEmpty()) + _validator.AddValidator(_gameExists.UseProperty(r => r.Request.EnrollInGameId)); + + if (request.Request.SponsorId.IsNotEmpty()) + _validator.AddValidator(_sponsorExists.UseProperty(r => r.Request.SponsorId)); + + await _validator.Validate(request, cancellationToken); + + // do the business + var createdUsers = new List(); + foreach (var id in request.Request.UserIds.ToArray()) + { + createdUsers.Add(await _userService.TryCreate(new NewUser + { + Id = id, + SponsorId = request.Request.SponsorId, + UnsetDefaultSponsorFlag = request.Request.UnsetDefaultSponsorFlag + })); + } + + // if requested, enroll them in the game + if (request.Request.EnrollInGameId.IsNotEmpty()) + { + var actingUser = _actingUserService.Get(); + + // - query to determine if anyone is already enrolled + var createdUserIds = createdUsers.Select(u => u.User.Id).ToArray(); + var enrolledUserIds = await _store + .WithNoTracking() + .Where(p => createdUserIds.Contains(p.UserId)) + .Where(p => p.Mode == p.Game.PlayerMode) + .WhereDateIsNotEmpty(p => p.SessionBegin) + .Select(p => p.UserId) + .ToArrayAsync(cancellationToken); + + foreach (var createdUser in createdUsers) + { + if (enrolledUserIds.Contains(createdUser.User.Id)) + continue; + + await _playerService.Enroll(new NewPlayer + { + GameId = request.Request.EnrollInGameId, + UserId = createdUser.User.Id + }, actingUser, cancellationToken); + } + } + + // as a convenience, we include the name of the sponsor assigned in the response. + // this is currently the same for all created users, but you know. you never know. + var sponsorIds = createdUsers.Select(u => u.User.SponsorId).Distinct().ToArray(); + + // for reasons that currently melt my brain, trying to group this query by Id and Name + // to project to a dictionary on the server side caused insane errors, thanks EF (i'm pretty sure) + var sponsors = await _store + .WithNoTracking() + .Where(s => sponsorIds.Contains(s.Id)) + .Select(s => new SimpleEntity { Id = s.Id, Name = s.Name }) + .ToArrayAsync(cancellationToken); + var sponsorNames = sponsors + .GroupBy(s => new { s.Id, s.Name }) + .ToDictionary(s => s.Key.Id, s => s.Key.Name); + + return new TryCreateUsersResponse + { + Users = createdUsers.Select(u => new TryCreateUsersResponseUser + { + Id = u.User.Id, + Name = u.User.ApprovedName, + IsNewUser = u.IsNewUser, + EnrolledInGameId = request.Request.EnrollInGameId, + 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 d3030d89..178f4a1a 100644 --- a/src/Gameboard.Api/Features/User/User.cs +++ b/src/Gameboard.Api/Features/User/User.cs @@ -2,6 +2,8 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using System; +using System.Collections; +using System.Collections.Generic; using System.Linq; namespace Gameboard.Api; @@ -33,7 +35,9 @@ public class User : IUserViewModel public class NewUser { - public string Id { get; set; } + public required string Id { get; set; } + public string SponsorId { get; set; } + public bool UnsetDefaultSponsorFlag { get; set; } } public class ChangedUser @@ -109,3 +113,26 @@ public class TryCreateUserResult public required bool IsNewUser { get; set; } public User User { get; set; } } + +public class TryCreateUsersRequest +{ + public required bool AllowSubsetCreation { get; set; } + public string EnrollInGameId { get; set; } + public required string SponsorId { get; set; } + public required bool UnsetDefaultSponsorFlag { get; set; } + public required IEnumerable UserIds { get; set; } +} + +public sealed class TryCreateUsersResponse +{ + public required IEnumerable Users { get; set; } +} + +public sealed class TryCreateUsersResponseUser +{ + public required string Id { get; set; } + public required string EnrolledInGameId { get; set; } + public required string Name { 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 4531fa58..be65a8f8 100644 --- a/src/Gameboard.Api/Features/User/UserController.cs +++ b/src/Gameboard.Api/Features/User/UserController.cs @@ -78,6 +78,11 @@ await HttpContext.SignInAsync( return result; } + [HttpPost("api/users")] + [Authorize] + public Task TryCreateMany([FromBody] TryCreateUsersRequest request) + => _mediator.Send(new TryCreateUsersCommand(request)); + /// /// Get user-specific settings /// diff --git a/src/Gameboard.Api/Features/User/UserExceptions.cs b/src/Gameboard.Api/Features/User/UserExceptions.cs index 240a9f7c..f42736f2 100644 --- a/src/Gameboard.Api/Features/User/UserExceptions.cs +++ b/src/Gameboard.Api/Features/User/UserExceptions.cs @@ -1,5 +1,9 @@ using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; using Gameboard.Api.Common; +using Gameboard.Api.Structure; namespace Gameboard.Api.Features.Users; @@ -12,3 +16,9 @@ internal class ApiKeyNoName : GameboardException { public ApiKeyNoName() : base($"API keys are required to have a value in their `Name` property.") { } } + +internal class CantCreateExistingUsers : GameboardValidationException +{ + public CantCreateExistingUsers(IEnumerable userIds) + : base($"Can't create {userIds.Count()} users: They already exist. (UserIds: {userIds.ToDelimited()})") { } +} diff --git a/src/Gameboard.Api/Features/User/UserService.cs b/src/Gameboard.Api/Features/User/UserService.cs index d1f543e3..c86f3a4f 100644 --- a/src/Gameboard.Api/Features/User/UserService.cs +++ b/src/Gameboard.Api/Features/User/UserService.cs @@ -82,9 +82,18 @@ public async Task TryCreate(NewUser model) entity.LastLoginDate = entity.CreatedOn; } - // assign the user to the default sponsor - entity.Sponsor = await _sponsorService.GetDefaultSponsor(); - entity.HasDefaultSponsor = true; + // if a specific sponsor is requested, try to set it + if (model.SponsorId.IsNotEmpty()) + entity.SponsorId = await _store + .WithNoTracking() + .Select(s => s.Id) + .SingleOrDefaultAsync(sId => sId == model.SponsorId); + + // if no sponsor was specified or if the specified one doesn't exist, use the default + entity.SponsorId ??= (await _sponsorService.GetDefaultSponsor()).Id; + + // unless specifically told otherwise, we flag this user as needing to confirm their sponsor + entity.HasDefaultSponsor = !model.UnsetDefaultSponsorFlag; bool found = false; int i = 0; From 0547f5cec72b16553a9c341e5c5f3cf41ba64e79 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Fri, 31 May 2024 15:05:13 -0400 Subject: [PATCH 09/12] Finish batch user create --- src/Gameboard.Api/Features/Game/Game.cs | 3 +++ src/Gameboard.Api/Features/Game/GameService.cs | 4 +++- src/Gameboard.Api/Features/Player/Services/PlayerService.cs | 5 +---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Gameboard.Api/Features/Game/Game.cs b/src/Gameboard.Api/Features/Game/Game.cs index d3ec4c7d..0b1e8c48 100644 --- a/src/Gameboard.Api/Features/Game/Game.cs +++ b/src/Gameboard.Api/Features/Game/Game.cs @@ -81,12 +81,15 @@ public class GameSearchFilter : SearchFilter private const string PastFilter = "past"; private const string PresentFilter = "present"; private const string FutureFilter = "future"; + public bool WantsAdvanceable => Filter.Contains(AdvanceableFilter); public bool WantsCompetitive => Filter.Contains(CompetitiveFilter); public bool WantsPractice => Filter.Contains(PracticeFilter); public bool WantsPresent => Filter.Contains(PresentFilter); public bool WantsPast => Filter.Contains(PastFilter); public bool WantsFuture => Filter.Contains(FutureFilter); + + public string OrderBy { get; set; } } public class BoardGame diff --git a/src/Gameboard.Api/Features/Game/GameService.cs b/src/Gameboard.Api/Features/Game/GameService.cs index a4a8ec5d..28439c8a 100644 --- a/src/Gameboard.Api/Features/Game/GameService.cs +++ b/src/Gameboard.Api/Features/Game/GameService.cs @@ -151,7 +151,9 @@ public Task Delete(string id) if (model.WantsPast) q = q.Where(g => g.GameEnd < now && g.GameEnd != AppConstants.NULL_DATE); - if (model.WantsFuture) + if (model.OrderBy.IsNotEmpty() && model.OrderBy.ToLower() == "name") + q = q.OrderBy(g => g.Name); + else if (model.WantsFuture) q = q.OrderBy(g => g.GameStart).ThenBy(g => g.Name); else q = q.OrderByDescending(g => g.GameStart).ThenBy(g => g.Name); diff --git a/src/Gameboard.Api/Features/Player/Services/PlayerService.cs b/src/Gameboard.Api/Features/Player/Services/PlayerService.cs index 40b89bb4..10064d34 100644 --- a/src/Gameboard.Api/Features/Player/Services/PlayerService.cs +++ b/src/Gameboard.Api/Features/Player/Services/PlayerService.cs @@ -450,10 +450,7 @@ public async Task Enlist(PlayerEnlistment model, User actor, Cancellatio var player = await _store .WithTracking() .Include(p => p.Sponsor) - .SingleOrDefaultAsync(p => p.Id == model.PlayerId, cancellationToken); - - if (player is null) - throw new ResourceNotFound(model.PlayerId); + .SingleOrDefaultAsync(p => p.Id == model.PlayerId, cancellationToken) ?? throw new ResourceNotFound(model.PlayerId); var playersWithThisCode = await _store .WithNoTracking() From 08ecd67234c383e983f6e29518e55f9dc3ad5706 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 12 Jun 2024 17:17:58 -0400 Subject: [PATCH 10/12] Add featured games, improvements to game center, and challenge question breakdown. --- src/Gameboard.Api/Common/CommonModels.cs | 1 - .../Common/Extensions/EnumerableExtensions.cs | 18 + src/Gameboard.Api/Data/Entities/Game.cs | 1 + src/Gameboard.Api/Data/GameboardDbContext.cs | 1 + ...240612204736_AddGameIsFeatured.Designer.cs | 1890 +++++++++++++++++ .../20240612204736_AddGameIsFeatured.cs | 38 + ...meboardDbContextPostgreSQLModelSnapshot.cs | 5 + ...240612204743_AddGameIsFeatured.Designer.cs | 1890 +++++++++++++++++ .../20240612204743_AddGameIsFeatured.cs | 38 + ...ameboardDbContextSqlServerModelSnapshot.cs | 5 + .../Extensions/DatabaseStartupExtensions.cs | 4 - .../Features/Admin/AdminController.cs | 9 +- .../GetGameCenterTeams/GetGameCenterTeams.cs | 268 +++ .../GetGameCenterTeamsModels.cs | 72 + .../Features/ChallengeSpec/ChallengeSpec.cs | 20 + .../ChallengeSpec/ChallengeSpecController.cs | 13 + .../ChallengeSpec/ChallengeSpecService.cs | 92 +- .../GetChallengeSpecQuestionPerformance.cs | 63 + ...tChallengeSpecQuestionPerformanceModels.cs | 11 + src/Gameboard.Api/Features/Game/Game.cs | 5 +- .../Features/Game/GameService.cs | 3 + .../GameEngine/Services/GameEngineService.cs | 4 +- .../Extensions/IQueryableExtensions.cs | 25 - .../ChallengesReportService.cs | 2 +- .../Features/Teams/Services/TeamService.cs | 27 + .../Features/Teams/TeamsModels.cs | 7 + src/Gameboard.Api/Features/Ticket/Ticket.cs | 3 + .../Features/Ticket/TicketService.cs | 8 + 28 files changed, 4481 insertions(+), 42 deletions(-) create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20240612204736_AddGameIsFeatured.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20240612204736_AddGameIsFeatured.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20240612204743_AddGameIsFeatured.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20240612204743_AddGameIsFeatured.cs create mode 100644 src/Gameboard.Api/Features/Admin/Requests/GetGameCenterTeams/GetGameCenterTeams.cs create mode 100644 src/Gameboard.Api/Features/Admin/Requests/GetGameCenterTeams/GetGameCenterTeamsModels.cs create mode 100644 src/Gameboard.Api/Features/ChallengeSpec/Requests/GetChallengeSpecQuestionPerformance.cs create mode 100644 src/Gameboard.Api/Features/ChallengeSpec/Requests/GetChallengeSpecQuestionPerformanceModels.cs delete mode 100644 src/Gameboard.Api/Features/Reports/Extensions/IQueryableExtensions.cs diff --git a/src/Gameboard.Api/Common/CommonModels.cs b/src/Gameboard.Api/Common/CommonModels.cs index 4ad42cdb..c952b231 100644 --- a/src/Gameboard.Api/Common/CommonModels.cs +++ b/src/Gameboard.Api/Common/CommonModels.cs @@ -1,5 +1,4 @@ using System; -using System.Linq.Expressions; namespace Gameboard.Api.Common; diff --git a/src/Gameboard.Api/Common/Extensions/EnumerableExtensions.cs b/src/Gameboard.Api/Common/Extensions/EnumerableExtensions.cs index 74880606..8861c6f2 100644 --- a/src/Gameboard.Api/Common/Extensions/EnumerableExtensions.cs +++ b/src/Gameboard.Api/Common/Extensions/EnumerableExtensions.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; namespace Gameboard.Api.Common; @@ -11,6 +13,22 @@ public static bool IsNotEmpty(this IEnumerable enumerable) public static bool IsEmpty(this IEnumerable enumerable) => !IsNotEmpty(enumerable); + public static IOrderedEnumerable Sort(this IEnumerable enumerable, Func orderBy, SortDirection? sortDirection = null) + { + if (sortDirection == SortDirection.Desc) + return enumerable.OrderByDescending(orderBy); + + return enumerable.OrderBy(orderBy); + } + + public static IOrderedQueryable Sort(this IOrderedQueryable query, Expression> orderBy, SortDirection sortDirection = SortDirection.Asc) + { + if (sortDirection == SortDirection.Asc) + return query.OrderBy(orderBy); + + return query.OrderByDescending(orderBy); + } + public static IEnumerable ToEnumerable(this T thing) => new T[] { thing }; diff --git a/src/Gameboard.Api/Data/Entities/Game.cs b/src/Gameboard.Api/Data/Entities/Game.cs index 5e153122..a0331f8f 100644 --- a/src/Gameboard.Api/Data/Entities/Game.cs +++ b/src/Gameboard.Api/Data/Entities/Game.cs @@ -45,6 +45,7 @@ public class Game : IEntity public string CardText1 { get; set; } public string CardText2 { get; set; } public string CardText3 { get; set; } + public bool IsFeatured { get; set; } // mode stuff public string ExternalHostId { get; set; } diff --git a/src/Gameboard.Api/Data/GameboardDbContext.cs b/src/Gameboard.Api/Data/GameboardDbContext.cs index 2751af1d..13ea4195 100644 --- a/src/Gameboard.Api/Data/GameboardDbContext.cs +++ b/src/Gameboard.Api/Data/GameboardDbContext.cs @@ -272,6 +272,7 @@ protected override void OnModelCreating(ModelBuilder builder) b.Property(p => p.AdvancedFromTeamId).HasStandardGuidLength(); // performance-oriented indices + b.HasIndex(p => p.UserId); b.HasIndex(p => new { p.UserId, p.TeamId }); // nav properties diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20240612204736_AddGameIsFeatured.Designer.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20240612204736_AddGameIsFeatured.Designer.cs new file mode 100644 index 00000000..f7b07c99 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20240612204736_AddGameIsFeatured.Designer.cs @@ -0,0 +1,1890 @@ +// +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("20240612204736_AddGameIsFeatured")] + partial class AddGameIsFeatured + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.17") + .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("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.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("GameEnd") + .HasColumnType("timestamp with time zone"); + + 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("Key") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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.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("UserId", "TeamId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + 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.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("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.Game", b => + { + b.HasOne("Gameboard.Api.Data.ExternalGameHost", "ExternalHost") + .WithMany("UsedByGames") + .HasForeignKey("ExternalHostId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ExternalHost"); + }); + + 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.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.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.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("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/20240612204736_AddGameIsFeatured.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20240612204736_AddGameIsFeatured.cs new file mode 100644 index 00000000..261937bf --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20240612204736_AddGameIsFeatured.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + /// + public partial class AddGameIsFeatured : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsFeatured", + table: "Games", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateIndex( + name: "IX_Players_UserId_TeamId", + table: "Players", + columns: new[] { "UserId", "TeamId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Players_UserId_TeamId", + table: "Players"); + + migrationBuilder.DropColumn( + name: "IsFeatured", + table: "Games"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs index 815771c9..b0369cf8 100644 --- a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs @@ -728,6 +728,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("GamespaceLimitPerSession") .HasColumnType("integer"); + b.Property("IsFeatured") + .HasColumnType("boolean"); + b.Property("IsPublished") .HasColumnType("boolean"); @@ -954,6 +957,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("UserId"); + b.HasIndex("UserId", "TeamId"); + b.ToTable("Players"); }); diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20240612204743_AddGameIsFeatured.Designer.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20240612204743_AddGameIsFeatured.Designer.cs new file mode 100644 index 00000000..68222f7d --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20240612204743_AddGameIsFeatured.Designer.cs @@ -0,0 +1,1890 @@ +// +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("20240612204743_AddGameIsFeatured")] + partial class AddGameIsFeatured + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.17") + .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("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.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("GameEnd") + .HasColumnType("datetimeoffset"); + + 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("Key") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + 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.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("UserId", "TeamId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + 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.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("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.Game", b => + { + b.HasOne("Gameboard.Api.Data.ExternalGameHost", "ExternalHost") + .WithMany("UsedByGames") + .HasForeignKey("ExternalHostId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ExternalHost"); + }); + + 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.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.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.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("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/20240612204743_AddGameIsFeatured.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20240612204743_AddGameIsFeatured.cs new file mode 100644 index 00000000..f57dc918 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20240612204743_AddGameIsFeatured.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + /// + public partial class AddGameIsFeatured : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsFeatured", + table: "Games", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateIndex( + name: "IX_Players_UserId_TeamId", + table: "Players", + columns: new[] { "UserId", "TeamId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Players_UserId_TeamId", + table: "Players"); + + migrationBuilder.DropColumn( + name: "IsFeatured", + table: "Games"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs index f41e2734..9920a3f2 100644 --- a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs @@ -729,6 +729,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("GamespaceLimitPerSession") .HasColumnType("int"); + b.Property("IsFeatured") + .HasColumnType("bit"); + b.Property("IsPublished") .HasColumnType("bit"); @@ -955,6 +958,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("UserId"); + b.HasIndex("UserId", "TeamId"); + b.ToTable("Players"); }); diff --git a/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs b/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs index fd9a8feb..9ccfd99e 100644 --- a/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs +++ b/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs @@ -29,9 +29,7 @@ public static WebApplication InitializeDatabase(this WebApplication app, AppSett using (var db = services.GetService()) { if (!db.Database.IsInMemory()) - { db.Database.Migrate(); - } SeedDatabase(env, config, db, settings, logger); } @@ -61,9 +59,7 @@ private static void SeedDatabase(IWebHostEnvironment env, IConfiguration config, logger.LogInformation($"Admin user '{settings.Database.AdminName}' found in configuration. Seeding now..."); if (db.Users.FirstOrDefault(u => u.Id == settings.Database.AdminId) != null) - { logger.LogInformation("This user is already seeded in the database. Skipping it this time."); - } else { db.Users.Add(new Data.User diff --git a/src/Gameboard.Api/Features/Admin/AdminController.cs b/src/Gameboard.Api/Features/Admin/AdminController.cs index f670be83..91e38dbd 100644 --- a/src/Gameboard.Api/Features/Admin/AdminController.cs +++ b/src/Gameboard.Api/Features/Admin/AdminController.cs @@ -9,10 +9,7 @@ public class AdminController : ControllerBase { private readonly IMediator _mediator; - public AdminController(IMediator mediator) - { - _mediator = mediator; - } + public AdminController(IMediator mediator) => _mediator = mediator; [HttpGet("active-challenges")] public Task GetActiveChallenges([FromQuery] string playerMode) @@ -30,6 +27,10 @@ public Task CreateAnnouncement([FromBody] SendAnnouncementCommand request) public Task GetGameCenterContext([FromRoute] string gameId) => _mediator.Send(new GetGameCenterContextQuery(gameId)); + [HttpGet("games/{gameId}/game-center/teams")] + public Task GetGameCenterTeams([FromRoute] string gameId, [FromQuery] GetGameCenterTeamsArgs queryArgs, [FromQuery] PagingArgs pagingArgs) + => _mediator.Send(new GetGameCenterTeamsQuery(gameId, queryArgs, pagingArgs)); + [HttpGet("stats")] public Task GetAppOverviewStats() => _mediator.Send(new GetAppOverviewStatsQuery()); diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterTeams/GetGameCenterTeams.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterTeams/GetGameCenterTeams.cs new file mode 100644 index 00000000..7e5de7de --- /dev/null +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterTeams/GetGameCenterTeams.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common.Services; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Teams; +using Gameboard.Api.Services; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Admin; + +public record GetGameCenterTeamsQuery(string GameId, GetGameCenterTeamsArgs Args, PagingArgs PagingArgs) : IRequest; + +internal class GetGameCenterTeamsHandler : IRequestHandler +{ + private readonly INowService _nowService; + private readonly IPagingService _pagingService; + private readonly IStore _store; + private readonly ITeamService _teamService; + private readonly TicketService _ticketService; + + public GetGameCenterTeamsHandler + ( + INowService nowService, + IPagingService pagingService, + IStore store, + ITeamService teamService, + TicketService ticketService + ) + { + _nowService = nowService; + _pagingService = pagingService; + _store = store; + _teamService = teamService; + _ticketService = ticketService; + } + + public async Task Handle(GetGameCenterTeamsQuery request, CancellationToken cancellationToken) + { + var nowish = _nowService.Get(); + + // a little blarghy because we're counting on the Role, not the whole captain resolution thing + var query = _store + .WithNoTracking() + .Where(p => p.Role == PlayerRole.Manager) + .Where(p => p.GameId == request.GameId); + + if (request.Args.HasScored is not null) + query = query.Where(p => (p.Score > 0) == request.Args.HasScored.Value); + + if (request.Args.PlayerMode is not null) + query = query.Where(p => p.Mode == request.Args.PlayerMode); + + if (request.Args.Status is not null) + { + switch (request.Args.Status) + { + case GameCenterTeamsStatus.Complete: + query = query + .WhereDateIsNotEmpty(p => p.SessionEnd) + .Where(p => p.SessionEnd < nowish); + break; + case GameCenterTeamsStatus.Playing: + query = query + .WhereDateIsNotEmpty(p => p.SessionBegin) + .Where(p => p.SessionBegin <= nowish) + .Where(p => p.SessionEnd > nowish); + break; + case GameCenterTeamsStatus.NotStarted: + query = query + .WhereDateIsEmpty(p => p.SessionBegin); + break; + } + } + + if (request.Args.Search.IsNotEmpty() && request.Args.Search.Length > 2) + { + query = query + .Where + ( + p => + // guid matches do startswith for speed + p.TeamId.StartsWith(request.Args.Search) || + p.UserId.StartsWith(request.Args.Search) || + p.Id.StartsWith(request.Args.Search) || + p.Challenges.Any(c => c.Id.StartsWith(request.Args.Search)) || + + // name matches are looser but will take longer + p.Sponsor.Name.Contains(request.Args.Search) || + p.ApprovedName.Contains(request.Args.Search) || + p.User.ApprovedName.Contains(request.Args.Search) + ); + } + + var matchingTeams = await query + .Select(p => new + { + Name = p.ApprovedName, + p.TeamId, + SessionBeginButts = p.SessionBegin, + SessionEndButts = p.SessionEnd, + SessionBegin = p.SessionBegin == DateTimeOffset.MinValue ? default(DateTimeOffset?) : p.SessionBegin, + SessionEnd = p.SessionEnd == DateTimeOffset.MinValue ? default(DateTimeOffset?) : p.SessionEnd, + TimeRemaining = nowish < p.SessionEnd ? (p.SessionEnd - nowish).TotalMilliseconds : default(double?), + TimeSinceStart = nowish > p.SessionBegin && nowish < p.SessionEnd ? (nowish - p.SessionBegin).TotalMilliseconds : default(double?) + }) + .Distinct() + .ToDictionaryAsync(t => t.TeamId, t => t, cancellationToken); + + // we'll need this data no matter what, and if we get it here, we can + // use it to do sorting stuff + var teamRanks = await _store + .WithNoTracking() + .Where(t => t.GameId == request.GameId) + .Where(s => matchingTeams.Keys.Contains(s.TeamId)) + .Select(t => new { t.TeamId, t.Rank, t.ScoreOverall }) + .GroupBy(t => t.TeamId) + .ToDictionaryAsync(gr => gr.Key, gr => gr.Single(), cancellationToken); + + // default sort is pretty much nonsense (todo) + var sortedTeamIds = matchingTeams.Keys.ToArray(); + if (request.Args.Sort is not null) + { + switch (request.Args.Sort) + { + case GetGameCenterTeamsSort.Rank: + { + sortedTeamIds = teamRanks.Keys.Sort(k => teamRanks[k].Rank).ToArray(); + break; + } + case GetGameCenterTeamsSort.TimeRemaining: + { + sortedTeamIds = matchingTeams + .Sort(t => t.Value.TimeRemaining, request.Args.SortDirection) + .Select(t => t.Value.TeamId) + .ToArray(); + + break; + } + case GetGameCenterTeamsSort.TimeStart: + { + sortedTeamIds = matchingTeams + .Sort(t => t.Value.TimeSinceStart, request.Args.SortDirection) + .Select(t => t.Value.TeamId) + .ToArray(); + + break; + } + // default name sort + default: + { + sortedTeamIds = matchingTeams + .Sort(t => t.Value.Name, request.Args.SortDirection) + .Select(t => t.Key) + .ToArray(); + + break; + } + } + } + + // these are the teamIds we need full info for, so pull all players on these teams + var paged = _pagingService.Page(sortedTeamIds, request.PagingArgs); + var pagedTeamIds = paged.Items; + + var teamPlayers = await query + .Select(p => new + { + p.Id, + p.ApprovedName, + p.Mode, + p.IsReady, + p.Role, + p.SessionBegin, + p.SessionEnd, + p.WhenCreated, + p.TeamId, + Sponsor = new + { + p.Sponsor.Id, + p.Sponsor.Name, + p.Sponsor.Logo + } + }) + .Where(p => pagedTeamIds.Contains(p.TeamId)) + .GroupBy(p => p.TeamId) + .ToDictionaryAsync(gr => gr.Key, gr => gr.ToArray(), cancellationToken); + + // pull other return data for the matching teams + var ticketCounts = await _ticketService + .GetTeamTickets(pagedTeamIds) + .Where(t => t.Status != "Closed") + .GroupBy(t => t.TeamId) + .ToDictionaryAsync(gr => gr.Key, gr => gr.Count(), cancellationToken); + + // we also need to know the expected length of a session in the game to determine + // if teams have been extended + var gameSessionMinutes = await _store + .WithNoTracking() + .Where(g => g.Id == request.GameId) + .Select(g => g.SessionMinutes) + .SingleAsync(cancellationToken); + + var teamSolves = await _teamService.GetSolves(pagedTeamIds, cancellationToken); + + return new GameCenterTeamsResults + { + Teams = new PagedEnumerable + { + Paging = paged.Paging, + Items = pagedTeamIds.Select(tId => + { + var players = teamPlayers[tId].Where(p => p.Role != PlayerRole.Member); + var captain = teamPlayers[tId].Single(p => p.Role == PlayerRole.Manager); + var solves = teamSolves[tId]; + + return new GameCenterTeamsResultsTeam + { + Id = tId, + Name = captain.ApprovedName, + IsExtended = gameSessionMinutes < (captain.SessionEnd - captain.SessionBegin).TotalMinutes, + Captain = new GameCenterTeamsPlayer + { + Id = captain.Id, + Name = captain.ApprovedName, + IsReady = captain.IsReady, + Sponsor = new SimpleSponsor + { + Id = captain.Sponsor.Id, + Name = captain.Sponsor.Name, + Logo = captain.Sponsor.Logo + } + }, + Players = players.Select(p => new GameCenterTeamsPlayer + { + Id = p.Id, + Name = p.ApprovedName, + IsReady = p.IsReady, + Sponsor = new SimpleSponsor + { + Id = p.Sponsor.Id, + Name = p.Sponsor.Name, + Logo = p.Sponsor.Logo + } + }), + ChallengesCompleteCount = solves.Complete, + ChallengesPartialCount = solves.Partial, + ChallengesRemainingCount = solves.Unscored, + IsReady = captain.IsReady && players.All(p => p.IsReady), + Rank = teamRanks.TryGetValue(tId, out var teamRankData) ? teamRankData.Rank : null, + RegisteredOn = captain.WhenCreated, + Session = new GameCenterTeamsSession + { + Start = matchingTeams[tId].SessionBegin.HasValue ? matchingTeams[tId].SessionBegin.Value.ToUnixTimeMilliseconds() : default(long?), + End = matchingTeams[tId].SessionEnd.HasValue ? matchingTeams[tId].SessionEnd.Value.ToUnixTimeMilliseconds() : default(long?), + TimeRemainingMs = matchingTeams[tId].TimeRemaining, + TimeSinceStartMs = matchingTeams[tId].TimeSinceStart + }, + TicketCount = ticketCounts.TryGetValue(tId, out int value) ? value : 0 + }; + }) + } + }; + } +} diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterTeams/GetGameCenterTeamsModels.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterTeams/GetGameCenterTeamsModels.cs new file mode 100644 index 00000000..1044a0a3 --- /dev/null +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterTeams/GetGameCenterTeamsModels.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using Microsoft.Identity.Client; + +namespace Gameboard.Api.Features.Admin; + +public enum GetGameCenterTeamsSort +{ + Rank, + TeamName, + TimeRemaining, + TimeStart +} + +public sealed class GetGameCenterTeamsArgs +{ + public bool? HasScored { get; set; } + public PlayerMode? PlayerMode { get; set; } + public string Search { get; set; } + public GameCenterTeamsStatus? Status { get; set; } + + // page and sort + public int? PageNumber { get; set; } + public GetGameCenterTeamsSort? Sort { get; set; } + public SortDirection? SortDirection { get; set; } +} + +public sealed class GameCenterTeamsResults +{ + public required PagedEnumerable Teams { get; set; } +} + +public sealed class GameCenterTeamsResultsTeam +{ + public required string Id { get; set; } + public required string Name { get; set; } + + public required GameCenterTeamsPlayer Captain { get; set; } + public required int ChallengesCompleteCount { get; set; } + public required int ChallengesPartialCount { get; set; } + public required int ChallengesRemainingCount { get; set; } + public required bool IsExtended { get; set; } + public required bool? IsReady { get; set; } + public required IEnumerable Players { get; set; } + public required int? Rank { get; set; } + public required DateTimeOffset RegisteredOn { get; set; } + public required GameCenterTeamsSession Session { get; set; } + public required int TicketCount { get; set; } +} + +public enum GameCenterTeamsStatus +{ + Complete, + NotStarted, + Playing +} + +public sealed class GameCenterTeamsPlayer +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required bool IsReady { get; set; } + public required SimpleSponsor Sponsor { get; set; } +} + +public sealed class GameCenterTeamsSession +{ + public required long? Start { get; set; } + public required long? End { get; set; } + public required double? TimeRemainingMs { get; set; } + public required double? TimeSinceStartMs { get; set; } +} diff --git a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpec.cs b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpec.cs index 7a57b80e..607c7a00 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpec.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpec.cs @@ -2,6 +2,7 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using System.Collections.Generic; +using Gameboard.Api.Features.GameEngine; namespace Gameboard.Api; @@ -73,3 +74,22 @@ public sealed class ChallengeSpecSummary public required bool ShowSolutionGuideInCompetitiveMode { get; set; } public required IEnumerable Tags { get; set; } } + +public sealed class ChallengeSpecQuestionPerformance +{ + public required int QuestionRank { get; set; } + public required string Hint { get; set; } + public required string Prompt { get; set; } + public required double PointValue { get; set; } + + public required int CountCorrect { get; set; } + public required int CountSubmitted { get; set; } +} + +public sealed class ChallengeSpecQuestionPerformanceChallenge +{ + public required bool IsComplete { get; set; } + public required bool IsPartial { get; set; } + public required bool IsZero { get; set; } + public required GameEngineGameState State { get; set; } +} diff --git a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecController.cs b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecController.cs index a26678dd..983ffe0f 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecController.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecController.cs @@ -2,8 +2,10 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using System.Threading.Tasks; +using Gameboard.Api.Features.ChallengeSpecs; using Gameboard.Api.Services; using Gameboard.Api.Validators; +using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Distributed; @@ -14,16 +16,19 @@ namespace Gameboard.Api.Controllers [Authorize(AppConstants.DesignerPolicy)] public class ChallengeSpecController : _Controller { + IMediator _mediator; ChallengeSpecService ChallengeSpecService { get; } public ChallengeSpecController( ILogger logger, IDistributedCache cache, + IMediator mediator, ChallengeSpecValidator validator, ChallengeSpecService challengespecService ) : base(logger, cache, validator) { ChallengeSpecService = challengespecService; + _mediator = mediator; } /// @@ -80,6 +85,14 @@ public Task Delete([FromRoute] string id) public Task List([FromQuery] SearchFilter model) => ChallengeSpecService.List(model); + /// + /// Load solve performance for the challenge spec + /// + [HttpGet("/api/challengespecs/{challengeSpecId}/question-performance")] + [Authorize(AppConstants.AdminPolicy)] + public Task GetQuestionPerformance([FromRoute] string challengeSpecId) + => _mediator.Send(new GetChallengeSpecQuestionPerformanceQuery(challengeSpecId)); + /// /// Sync challengespec name/description with external source /// diff --git a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecService.cs b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecService.cs index d63543cb..e0ce04af 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecService.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecService.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; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -16,13 +17,16 @@ namespace Gameboard.Api.Services; public class ChallengeSpecService : _Service { + private readonly IJsonService _jsonService; private readonly INowService _now; private readonly ISlugService _slug; private readonly IStore _store; + IGameEngineService GameEngine { get; } public ChallengeSpecService ( + IJsonService jsonService, ILogger logger, IMapper mapper, INowService now, @@ -32,6 +36,7 @@ public ChallengeSpecService IGameEngineService gameEngine ) : base(logger, mapper, options) { + _jsonService = jsonService; _now = now; _slug = slug; _store = store; @@ -88,6 +93,89 @@ public async Task AddOrUpdate(NewChallengeSpec model) return Mapper.Map(entity); } + public async Task> GetQuestionPerformance(string challengeSpecId, CancellationToken cancellationToken) + { + var results = await GetQuestionPerformance(new string[] { challengeSpecId }, cancellationToken); + if (!results.Any()) + throw new ArgumentException($"Couldn't load performance for specId {challengeSpecId}", nameof(challengeSpecId)); + + return results[challengeSpecId]; + } + + public async Task>> GetQuestionPerformance(IEnumerable challengeSpecIds, CancellationToken cancellationToken) + { + // pull raw data + var data = await _store + .WithNoTracking() + .Where(c => challengeSpecIds.Contains(c.SpecId)) + .Select(c => new + { + c.Id, + c.SpecId, + c.Points, + c.Score, + c.StartTime, + c.State + }) + .GroupBy(c => c.SpecId) + .ToDictionaryAsync(gr => gr.Key, gr => gr.Select(c => new + { + IsComplete = c.Points >= c.Score, + IsPartial = c.Points < c.Score && c.Points > 0, + IsZero = c.Score == 0, + c.StartTime, + c.State, + }), cancellationToken); + + // because of topo architecture, we can't tell the caller anything about the challenge unless it's been attempted + // at least once + if (data is null || !data.Values.Any(v => v.Any())) + return null; + + // deserialize states + var questionPerformance = new Dictionary>(); + foreach (var challengeSpecId in data.Keys) + { + var challenges = data[challengeSpecId] + .OrderByDescending(c => c.StartTime) + .Select(c => new ChallengeSpecQuestionPerformanceChallenge + { + IsComplete = c.IsComplete, + IsPartial = c.IsPartial, + IsZero = c.IsZero, + State = _jsonService.Deserialize(c.State) + }); + + var allQuestions = challenges + .Select(c => c.State) + .Select(s => s.Challenge) + .SelectMany(c => c.Questions) + .GroupBy(q => q.Text) + .ToDictionary(q => q.Key, q => q.ToArray()); + + if (!challenges.Any()) + continue; + + var exemplarState = challenges.First().State; + var maxWeight = exemplarState.Challenge.Questions.Select(q => q.Weight).Sum(); + var questions = exemplarState.Challenge.Questions.Select((q, index) => new ChallengeSpecQuestionPerformance + { + QuestionRank = index + 1, + Hint = q.Hint, + Prompt = q.Text, + // it's apparently a topo rule that weight can be zero and that means that the challenge weight is equally divided + PointValue = maxWeight == 0 ? (exemplarState.Challenge.MaxPoints / exemplarState.Challenge.Questions.Count()) : exemplarState.Challenge.MaxPoints * (q.Weight / maxWeight), + CountCorrect = allQuestions[q.Text].Count(answeredQ => answeredQ.IsCorrect), + CountSubmitted = allQuestions[q.Text].Count(answeredQ => answeredQ.IsGraded) + }).OrderBy(q => q.QuestionRank); + + // GB will need special topo access anyway if we want to show support people the answers + questionPerformance.Add(challengeSpecId, questions); + } + + return questionPerformance; + } + public async Task Retrieve(string id) => Mapper.Map(await _store.FirstOrDefaultAsync(s => s.Id == id, CancellationToken.None)); @@ -152,8 +240,8 @@ public async Task SyncActiveSpecs(CancellationToken cancellationToken) foreach (var spec in activeSpecs) { - if (externalSpecs.ContainsKey(spec.ExternalId)) - SyncSpec(spec, externalSpecs[spec.ExternalId]); + if (externalSpecs.TryGetValue(spec.ExternalId, out ExternalSpec value)) + SyncSpec(spec, value); } await _store.SaveUpdateRange(activeSpecs); diff --git a/src/Gameboard.Api/Features/ChallengeSpec/Requests/GetChallengeSpecQuestionPerformance.cs b/src/Gameboard.Api/Features/ChallengeSpec/Requests/GetChallengeSpecQuestionPerformance.cs new file mode 100644 index 00000000..83010cfb --- /dev/null +++ b/src/Gameboard.Api/Features/ChallengeSpec/Requests/GetChallengeSpecQuestionPerformance.cs @@ -0,0 +1,63 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Data; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Authorizers; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.ChallengeSpecs; + +public record GetChallengeSpecQuestionPerformanceQuery(string ChallengeSpecId) : IRequest; + +internal class GetChallengeSpecQuestionPerformanceHandler : IRequestHandler +{ + private readonly ChallengeSpecService _challengeSpecService; + private readonly EntityExistsValidator _specExists; + private readonly IStore _store; + private readonly UserRoleAuthorizer _userRoleAuthorizer; + private readonly IValidatorService _validatorService; + + public GetChallengeSpecQuestionPerformanceHandler + ( + ChallengeSpecService challengeSpecService, + EntityExistsValidator specExists, + IStore store, + UserRoleAuthorizer userRoleAuthorizer, + IValidatorService validatorService + ) + { + _challengeSpecService = challengeSpecService; + _specExists = specExists; + _store = store; + _userRoleAuthorizer = userRoleAuthorizer; + _validatorService = validatorService; + } + + public async Task Handle(GetChallengeSpecQuestionPerformanceQuery request, CancellationToken cancellationToken) + { + // auth/validate + _userRoleAuthorizer.AllowRoles(UserRole.Support, UserRole.Director, UserRole.Admin); + _validatorService.AddValidator(_specExists.UseProperty(r => r.ChallengeSpecId)); + await _validatorService.Validate(request, cancellationToken); + + // pull raw data + var specData = await _store + .WithNoTracking() + .Include(cs => cs.Game) + .Select(cs => new { cs.Id, cs.Name, cs.GameId, GameName = cs.Game.Name, cs.Points }) + .SingleAsync(cs => cs.Id == request.ChallengeSpecId, cancellationToken); + var performance = await _challengeSpecService.GetQuestionPerformance(request.ChallengeSpecId, cancellationToken); + + return new GetChallengeSpecQuestionPerformanceResult + { + ChallengeSpec = new SimpleEntity { Id = specData.Id, Name = specData.Name }, + MaxPossibleScore = specData.Points, + Game = new SimpleEntity { Id = specData.GameId, Name = specData.GameName }, + Questions = performance + }; + } +} diff --git a/src/Gameboard.Api/Features/ChallengeSpec/Requests/GetChallengeSpecQuestionPerformanceModels.cs b/src/Gameboard.Api/Features/ChallengeSpec/Requests/GetChallengeSpecQuestionPerformanceModels.cs new file mode 100644 index 00000000..238ce9d9 --- /dev/null +++ b/src/Gameboard.Api/Features/ChallengeSpec/Requests/GetChallengeSpecQuestionPerformanceModels.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Gameboard.Api.Features.ChallengeSpecs; + +public sealed class GetChallengeSpecQuestionPerformanceResult +{ + public required SimpleEntity ChallengeSpec { get; set; } + public required double MaxPossibleScore { get; set; } + public required SimpleEntity Game { get; set; } + public required IEnumerable Questions { get; set; } +} diff --git a/src/Gameboard.Api/Features/Game/Game.cs b/src/Gameboard.Api/Features/Game/Game.cs index 0b1e8c48..947ada48 100644 --- a/src/Gameboard.Api/Features/Game/Game.cs +++ b/src/Gameboard.Api/Features/Game/Game.cs @@ -4,8 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Gameboard.Api.Features.GameEngine; -using MediatR; namespace Gameboard.Api; @@ -50,6 +48,7 @@ public class GameDetail public string CardText2 { get; set; } public string CardText3 { get; set; } public string Mode { get; set; } + public bool IsFeatured { get; set; } public PlayerMode PlayerMode { get; set; } public bool ShowOnHomePageInPracticeMode { get; set; } } @@ -82,6 +81,8 @@ public class GameSearchFilter : SearchFilter private const string PresentFilter = "present"; private const string FutureFilter = "future"; + public bool? IsFeatured { get; set; } + public bool WantsAdvanceable => Filter.Contains(AdvanceableFilter); public bool WantsCompetitive => Filter.Contains(CompetitiveFilter); public bool WantsPractice => Filter.Contains(PracticeFilter); diff --git a/src/Gameboard.Api/Features/Game/GameService.cs b/src/Gameboard.Api/Features/Game/GameService.cs index 28439c8a..fdf2553a 100644 --- a/src/Gameboard.Api/Features/Game/GameService.cs +++ b/src/Gameboard.Api/Features/Game/GameService.cs @@ -133,6 +133,9 @@ public Task Delete(string id) if (model == null) return q; + if (model.IsFeatured.HasValue) + q = q.Where(g => g.IsFeatured == model.IsFeatured); + if (model.WantsAdvanceable) q = q.Where(g => g.GameEnd > now); diff --git a/src/Gameboard.Api/Features/GameEngine/Services/GameEngineService.cs b/src/Gameboard.Api/Features/GameEngine/Services/GameEngineService.cs index a7ff595d..fde8ce2c 100644 --- a/src/Gameboard.Api/Features/GameEngine/Services/GameEngineService.cs +++ b/src/Gameboard.Api/Features/GameEngine/Services/GameEngineService.cs @@ -42,14 +42,12 @@ public class GameEngineService : _Service, IGameEngineService IAlloyApiClient Alloy { get; } private readonly ICrucibleService _crucible; - private readonly IGameEngineStore _store; private readonly IJsonService _jsonService; private readonly IVmUrlResolver _vmUrlResolver; public GameEngineService( IJsonService jsonService, ILogger logger, - IGameEngineStore store, IMapper mapper, CoreOptions options, ITopoMojoApiClient mojo, @@ -61,7 +59,6 @@ IVmUrlResolver vmUrlResolver _jsonService = jsonService; _crucible = crucible; Mojo = mojo; - _store = store; _vmUrlResolver = vmUrlResolver; } @@ -352,6 +349,7 @@ public async Task CompleteGamespace(string id, GameEngineType gameEngineType) } public Task ExtendSession(Data.Challenge entity, DateTimeOffset sessionEnd) + => ExtendSession(entity.Id, sessionEnd, entity.GameEngineType); public Task ExtendSession(string challengeId, DateTimeOffset sessionEnd, GameEngineType gameEngineType) diff --git a/src/Gameboard.Api/Features/Reports/Extensions/IQueryableExtensions.cs b/src/Gameboard.Api/Features/Reports/Extensions/IQueryableExtensions.cs deleted file mode 100644 index 7b7cf1e5..00000000 --- a/src/Gameboard.Api/Features/Reports/Extensions/IQueryableExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; - -namespace Gameboard.Api.Features.Reports; - -public static class ReportsIQueryableExtensions -{ - public static IOrderedEnumerable Sort(this IEnumerable enumerable, Func orderBy, SortDirection sortDirection = SortDirection.Asc) - { - if (sortDirection == SortDirection.Asc) - return enumerable.OrderBy(orderBy); - - return enumerable.OrderByDescending(orderBy); - } - - public static IOrderedQueryable Sort(this IOrderedQueryable query, Expression> orderBy, SortDirection sortDirection = SortDirection.Asc) - { - if (sortDirection == SortDirection.Asc) - return query.OrderBy(orderBy); - - return query.OrderByDescending(orderBy); - } -} diff --git a/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportService.cs index e6a2c2a9..00ec15f1 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportService.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportService.cs @@ -132,7 +132,7 @@ public async Task> GetRawResults(ChallengesR .Where(c => c.Score >= c.Points) .Where(c => c.PlayerMode == PlayerMode.Competition) .Count() - }); + }, cancellationToken); var preSortResults = specs.Select(cs => { diff --git a/src/Gameboard.Api/Features/Teams/Services/TeamService.cs b/src/Gameboard.Api/Features/Teams/Services/TeamService.cs index 3f63facd..1e8db6cd 100644 --- a/src/Gameboard.Api/Features/Teams/Services/TeamService.cs +++ b/src/Gameboard.Api/Features/Teams/Services/TeamService.cs @@ -20,6 +20,7 @@ public interface ITeamService Task DeleteTeam(string teamId, SimpleEntity actingUser, CancellationToken cancellationToken); Task EndSession(string teamId, User actor, CancellationToken cancellationToken); Task ExtendSession(ExtendTeamSessionRequest request, CancellationToken cancellationToken); + Task> GetSolves(IEnumerable teamIds, CancellationToken cancellationToken); Task> GetChallengesWithActiveGamespace(string teamId, string gameId, CancellationToken cancellationToken); Task GetExists(string teamId); Task GetGameId(string teamId, CancellationToken cancellationToken); @@ -165,6 +166,32 @@ await _teamHubService.SendTeamSessionExtended(new TeamState return captainModel; } + public async Task> GetSolves(IEnumerable teamIds, CancellationToken cancellationToken) + { + var solveData = await _store + .WithNoTracking() + .Where(c => teamIds.Contains(c.TeamId)) + .GroupBy(c => c.TeamId) + .ToDictionaryAsync(gr => gr.Key, gr => new TeamChallengeSolveCounts + { + Complete = gr.Count(c => c.Score >= c.Points), + Partial = gr.Count(c => c.Score < c.Points && c.Score > 0), + Unscored = gr.Count(c => c.Score == 0) + }, cancellationToken); + + foreach (var missingTeamId in teamIds.Where(tId => !solveData.ContainsKey(tId))) + { + solveData.Add(missingTeamId, new TeamChallengeSolveCounts + { + Complete = 0, + Partial = 0, + Unscored = 0 + }); + } + + return solveData; + } + public async Task> GetChallengesWithActiveGamespace(string teamId, string gameId, CancellationToken cancellationToken) => await _store .List() diff --git a/src/Gameboard.Api/Features/Teams/TeamsModels.cs b/src/Gameboard.Api/Features/Teams/TeamsModels.cs index 4880b9b0..04434ebc 100644 --- a/src/Gameboard.Api/Features/Teams/TeamsModels.cs +++ b/src/Gameboard.Api/Features/Teams/TeamsModels.cs @@ -25,6 +25,13 @@ public class ResetTeamSessionRequest public required bool UnenrollTeam { get; set; } } +public sealed class TeamChallengeSolveCounts +{ + public required int Complete { get; set; } + public required int Partial { get; set; } + public required int Unscored { get; set; } +} + public class TeamInvitation { public string Code { get; set; } diff --git a/src/Gameboard.Api/Features/Ticket/Ticket.cs b/src/Gameboard.Api/Features/Ticket/Ticket.cs index 1d8eec7e..036bd8fd 100644 --- a/src/Gameboard.Api/Features/Ticket/Ticket.cs +++ b/src/Gameboard.Api/Features/Ticket/Ticket.cs @@ -145,6 +145,9 @@ public class TicketSearchFilter : SearchFilter public bool WantsNotClosed => Filter.Contains(NotClosedFilter); public bool WantsAssignedToMe => Filter.Contains(AssignedToMeFilter); public bool WantsUnassigned => Filter.Contains(UnassignedFilter); + + // sane filters + public string GameId { get; set; } public string WithAllLabels { get; set; } // Ordering logic - set up string constants, then check if they're in the request diff --git a/src/Gameboard.Api/Features/Ticket/TicketService.cs b/src/Gameboard.Api/Features/Ticket/TicketService.cs index ab6423e3..29a7d06b 100644 --- a/src/Gameboard.Api/Features/Ticket/TicketService.cs +++ b/src/Gameboard.Api/Features/Ticket/TicketService.cs @@ -180,6 +180,11 @@ await _mediator.Publish(new TicketCreatedNotification .Where(t => t.Status != "Closed"); } + public IQueryable GetTeamTickets(IEnumerable teamIds) + => _store + .WithNoTracking() + .Where(t => teamIds.Contains(t.TeamId)); + public async Task Update(ChangedTicket model, string actorId, bool sudo) { // need the creator to send updates @@ -267,6 +272,9 @@ public async Task> List(TicketSearchFilter model, str userTeams.Any(i => i == t.TeamId)); } + if (model.GameId.IsNotEmpty()) + q = q.Where(t => t.Challenge.GameId == model.GameId || t.Player.GameId == model.GameId); + // Ordering in descending order if (model.WantsOrderingDesc) { From 0c1261b04e3b1b444edec3ace8061f85571167e9 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 12 Jun 2024 17:33:01 -0400 Subject: [PATCH 11/12] Fix compiler warning --- src/Gameboard.Api/Features/Ticket/Ticket.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Gameboard.Api/Features/Ticket/Ticket.cs b/src/Gameboard.Api/Features/Ticket/Ticket.cs index 036bd8fd..e6e0a8e4 100644 --- a/src/Gameboard.Api/Features/Ticket/Ticket.cs +++ b/src/Gameboard.Api/Features/Ticket/Ticket.cs @@ -171,7 +171,6 @@ public class TicketSearchFilter : SearchFilter public class TicketReportFilter : TicketSearchFilter { - public string GameId { get; set; } public bool WantsGame => !GameId.IsEmpty(); public DateTimeOffset StartRange { get; set; } public DateTimeOffset EndRange { get; set; } From 03b12cf814c2efeb75b4c0952d3c2b8f9c0bee2b Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 13 Jun 2024 10:17:56 -0400 Subject: [PATCH 12/12] Fix team game calculation for game center landing. --- .../Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs index b9746063..65887caa 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs @@ -67,7 +67,7 @@ public async Task Handle(GetGameCenterContextQuery request, C IsLive = g.GameStart <= nowish && g.GameEnd >= nowish, g.IsPracticeMode, IsRegistrationActive = g.RegistrationType == GameRegistrationType.Open && g.RegistrationOpen <= nowish && g.RegistrationClose >= nowish, - IsTeamGame = g.MinTeamSize > 1 + IsTeamGame = g.MaxTeamSize > 1 }) .SingleAsync(g => g.Id == request.GameId, cancellationToken);