From c53ec341efe778a339173ff4b68e11018e6c7b64 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 6 Jan 2025 12:39:24 -0500 Subject: [PATCH 01/26] Resolves #522 --- .../Features/Teams/StartTeamSessionTests.cs | 104 +++++++++++++++++- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs index e73c15b2..63cbad49 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Teams/StartTeamSessionTests.cs @@ -1,7 +1,10 @@ using Gameboard.Api.Common; using Gameboard.Api.Data; +using Gameboard.Api.Features.Reports; using Gameboard.Api.Features.Teams; using Gameboard.Api.Structure; +using Microsoft.EntityFrameworkCore; +using ServiceStack; namespace Gameboard.Api.Tests.Integration.Teams; @@ -61,7 +64,7 @@ await _testContext.WithDataState(state => state.Add(new Data.Game { Id = gameId, - MinTeamSize = 2, + MinTeamSize = 1, MaxTeamSize = 5, GameStart = DateTimeOffset.UtcNow, GameEnd = DateTimeOffset.UtcNow.AddDays(1), @@ -203,7 +206,6 @@ await _testContext.WithDataState(state => response.IsSuccessStatusCode.ShouldBeFalse(); } - // Users can team up, leave the team, join a different team, then start sessions on the original and the new team // Admins can start sessions for non-admins [Theory, GbIntegrationAutoData] public async Task Team_WhenAdminStartingOtherTeamSession_Starts @@ -235,7 +237,101 @@ await _testContext.WithDataState(state => .CreateHttpClientWithAuthRole(UserRoleKey.Admin) .PutAsync($"api/player/{targetPlayerId}/start", null); - // then the response should have a failure code - response.IsSuccessStatusCode.ShouldBeTrue(); + // then the session should have successfully begun + var sessionStartTime = await _testContext + .GetValidationDbContext() + .Players + .AsNoTracking() + .Where(p => p.Id == targetPlayerId) + .Select(p => p.SessionBegin) + .SingleOrDefaultAsync(); + + sessionStartTime.ShouldNotBe(DateTimeOffset.MinValue); + } + + // Users can team up, leave the team, join a different team, then start sessions on the original and the new team + [Theory, GbIntegrationAutoData] + public async Task Team_WhenTeamedUpThenLeave_CanBothStart + ( + string gameId, + string player1Id, + string player2Id, + string teamId, + string user1Id, + string user2Id, + IFixture fixture + ) + { + // given two players + await _testContext.WithDataState(state => + { + state.Add(fixture, game => + { + game.Id = gameId; + game.MaxTeamSize = 2; + game.Players = + [ + state.Build(fixture, p => + { + p.Id = player1Id; + p.Role = PlayerRole.Manager; + p.TeamId = teamId; + p.User = state.Build(fixture, u => u.Id = user1Id); + }), + state.Build(fixture, p => + { + p.Id = player2Id; + p.Role = PlayerRole.Manager; + p.TeamId = fixture.Create(); + p.User = state.Build(fixture, u => u.Id = user2Id); + }) + ]; + }); + }); + + // when they team up, the second player leaves, and both try to start their sessions + var player1Http = _testContext.CreateHttpClientWithActingUser(u => u.Id = user1Id); + var player2Http = _testContext.CreateHttpClientWithActingUser(u => u.Id = user2Id); + + // generate invite + var inviteCode = await player1Http + .PostAsync($"api/player/{player1Id}/invite", null) + .DeserializeResponseAs(); + + // team up + var player2 = await player2Http + .PostAsync($"api/player/enlist", new PlayerEnlistment + { + Code = inviteCode.Code, + PlayerId = player2Id, + UserId = user2Id + }.ToJsonBody()) + .DeserializeResponseAs(); + + // player 2 unenrolls + await player2Http.DeleteAsync($"api/player/{player2Id}"); + + // player 2 re-enrolls on a different team + var player2ReEnrolled = await player2Http.PostAsync($"api/player", new NewPlayer + { + GameId = gameId, + UserId = user2Id + }.ToJsonBody()) + .DeserializeResponseAs(); + + // both sessions launch + var response1 = await player1Http.PutAsync($"api/player/{player1Id}/start", null); + var response2 = await player2Http.PutAsync($"api/player/{player2ReEnrolled.Id}/start", null); + + // both users should have started sessions + var finalPlayers = await _testContext + .GetValidationDbContext() + .Players + .AsNoTracking() + .Where(p => p.GameId == gameId) + .ToArrayAsync(); + + finalPlayers.ShouldContain(p => p.UserId == user1Id && p.SessionBegin != DateTimeOffset.MinValue); + finalPlayers.ShouldContain(p => p.UserId == user2Id && p.SessionBegin != DateTimeOffset.MinValue); } } From edbc0c25e0c478eecdc5683652aa1e67d1a10579 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 7 Jan 2025 09:14:42 -0500 Subject: [PATCH 02/26] WIP export --- .../Common/Services/ZipService.cs | 30 +++++++++++++++++++ src/Gameboard.Api/Features/Game/Game.cs | 7 +++++ .../Features/Game/GameImportExportService.cs | 6 ++++ .../Game/Requests/ExportGame/ExportGame.cs | 17 +++++++++++ .../Requests/ExportGame/ExportGameModels.cs | 6 ++++ 5 files changed, 66 insertions(+) create mode 100644 src/Gameboard.Api/Common/Services/ZipService.cs create mode 100644 src/Gameboard.Api/Features/Game/GameImportExportService.cs create mode 100644 src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGame.cs create mode 100644 src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs diff --git a/src/Gameboard.Api/Common/Services/ZipService.cs b/src/Gameboard.Api/Common/Services/ZipService.cs new file mode 100644 index 00000000..e4da27e5 --- /dev/null +++ b/src/Gameboard.Api/Common/Services/ZipService.cs @@ -0,0 +1,30 @@ +using System.IO; +using System.IO.Compression; + +namespace Gameboard.Api.Common.Services; + +public interface IZipService +{ + public ZipArchive Zip(string outputPath, string[] filePaths, string relativeRoot = null); +} + +internal sealed class ZipService : IZipService +{ + public ZipArchive Zip(string outputPath, string[] filePaths, string relativeRoot = null) + { + using var archive = ZipFile.Open(outputPath, ZipArchiveMode.Create); + + foreach (var path in filePaths) + { + var entryPath = path; + if (relativeRoot.IsNotEmpty()) + { + entryPath = Path.GetRelativePath(relativeRoot, path); + } + + archive.CreateEntryFromFile(path, entryPath); + } + + return archive; + } +} diff --git a/src/Gameboard.Api/Features/Game/Game.cs b/src/Gameboard.Api/Features/Game/Game.cs index 11efd317..debae359 100644 --- a/src/Gameboard.Api/Features/Game/Game.cs +++ b/src/Gameboard.Api/Features/Game/Game.cs @@ -159,3 +159,10 @@ public sealed class GameActiveTeam public required string TeamId { get; set; } public required DateTimeOffset SessionEnd { get; set; } } + +public sealed class GameImportExport +{ + public required string CardImageUrl { get; set; } + public required string MapImageUrl { get; set; } + public required Data.Game Game { get; set; } +} diff --git a/src/Gameboard.Api/Features/Game/GameImportExportService.cs b/src/Gameboard.Api/Features/Game/GameImportExportService.cs new file mode 100644 index 00000000..22c1f250 --- /dev/null +++ b/src/Gameboard.Api/Features/Game/GameImportExportService.cs @@ -0,0 +1,6 @@ +namespace Gameboard.Api.Features.Games; + +public interface IGameImportExportService +{ + +} diff --git a/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGame.cs b/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGame.cs new file mode 100644 index 00000000..3623a7d2 --- /dev/null +++ b/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGame.cs @@ -0,0 +1,17 @@ +using System; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Gameboard.Api.Features.Games; + +public record ExportGameCommand(string GameId) : IRequest; + +internal sealed class ExportGameHandler : IRequestHandler +{ + public Task Handle(ExportGameCommand request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} diff --git a/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs b/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs new file mode 100644 index 00000000..5387b008 --- /dev/null +++ b/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs @@ -0,0 +1,6 @@ +namespace Gameboard.Api.Features.Games; + +public sealed class ExportGameResult +{ + public required string GameId { get; set; } +} From 52e287939503b58398a3cfa9ff4a897d78a88d64 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 7 Jan 2025 09:54:31 -0500 Subject: [PATCH 03/26] Fix an issue that caused the game name to be null in ticket markdown export --- src/Gameboard.Api/Features/Ticket/TicketService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Gameboard.Api/Features/Ticket/TicketService.cs b/src/Gameboard.Api/Features/Ticket/TicketService.cs index 3c217ba0..dd86f86a 100644 --- a/src/Gameboard.Api/Features/Ticket/TicketService.cs +++ b/src/Gameboard.Api/Features/Ticket/TicketService.cs @@ -626,6 +626,7 @@ private async Task BuildTicketUser(Data.User user) .Include(c => c.Activity) .ThenInclude(a => a.Assignee) .Include(c => c.Challenge) + .ThenInclude(c => c.Game) .Include(c => c.Player) .ThenInclude(p => p.Game); } From 5a4d568fb19bff799ed8acf88235c202b278467d Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 7 Jan 2025 11:57:32 -0500 Subject: [PATCH 04/26] Fixed an issue where challenges from games with no end date wouldn't appear in the ticket challenge picker --- .../Features/Challenge/Services/ChallengeService.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs index f9888ab7..4e050656 100644 --- a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs +++ b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs @@ -285,7 +285,13 @@ public async Task ListByUser(string uid) var practiceChallengesCutoff = _now.Get().AddDays(-7); q = q.Include(c => c.Player).Include(c => c.Game); // band-aid for #296 - q = q.Where(c => c.Game.GameEnd > recent || (c.PlayerMode == PlayerMode.Practice && c.StartTime >= practiceChallengesCutoff)); + q = q.Where + ( + c => + c.EndTime > recent || + c.Game.GameEnd > recent || + (c.PlayerMode == PlayerMode.Practice && c.StartTime >= practiceChallengesCutoff) + ); q = q.OrderByDescending(p => p.StartTime); return await Mapper.ProjectTo(q).ToArrayAsync(); From 9d81b6ef767f9048990586155dd7038d7bd5150d Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 8 Jan 2025 16:23:18 -0500 Subject: [PATCH 05/26] Add cancellation tokens to report endpoints, fix denorm bug for new scoring teams. --- .../Features/Reports/ReportsController.cs | 73 +++++++++---------- .../Scores/ScoreDenormalizationService.cs | 17 +++-- .../Features/Scores/ScoringService.cs | 5 +- .../Features/Teams/Requests/EndTeamSession.cs | 5 +- .../Features/Teams/TeamController.cs | 2 + 5 files changed, 55 insertions(+), 47 deletions(-) diff --git a/src/Gameboard.Api/Features/Reports/ReportsController.cs b/src/Gameboard.Api/Features/Reports/ReportsController.cs index 6fbd43e7..a579c61e 100644 --- a/src/Gameboard.Api/Features/Reports/ReportsController.cs +++ b/src/Gameboard.Api/Features/Reports/ReportsController.cs @@ -17,77 +17,76 @@ public class ReportsController(IMediator mediator, IReportsService service) : Co private readonly IReportsService _service = service; [HttpGet] - public async Task> List() - => await _service.List(); + public Task> List() + => _service.List(); [HttpGet("challenges")] - public Task> GetChallengesReport([FromQuery] ChallengesReportParameters parameters, [FromQuery] PagingArgs paging) - => _mediator.Send(new GetChallengesReportQuery(parameters, paging)); + public Task> GetChallengesReport([FromQuery] ChallengesReportParameters parameters, [FromQuery] PagingArgs paging, CancellationToken cancellationToken) + => _mediator.Send(new GetChallengesReportQuery(parameters, paging), cancellationToken); [HttpGet("enrollment")] - public Task> GetEnrollmentReportSummary([FromQuery] EnrollmentReportParameters parameters, [FromQuery] PagingArgs paging) - => _mediator.Send(new EnrollmentReportSummaryQuery(parameters, paging)); + public Task> GetEnrollmentReportSummary([FromQuery] EnrollmentReportParameters parameters, [FromQuery] PagingArgs paging, CancellationToken cancellationToken) + => _mediator.Send(new EnrollmentReportSummaryQuery(parameters, paging), cancellationToken); [HttpGet("enrollment/stats")] - public Task GetEnrollmentReportSummaryStats([FromQuery] EnrollmentReportParameters parameters) - => _mediator.Send(new EnrollmentReportSummaryStatsQuery(parameters)); + public Task GetEnrollmentReportSummaryStats([FromQuery] EnrollmentReportParameters parameters, CancellationToken cancellationToken) + => _mediator.Send(new EnrollmentReportSummaryStatsQuery(parameters), cancellationToken); [HttpGet("enrollment/trend")] - public Task GetEnrollmentReportLineChart([FromQuery] EnrollmentReportParameters parameters) - => _mediator.Send(new EnrollmentReportLineChartQuery(parameters)); + public Task GetEnrollmentReportLineChart([FromQuery] EnrollmentReportParameters parameters, CancellationToken cancellationToken) + => _mediator.Send(new EnrollmentReportLineChartQuery(parameters), cancellationToken); [HttpGet("enrollment/by-game")] - public Task> GetEnrollmentReportByGame([FromQuery] EnrollmentReportParameters parameters, [FromQuery] PagingArgs pagingArgs) - => _mediator.Send(new EnrollmentReportByGameQuery(parameters, pagingArgs)); + public Task> GetEnrollmentReportByGame([FromQuery] EnrollmentReportParameters parameters, [FromQuery] PagingArgs pagingArgs, CancellationToken cancellationToken) + => _mediator.Send(new EnrollmentReportByGameQuery(parameters, pagingArgs), cancellationToken); [HttpGet("feedback")] - public Task> GetFeedbackReport([FromQuery] FeedbackReportParameters request, [FromQuery] PagingArgs pagingArgs) - => _mediator.Send(new FeedbackReportQuery(request, pagingArgs)); + public Task> GetFeedbackReport([FromQuery] FeedbackReportParameters request, [FromQuery] PagingArgs pagingArgs, CancellationToken cancellationToken) + => _mediator.Send(new FeedbackReportQuery(request, pagingArgs), cancellationToken); [HttpGet("feedback-legacy")] - public Task GetFeedbackGameReport([FromQuery] GetFeedbackGameReportQuery query) - => _mediator.Send(query); - + public Task GetFeedbackGameReport([FromQuery] GetFeedbackGameReportQuery query, CancellationToken cancellationToken) + => _mediator.Send(query, cancellationToken); [HttpGet("players")] - public Task> GetPlayersReport([FromQuery] PlayersReportParameters parameters, [FromQuery] PagingArgs pagingArgs) - => _mediator.Send(new GetPlayersReportQuery(parameters, pagingArgs)); + public Task> GetPlayersReport([FromQuery] PlayersReportParameters parameters, [FromQuery] PagingArgs pagingArgs, CancellationToken cancellationToken) + => _mediator.Send(new GetPlayersReportQuery(parameters, pagingArgs), cancellationToken); [HttpGet("practice-area")] - public async Task> GetPracticeModeReport([FromQuery] PracticeModeReportParameters parameters, [FromQuery] PagingArgs paging) - => await _mediator.Send(new PracticeModeReportQuery(parameters, paging)); + public async Task> GetPracticeModeReport([FromQuery] PracticeModeReportParameters parameters, [FromQuery] PagingArgs paging, CancellationToken cancellationToken) + => await _mediator.Send(new PracticeModeReportQuery(parameters, paging), cancellationToken); [HttpGet("practice-area/challenge-spec/{challengeSpecId}")] - public Task GetChallengeDetail([FromQuery] PracticeModeReportParameters parameters, [FromQuery] PracticeModeReportChallengeDetailParameters challengeDetailParameters, [FromQuery] PagingArgs pagingArgs, [FromRoute] string challengeSpecId) - => _mediator.Send(new PracticeModeReportChallengeDetailQuery(challengeSpecId, parameters, challengeDetailParameters, pagingArgs)); + public Task GetChallengeDetail([FromQuery] PracticeModeReportParameters parameters, [FromQuery] PracticeModeReportChallengeDetailParameters challengeDetailParameters, [FromQuery] PagingArgs pagingArgs, [FromRoute] string challengeSpecId, CancellationToken cancellationToken) + => _mediator.Send(new PracticeModeReportChallengeDetailQuery(challengeSpecId, parameters, challengeDetailParameters, pagingArgs), cancellationToken); [HttpGet("practice-area/user/{id}/summary")] - public async Task GetPracticeModeReportPlayerModeSummary([FromRoute] string id, [FromQuery] bool isPractice) - => await _mediator.Send(new PracticeModeReportPlayerModeSummaryQuery(id, isPractice)); + public async Task GetPracticeModeReportPlayerModeSummary([FromRoute] string id, [FromQuery] bool isPractice, CancellationToken cancellationToken) + => await _mediator.Send(new PracticeModeReportPlayerModeSummaryQuery(id, isPractice), cancellationToken); [HttpGet("site-usage")] - public Task GetSiteUsageReport([FromQuery] SiteUsageReportParameters parameters) - => _mediator.Send(new GetSiteUsageReportQuery(parameters)); + public Task GetSiteUsageReport([FromQuery] SiteUsageReportParameters parameters, CancellationToken cancellationToken) + => _mediator.Send(new GetSiteUsageReportQuery(parameters), cancellationToken); [HttpGet("site-usage/challenges")] - public Task> GetSiteUsageREportChallenges([FromQuery] SiteUsageReportParameters reportParameters, [FromQuery] PagingArgs pagingArgs) - => _mediator.Send(new GetSiteUsageReportChallengesQuery(reportParameters, pagingArgs)); + public Task> GetSiteUsageREportChallenges([FromQuery] SiteUsageReportParameters reportParameters, [FromQuery] PagingArgs pagingArgs, CancellationToken cancellationToken) + => _mediator.Send(new GetSiteUsageReportChallengesQuery(reportParameters, pagingArgs), cancellationToken); [HttpGet("site-usage/players")] - public Task> GetSiteUsageReportPlayers([FromQuery] SiteUsageReportParameters reportParameters, [FromQuery] SiteUsageReportPlayersParameters playersParameters, [FromQuery] PagingArgs pagingArgs) - => _mediator.Send(new GetSiteUsageReportPlayersQuery(reportParameters, playersParameters, pagingArgs)); + public Task> GetSiteUsageReportPlayers([FromQuery] SiteUsageReportParameters reportParameters, [FromQuery] SiteUsageReportPlayersParameters playersParameters, [FromQuery] PagingArgs pagingArgs, CancellationToken cancellationToken) + => _mediator.Send(new GetSiteUsageReportPlayersQuery(reportParameters, playersParameters, pagingArgs), cancellationToken); [HttpGet("site-usage/sponsors")] - public Task> GetSiteUsageReportSponsors([FromQuery] SiteUsageReportParameters reportParameters) - => _mediator.Send(new GetSiteUsageReportSponsorsQuery(reportParameters)); + public Task> GetSiteUsageReportSponsors([FromQuery] SiteUsageReportParameters reportParameters, CancellationToken cancellationToken) + => _mediator.Send(new GetSiteUsageReportSponsorsQuery(reportParameters), cancellationToken); [HttpGet("support")] - public Task> GetSupportReport([FromQuery] SupportReportParameters reportParams, [FromQuery] PagingArgs pagingArgs) - => _mediator.Send(new SupportReportQuery(reportParams, pagingArgs)); + public Task> GetSupportReport([FromQuery] SupportReportParameters reportParams, [FromQuery] PagingArgs pagingArgs, CancellationToken cancellationToken) + => _mediator.Send(new SupportReportQuery(reportParams, pagingArgs), cancellationToken); [HttpGet("metaData")] - public Task GetReportMetaData([FromQuery] string reportKey) - => _mediator.Send(new GetMetaDataQuery(reportKey)); + public Task GetReportMetaData([FromQuery] string reportKey, CancellationToken cancellationToken) + => _mediator.Send(new GetMetaDataQuery(reportKey), cancellationToken); [HttpGet("parameter/challenge-specs/{gameId?}")] public Task> GetChallengeSpecs(string gameId = null) diff --git a/src/Gameboard.Api/Features/Scores/ScoreDenormalizationService.cs b/src/Gameboard.Api/Features/Scores/ScoreDenormalizationService.cs index b9786548..76c35b4c 100644 --- a/src/Gameboard.Api/Features/Scores/ScoreDenormalizationService.cs +++ b/src/Gameboard.Api/Features/Scores/ScoreDenormalizationService.cs @@ -104,13 +104,18 @@ private async Task RerankGame(string gameId) var captains = await _teamService.ResolveCaptains(teams.Select(t => t.TeamId).ToArray(), cancellationToken: CancellationToken.None); - var rankedTeams = _scoringService.GetTeamRanks(teams.Select(t => new TeamForRanking + var rankedTeams = _scoringService.GetTeamRanks(teams.Select(t => { - CumulativeTimeMs = t.CumulativeTimeMs, - OverallScore = t.ScoreOverall, - SessionStart = !captains.TryGetValue(t.TeamId, out var captain) || captain?.SessionBegin == DateTimeOffset.MinValue ? null : captain.SessionBegin, - TeamId = t.TeamId - })); + captains.TryGetValue(t.TeamId, out var captain); + + return new TeamForRanking + { + CumulativeTimeMs = t.CumulativeTimeMs, + OverallScore = t.ScoreOverall, + SessionStart = (captain?.SessionBegin == null || captain?.SessionBegin == DateTimeOffset.MinValue) ? null : captain.SessionBegin, + TeamId = t.TeamId + }; + }); foreach (var team in teams) team.Rank = rankedTeams.TryGetValue(team.TeamId, out var teamRank) ? (teamRank ?? 0) : 0; diff --git a/src/Gameboard.Api/Features/Scores/ScoringService.cs b/src/Gameboard.Api/Features/Scores/ScoringService.cs index 7941f136..6f251ce7 100644 --- a/src/Gameboard.Api/Features/Scores/ScoringService.cs +++ b/src/Gameboard.Api/Features/Scores/ScoringService.cs @@ -297,10 +297,11 @@ public async Task GetTeamScore(string teamId, CancellationToken cance public IDictionary GetTeamRanks(IEnumerable teams) { var scoreRank = 0; - TeamForRanking lastScore = null; + var lastScore = default(TeamForRanking); var retVal = new Dictionary(); var ranked = teams - .OrderByDescending(t => t.OverallScore).ThenBy(t => t.CumulativeTimeMs); + .OrderByDescending(t => t.OverallScore) + .ThenBy(t => t.CumulativeTimeMs); foreach (var team in ranked) { diff --git a/src/Gameboard.Api/Features/Teams/Requests/EndTeamSession.cs b/src/Gameboard.Api/Features/Teams/Requests/EndTeamSession.cs index 6069cd2a..ceba530b 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/EndTeamSession.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/EndTeamSession.cs @@ -13,14 +13,15 @@ namespace Gameboard.Api.Features.Teams; public record EndTeamSessionCommand(string TeamId, User Actor) : IRequest; -internal class EndTeamSessionHandler( +internal class EndTeamSessionHandler +( INowService nowService, IUserRolePermissionsService permissionsService, IStore store, TeamExistsValidator teamExists, ITeamService teamService, IValidatorService validatorService - ) : IRequestHandler +) : IRequestHandler { private readonly INowService _nowService = nowService; private readonly IUserRolePermissionsService _permissionsService = permissionsService; diff --git a/src/Gameboard.Api/Features/Teams/TeamController.cs b/src/Gameboard.Api/Features/Teams/TeamController.cs index e9f1f5a7..ae6bd319 100644 --- a/src/Gameboard.Api/Features/Teams/TeamController.cs +++ b/src/Gameboard.Api/Features/Teams/TeamController.cs @@ -61,7 +61,9 @@ public Task> GetTeams([FromRoute] string gameId) public async Task UpdateSession([FromBody] SessionChangeRequest model, CancellationToken cancellationToken) { if (model.SessionEnd is null) + { return await _mediator.Send(new EndTeamSessionCommand(model.TeamId, _actingUserService.Get()), cancellationToken); + } else { var player = await _teamService.ExtendSession From b3f4346d4c798857aea0d84a5ba7a4e33e8ddeb3 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 8 Jan 2025 17:39:05 -0500 Subject: [PATCH 06/26] Cleanup --- .../Requests/ExportGame/ExportGameModels.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs diff --git a/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs b/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs new file mode 100644 index 00000000..5d136e14 --- /dev/null +++ b/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs @@ -0,0 +1,78 @@ +using System; + +namespace Gameboard.Api.Features.Games; + +public sealed class ExportGameResult +{ + public required string ExportBatchId { get; set; } + public required string GameId { get; set; } + public required GameImportExport GameData { get; set; } +} + +public sealed class GameImportExport +{ + public required string ExportedGameId { get; set; } + public required string Name { get; set; } + public required string Competition { get; set; } + public required string Season { get; set; } + public required string Track { get; set; } + public required string Division { get; set; } + public required string Logo { get; set; } + public required string Sponsor { get; set; } + public required string Background { get; set; } + public required string TestCode { get; set; } + public required DateTimeOffset? GameStart { get; set; } + public required DateTimeOffset? GameEnd { get; set; } + public required string GameMarkdown { get; set; } + public required string FeedbackConfig { get; set; } + public required string RegistrationMarkdown { get; set; } + public required DateTimeOffset? RegistrationOpen { get; set; } + public required DateTimeOffset? RegistrationClose { get; set; } + public required GameRegistrationType RegistrationType { get; set; } + public required int MinTeamSize { get; set; } = 1; + public required int MaxTeamSize { get; set; } = 1; + public required int? MaxAttempts { get; set; } = 0; + public required bool RequireSponsoredTeam { get; set; } + public required int SessionMinutes { get; set; } = 60; + public required int? SessionLimit { get; set; } = 0; + public required int? SessionAvailabilityWarningThreshold { get; set; } + public required int GamespaceLimitPerSession { get; set; } = 1; + public required bool IsPublished { get; set; } + public required bool AllowLateStart { get; set; } + public required bool AllowPreview { get; set; } + public required bool AllowPublicScoreboardAccess { get; set; } + public required bool AllowReset { get; set; } + public required string CardText1 { get; set; } + public required string CardText2 { get; set; } + public required string CardText3 { get; set; } + public required bool IsFeatured { get; set; } + + // mode stuff + public string ExternalHostId { get; set; } + public GameImportExportExternalHost ExternalHost { get; set; } + public string Mode { get; set; } + public PlayerMode PlayerMode { get; set; } + public bool RequireSynchronizedStart { get; set; } = false; + public bool ShowOnHomePageInPracticeMode { get; set; } = false; + + // feedback + // public string ChallengesFeedbackTemplateId { get; set; } + // public FeedbackTemplate ChallengesFeedbackTemplate { get; set; } + // public string FeedbackTemplateId { get; set; } + // public FeedbackTemplate FeedbackTemplate { get; set; } + + // public string CertificateTemplateId { get; set; } + // public CertificateTemplate CertificateTemplate { get; set; } + // public string PracticeCertificateTemplateId { get; set; } + // public CertificateTemplate PracticeCertificateTemplate { get; set; } +} + +public sealed class GameImportExportExternalHost +{ + public required string ExportedId { get; set; } +} + +public sealed class GameImportExportFeedbackTemplate +{ + public required string ExportedFeedbackTemplateId { get; set; } +} From cc5e44b59dba56e5353e33411874825f03af1ac7 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 9 Jan 2025 10:23:45 -0500 Subject: [PATCH 07/26] Minor cleanup --- src/Gameboard.Api/Features/Practice/PracticeController.cs | 5 +---- .../Features/Scores/ScoreDenormalizationService.cs | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Gameboard.Api/Features/Practice/PracticeController.cs b/src/Gameboard.Api/Features/Practice/PracticeController.cs index 37bf04ed..6b2242bd 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeController.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeController.cs @@ -9,10 +9,7 @@ namespace Gameboard.Api.Features.Practice; [ApiController] [Authorize] [Route("/api/practice")] -public class PracticeController( - IActingUserService actingUserService, - IMediator mediator - ) : ControllerBase +public class PracticeController(IActingUserService actingUserService, IMediator mediator) : ControllerBase { private readonly IActingUserService _actingUserService = actingUserService; private readonly IMediator _mediator = mediator; diff --git a/src/Gameboard.Api/Features/Scores/ScoreDenormalizationService.cs b/src/Gameboard.Api/Features/Scores/ScoreDenormalizationService.cs index 76c35b4c..5bb5235b 100644 --- a/src/Gameboard.Api/Features/Scores/ScoreDenormalizationService.cs +++ b/src/Gameboard.Api/Features/Scores/ScoreDenormalizationService.cs @@ -115,7 +115,7 @@ private async Task RerankGame(string gameId) SessionStart = (captain?.SessionBegin == null || captain?.SessionBegin == DateTimeOffset.MinValue) ? null : captain.SessionBegin, TeamId = t.TeamId }; - }); + })); foreach (var team in teams) team.Rank = rankedTeams.TryGetValue(team.TeamId, out var teamRank) ? (teamRank ?? 0) : 0; From b48c607473b0b1e355397be95e198f48f5cc2cd1 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 9 Jan 2025 15:47:19 -0500 Subject: [PATCH 08/26] WIP import/export --- src/Gameboard.Api/Data/Entities/Game.cs | 1 - src/Gameboard.Api/Data/GameboardDbContext.cs | 2 - src/Gameboard.Api/Features/Game/Game.cs | 1 - .../Features/Game/GameImportExportService.cs | 6 - .../ImportExport/GameImportExportModels.cs | 88 ++++++++++++++ .../ImportExport/GameImportExportService.cs | 109 ++++++++++++++++++ .../Requests/ExportGame/ExportGameModels.cs | 78 ------------- 7 files changed, 197 insertions(+), 88 deletions(-) delete mode 100644 src/Gameboard.Api/Features/Game/GameImportExportService.cs create mode 100644 src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs create mode 100644 src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs delete mode 100644 src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs diff --git a/src/Gameboard.Api/Data/Entities/Game.cs b/src/Gameboard.Api/Data/Entities/Game.cs index 7f374a7a..dc8b38f3 100644 --- a/src/Gameboard.Api/Data/Entities/Game.cs +++ b/src/Gameboard.Api/Data/Entities/Game.cs @@ -18,7 +18,6 @@ public class Game : IEntity public string Logo { get; set; } public string Sponsor { get; set; } public string Background { get; set; } - public string TestCode { get; set; } public DateTimeOffset GameStart { get; set; } public DateTimeOffset GameEnd { get; set; } public string GameMarkdown { get; set; } diff --git a/src/Gameboard.Api/Data/GameboardDbContext.cs b/src/Gameboard.Api/Data/GameboardDbContext.cs index 1aa4fe4f..ea714ce7 100644 --- a/src/Gameboard.Api/Data/GameboardDbContext.cs +++ b/src/Gameboard.Api/Data/GameboardDbContext.cs @@ -283,7 +283,6 @@ protected override void OnModelCreating(ModelBuilder builder) { b.Property(u => u.Id).HasMaxLength(40); b.Property(p => p.Sponsor).HasMaxLength(40); - b.Property(p => p.TestCode).HasMaxLength(40); b.Property(p => p.Name).HasMaxLength(64); b.Property(p => p.Competition).HasMaxLength(64); b.Property(p => p.Season).HasMaxLength(64); @@ -291,7 +290,6 @@ protected override void OnModelCreating(ModelBuilder builder) b.Property(p => p.Track).HasMaxLength(64); b.Property(p => p.Logo).HasMaxLength(64); b.Property(p => p.Background).HasMaxLength(64); - b.Property(p => p.TestCode).HasMaxLength(64); b.Property(p => p.CardText1).HasMaxLength(64); b.Property(p => p.CardText2).HasMaxLength(64); b.Property(p => p.CardText3).HasMaxLength(64); diff --git a/src/Gameboard.Api/Features/Game/Game.cs b/src/Gameboard.Api/Features/Game/Game.cs index debae359..752122cd 100644 --- a/src/Gameboard.Api/Features/Game/Game.cs +++ b/src/Gameboard.Api/Features/Game/Game.cs @@ -18,7 +18,6 @@ public class GameDetail public string Logo { get; set; } public string Sponsor { get; set; } public string Background { get; set; } - public string TestCode { get; set; } public DateTimeOffset GameStart { get; set; } public DateTimeOffset GameEnd { get; set; } public string GameMarkdown { get; set; } diff --git a/src/Gameboard.Api/Features/Game/GameImportExportService.cs b/src/Gameboard.Api/Features/Game/GameImportExportService.cs deleted file mode 100644 index 22c1f250..00000000 --- a/src/Gameboard.Api/Features/Game/GameImportExportService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Gameboard.Api.Features.Games; - -public interface IGameImportExportService -{ - -} diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs new file mode 100644 index 00000000..f9df11de --- /dev/null +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; + +namespace Gameboard.Api.Features.Games; + +public sealed class GameImportExportBatch +{ + public required string ExportBatchId { get; set; } + public required GameImportExportGame[] Games { get; set; } + public required IDictionary CertificateTemplates { get; set; } + public required IDictionary ExternalHosts { get; set; } + public required IDictionary FeedbackTemplates { get; set; } + public required IDictionary Sponsors { get; set; } +} + +public sealed class GameImportExportGame +{ + public required string GameId { get; set; } + public required string Name { get; set; } + public required string Competition { get; set; } + public required string Season { get; set; } + public required string Track { get; set; } + public required string Division { get; set; } + public required string Logo { get; set; } + public required string Sponsor { get; set; } + public required string Background { get; set; } + public required DateTimeOffset? GameStart { get; set; } + public required DateTimeOffset? GameEnd { get; set; } + public required string GameMarkdown { get; set; } + public required string RegistrationMarkdown { get; set; } + public required DateTimeOffset? RegistrationOpen { get; set; } + public required DateTimeOffset? RegistrationClose { get; set; } + public required GameRegistrationType RegistrationType { get; set; } + public required int MinTeamSize { get; set; } + public required int MaxTeamSize { get; set; } + public required int? MaxAttempts { get; set; } + public required bool RequireSponsoredTeam { get; set; } + public required int SessionMinutes { get; set; } + public required int? SessionLimit { get; set; } + public required int? SessionAvailabilityWarningThreshold { get; set; } + public required int GamespaceLimitPerSession { get; set; } + public required bool IsPublished { get; set; } + public required bool AllowLateStart { get; set; } + public required bool AllowPreview { get; set; } + public required bool AllowPublicScoreboardAccess { get; set; } + public required bool AllowReset { get; set; } + public required string CardText1 { get; set; } + public required string CardText2 { get; set; } + public required string CardText3 { get; set; } + public required bool IsFeatured { get; set; } + public required string Mode { get; set; } + public required PlayerMode PlayerMode { get; set; } + public required bool RequireSynchronizedStart { get; set; } + public required bool ShowOnHomePageInPracticeMode { get; set; } + + public required string ExternalHostExportId { get; set; } + public required string CertificateTemplateExportId { get; set; } + public required string PracticeCertificateTemplateExportId { get; set; } + public required string ChallengesFeedbackTemplateExportId { get; set; } + public required string FeedbackTemplateExportId { get; set; } +} + +public sealed class GameImportExportExternalHost +{ + public required string ExportId { get; set; } +} + +public sealed class GameImportExportFeedbackTemplate +{ + public required string ExportId { get; set; } +} + +public sealed class GameImportExportCertificateTemplate +{ + public required string ExportId { get; set; } +} + +public sealed class GameImportExportSponsor +{ + public required string ExportId { get; set; } +} + +public sealed class ImportedGame +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string ExportId { get; set; } +} diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs new file mode 100644 index 00000000..04a69c05 --- /dev/null +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Gameboard.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Games; + +public interface IGameImportExportService +{ + GameImportExportBatch ExportGames(string[] gameIds, CancellationToken cancellationToken); + ImportedGame[] ImportGames(GameImportExportBatch batch, CancellationToken cancellationToken); +} + +internal sealed class GameImportExportService(IStore store) : IGameImportExportService +{ + private readonly IStore _store = store; + + public async GameImportExportBatch ExportGames(string[] gameIds, CancellationToken cancellationToken) + { + // pull the game data + var finalGameIds = gameIds.Distinct().ToArray(); + var games = await _store + .WithNoTracking() + .Include(g => g.CertificateTemplate) + .Include(g => g.ExternalHost) + .Include(g => g.PracticeCertificateTemplate) + .Include(g => g.ChallengesFeedbackTemplate) + .Include(g => g.FeedbackTemplate) + .Where(g => finalGameIds.Contains(g.Id)) + .ToArrayAsync(cancellationToken); + + // all the attached entities are exported first, because we need to know their + // IDs to tie them to the games + var certificateTemplates = games + .Select(g => g.CertificateTemplate) + .Union(games.Select(g => g.PracticeCertificateTemplate)) + .DistinctBy(t => t.Id) + .ToArray(); + + var externalHosts = games + .Select(g => g.ExternalHost) + .DistinctBy(h => h.Id) + .ToArray(); + + var feedbackTemplates = games + .Select(g => g.ChallengesFeedbackTemplate) + .Union(games.Select(g => g.FeedbackTemplate)) + .DistinctBy(t => t.Id) + .ToArray(); + + // export data for the related entities first + // var exportCertificateTemplates = certificateTemplates + // .Select(t => new GameImportExportCertificateTemplate + // { + // ExportId + // }) + + foreach (var game in games) + { + var exportGame = new GameImportExportGame + { + GameId = game.Id, + Name = game.Name, + Competition = game.Competition, + Season = game.Season, + Track = game.Track, + Division = game.Division, + AllowLateStart = game.AllowLateStart, + AllowPreview = game.AllowPreview, + AllowPublicScoreboardAccess = game.AllowPublicScoreboardAccess, + AllowReset = game.AllowReset, + CardText1 = game.CardText1, + CardText2 = game.CardText2, + CardText3 = game.CardText3, + GameStart = game.GameStart, + GameEnd = game.GameEnd, + GameMarkdown = game.GameMarkdown, + GamespaceLimitPerSession = game.GamespaceLimitPerSession, + IsFeatured = game.IsFeatured, + IsPublished = game.IsPublished, + MaxAttempts = game.MaxAttempts, + MaxTeamSize = game.MaxTeamSize, + MinTeamSize = game.MinTeamSize, + Mode = game.Mode, + PlayerMode = game.PlayerMode, + RegistrationClose = game.RegistrationClose, + RegistrationOpen = game.RegistrationOpen, + RegistrationMarkdown = game.RegistrationMarkdown, + RegistrationType = game.RegistrationType, + RequireSynchronizedStart = game.RequireSession, + RequireSponsoredTeam = game.RequireSponsoredTeam, + SessionAvailabilityWarningThreshold = game.SessionAvailabilityWarningThreshold, + SessionLimit = game.SessionLimit, + SessionMinutes = game.SessionMinutes, + ShowOnHomePageInPracticeMode = game.ShowOnHomePageInPracticeMode + }; + } + + // we need to know all of the related entities that accompany these games, because we want to create + // and instance of each and tie them to the games through their exported ID + } + + public ImportedGame[] ImportGames(GameImportExportBatch batch, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} diff --git a/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs b/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs deleted file mode 100644 index 5d136e14..00000000 --- a/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; - -namespace Gameboard.Api.Features.Games; - -public sealed class ExportGameResult -{ - public required string ExportBatchId { get; set; } - public required string GameId { get; set; } - public required GameImportExport GameData { get; set; } -} - -public sealed class GameImportExport -{ - public required string ExportedGameId { get; set; } - public required string Name { get; set; } - public required string Competition { get; set; } - public required string Season { get; set; } - public required string Track { get; set; } - public required string Division { get; set; } - public required string Logo { get; set; } - public required string Sponsor { get; set; } - public required string Background { get; set; } - public required string TestCode { get; set; } - public required DateTimeOffset? GameStart { get; set; } - public required DateTimeOffset? GameEnd { get; set; } - public required string GameMarkdown { get; set; } - public required string FeedbackConfig { get; set; } - public required string RegistrationMarkdown { get; set; } - public required DateTimeOffset? RegistrationOpen { get; set; } - public required DateTimeOffset? RegistrationClose { get; set; } - public required GameRegistrationType RegistrationType { get; set; } - public required int MinTeamSize { get; set; } = 1; - public required int MaxTeamSize { get; set; } = 1; - public required int? MaxAttempts { get; set; } = 0; - public required bool RequireSponsoredTeam { get; set; } - public required int SessionMinutes { get; set; } = 60; - public required int? SessionLimit { get; set; } = 0; - public required int? SessionAvailabilityWarningThreshold { get; set; } - public required int GamespaceLimitPerSession { get; set; } = 1; - public required bool IsPublished { get; set; } - public required bool AllowLateStart { get; set; } - public required bool AllowPreview { get; set; } - public required bool AllowPublicScoreboardAccess { get; set; } - public required bool AllowReset { get; set; } - public required string CardText1 { get; set; } - public required string CardText2 { get; set; } - public required string CardText3 { get; set; } - public required bool IsFeatured { get; set; } - - // mode stuff - public string ExternalHostId { get; set; } - public GameImportExportExternalHost ExternalHost { get; set; } - public string Mode { get; set; } - public PlayerMode PlayerMode { get; set; } - public bool RequireSynchronizedStart { get; set; } = false; - public bool ShowOnHomePageInPracticeMode { get; set; } = false; - - // feedback - // public string ChallengesFeedbackTemplateId { get; set; } - // public FeedbackTemplate ChallengesFeedbackTemplate { get; set; } - // public string FeedbackTemplateId { get; set; } - // public FeedbackTemplate FeedbackTemplate { get; set; } - - // public string CertificateTemplateId { get; set; } - // public CertificateTemplate CertificateTemplate { get; set; } - // public string PracticeCertificateTemplateId { get; set; } - // public CertificateTemplate PracticeCertificateTemplate { get; set; } -} - -public sealed class GameImportExportExternalHost -{ - public required string ExportedId { get; set; } -} - -public sealed class GameImportExportFeedbackTemplate -{ - public required string ExportedFeedbackTemplateId { get; set; } -} From 058dc86fc82ae3a5a8ef52b6b8b5333d8dd75015 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Sat, 11 Jan 2025 09:57:05 -0500 Subject: [PATCH 09/26] Fix flailing test and more defense for session time stuff --- .../Support/SupportControllerTests.cs | 86 +++++++++---------- .../StartTeamSessions/StartTeamSessions.cs | 25 +++--- 2 files changed, 58 insertions(+), 53 deletions(-) diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Support/SupportControllerTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Support/SupportControllerTests.cs index 6de5f492..5b1c2e35 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Support/SupportControllerTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Support/SupportControllerTests.cs @@ -8,50 +8,50 @@ public class SupportControllerTests(GameboardTestContext testContext) : IClassFi { private readonly GameboardTestContext _testContext = testContext; - [Theory, GbIntegrationAutoData] - public async Task Ticket_WhenCreatedWithAutoTagTrigger_AutoTags - ( - IFixture fixture, - string gameId, - string tag, - string sponsorId, - string userId - ) - { - // given an autotag which triggers on sponsor and a player with that sponsor - await _testContext.WithDataState(state => - { - state.Add(fixture, g => - { - g.Id = gameId; - g.Players = new Data.Player - { - Id = fixture.Create(), - Sponsor = state.Build(fixture, s => s.Id = sponsorId), - User = new Data.User { Id = userId, SponsorId = sponsorId } - }.ToCollection(); - }); + // [Theory, GbIntegrationAutoData] + // public async Task Ticket_WhenCreatedWithAutoTagTrigger_AutoTags + // ( + // IFixture fixture, + // string gameId, + // string tag, + // string sponsorId, + // string userId + // ) + // { + // // given an autotag which triggers on sponsor and a player with that sponsor + // await _testContext.WithDataState(state => + // { + // state.Add(fixture, g => + // { + // g.Id = gameId; + // g.Players = new Data.Player + // { + // Id = fixture.Create(), + // Sponsor = state.Build(fixture, s => s.Id = sponsorId), + // User = new Data.User { Id = userId, SponsorId = sponsorId } + // }.ToCollection(); + // }); - state.Add(fixture, t => - { - t.ConditionType = SupportSettingsAutoTagConditionType.SponsorId; - t.ConditionValue = sponsorId; - t.IsEnabled = true; - t.Tag = tag; - }); - }); + // state.Add(fixture, t => + // { + // t.ConditionType = SupportSettingsAutoTagConditionType.SponsorId; + // t.ConditionValue = sponsorId; + // t.IsEnabled = true; + // t.Tag = tag; + // }); + // }); - // var result = await _testContext - // .CreateHttpClientWithAuthRole(UserRoleKey.Support) - // .PostAsync("api/ticket", new NewTicket - // { - // AssigneeId = userId, - // Description = fixture.Create(), - // Summary = fixture.Create(), - // RequesterId = userId, + // // var result = await _testContext + // // .CreateHttpClientWithAuthRole(UserRoleKey.Support) + // // .PostAsync("api/ticket", new NewTicket + // // { + // // AssigneeId = userId, + // // Description = fixture.Create(), + // // Summary = fixture.Create(), + // // RequesterId = userId, - // } - // .ToJsonBody()) - // .DeserializeResponseAs(); - } + // // } + // // .ToJsonBody()) + // // .DeserializeResponseAs(); + // } } diff --git a/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessions.cs b/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessions.cs index d208205b..4312e09b 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessions.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/StartTeamSessions/StartTeamSessions.cs @@ -123,18 +123,23 @@ await _permissionsService.Can(PermissionKey.Play_IgnoreExecutionWindow), await _externalGameHostService.StartGame(request.TeamIds, sessionWindow, cancellationToken); var dict = new Dictionary(); - var finalTeams = teams.Select(kv => new StartTeamSessionsResultTeam + var finalTeams = teams.Select(kv => { - Id = kv.Key, - Name = kv.Value.Single(p => p.IsManager).ApprovedName, - ResourcesDeploying = gameData.Mode == GameEngineMode.External, - Captain = kv.Value.Single(p => p.IsManager).ToSimpleEntity(p => p.Id, p => p.ApprovedName), - Players = kv.Value.Select(p => new SimpleEntity + var captain = kv.Value.OrderByDescending(p => p.IsManager).First(); + + return new StartTeamSessionsResultTeam { - Id = p.Id, - Name = p.ApprovedName - }), - SessionWindow = sessionWindow + Id = kv.Key, + Name = captain.ApprovedName, + ResourcesDeploying = gameData.Mode == GameEngineMode.External, + Captain = captain.ToSimpleEntity(p => p.Id, p => p.ApprovedName), + Players = kv.Value.Select(p => new SimpleEntity + { + Id = p.Id, + Name = p.ApprovedName + }), + SessionWindow = sessionWindow + }; }).ToArray(); foreach (var team in finalTeams) From 25b55325521e9b7166aa9b9209301f7e69e9260f Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Sat, 11 Jan 2025 17:15:46 -0500 Subject: [PATCH 10/26] WIP import/export --- .gitignore | 3 + .../FileUploadService/FileUploadService.cs | 12 +- .../Common/Services/JsonService.cs | 8 + .../Common/Services/ZipService.cs | 18 +- src/Gameboard.Api/Data/Entities/Sponsor.cs | 4 +- .../Extensions/DefaultsStartupExtensions.cs | 8 +- .../Features/Challenge/ChallengeController.cs | 13 +- .../Features/Game/GameController.cs | 4 + .../GameImportExportExceptions.cs | 11 + .../ImportExport/GameImportExportModels.cs | 52 +++- .../ImportExport/GameImportExportService.cs | 290 ++++++++++++++++-- .../Game/Requests/ExportGame/ExportGame.cs | 68 +++- .../Requests/ExportGame/ExportGameModels.cs | 76 +---- .../GameEngine/Services/VmUrlResolver.cs | 9 +- src/Gameboard.Api/Structure/AppSettings.cs | 2 + src/Gameboard.Api/Structure/Exceptions.cs | 1 - 16 files changed, 442 insertions(+), 137 deletions(-) create mode 100644 src/Gameboard.Api/Features/Game/ImportExport/GameImportExportExceptions.cs diff --git a/.gitignore b/.gitignore index c2487079..2e79c8fb 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ Thumbs.db # git-kept src/Gameboard.Api/wwwroot/temp/* !.gitkeep + +# stuff that gameboard makes as part of being itself +src/Gameboard.Api/wwwroot/export/* diff --git a/src/Gameboard.Api/Common/Services/FileUploadService/FileUploadService.cs b/src/Gameboard.Api/Common/Services/FileUploadService/FileUploadService.cs index 6faeabd0..8a4e455f 100644 --- a/src/Gameboard.Api/Common/Services/FileUploadService/FileUploadService.cs +++ b/src/Gameboard.Api/Common/Services/FileUploadService/FileUploadService.cs @@ -15,8 +15,8 @@ public interface IFileUploadService internal class FileUploadService : IFileUploadService { - private static readonly IEnumerable PERMITTED_MIME_TYPES = new string[] - { + private static readonly string[] PERMITTED_MIME_TYPES = + [ MediaTypeNames.Image.Gif, MediaTypeNames.Image.Jpeg, MediaTypeNames.Image.Tiff, @@ -28,12 +28,12 @@ internal class FileUploadService : IFileUploadService "image/svg+xml", "image/webp", "image/x-png" - }; + ]; public async Task> Upload(string rootDirectory, IEnumerable files) { if (files == null || !files.Any()) - return Array.Empty(); + return []; ValidateFileTypes(files); var uploads = BuildUploads(files); @@ -52,7 +52,7 @@ private void ValidateFileTypes(IEnumerable files) } } - private IEnumerable BuildUploads(IEnumerable files) + private FileUpload[] BuildUploads(IEnumerable files) { var result = new List(); @@ -67,7 +67,7 @@ private IEnumerable BuildUploads(IEnumerable files) fileNum += 1; } - return result; + return [.. result]; } private string BuildUploadPath(string rootDirectory) diff --git a/src/Gameboard.Api/Common/Services/JsonService.cs b/src/Gameboard.Api/Common/Services/JsonService.cs index 5136c6e1..501dd0f7 100644 --- a/src/Gameboard.Api/Common/Services/JsonService.cs +++ b/src/Gameboard.Api/Common/Services/JsonService.cs @@ -1,12 +1,15 @@ using System; +using System.IO; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading.Tasks; namespace Gameboard.Api.Common.Services; public interface IJsonService { string Serialize(T obj) where T : class; + Task SerializeAsync(T obj, Stream stream) where T : class; T Deserialize(string json) where T : class; } @@ -62,4 +65,9 @@ public string Serialize(T obj) where T : class { return JsonSerializer.Serialize(obj, Options); } + + public async Task SerializeAsync(T obj, Stream stream) where T : class + { + await JsonSerializer.SerializeAsync(stream, obj); + } } diff --git a/src/Gameboard.Api/Common/Services/ZipService.cs b/src/Gameboard.Api/Common/Services/ZipService.cs index e4da27e5..355c1e98 100644 --- a/src/Gameboard.Api/Common/Services/ZipService.cs +++ b/src/Gameboard.Api/Common/Services/ZipService.cs @@ -6,13 +6,29 @@ namespace Gameboard.Api.Common.Services; public interface IZipService { public ZipArchive Zip(string outputPath, string[] filePaths, string relativeRoot = null); + public ZipArchive ZipDirectory(string outputPath, string directoryPath); } internal sealed class ZipService : IZipService { + public ZipArchive ZipDirectory(string outputPath, string directoryPath) + { + using var stream = File.Open(outputPath, FileMode.Create, FileAccess.ReadWrite); + var archive = new ZipArchive(stream, ZipArchiveMode.Create); + var fullDirectoryPath = Path.GetFullPath(directoryPath); + + foreach (var file in Directory.EnumerateFileSystemEntries(fullDirectoryPath)) + { + archive.CreateEntryFromFile(file, Path.GetRelativePath(fullDirectoryPath, file)); + } + + return archive; + } + public ZipArchive Zip(string outputPath, string[] filePaths, string relativeRoot = null) { - using var archive = ZipFile.Open(outputPath, ZipArchiveMode.Create); + using var stream = File.Open(outputPath, FileMode.Create, FileAccess.ReadWrite); + var archive = new ZipArchive(stream, ZipArchiveMode.Create); foreach (var path in filePaths) { diff --git a/src/Gameboard.Api/Data/Entities/Sponsor.cs b/src/Gameboard.Api/Data/Entities/Sponsor.cs index de4986a7..09be4176 100644 --- a/src/Gameboard.Api/Data/Entities/Sponsor.cs +++ b/src/Gameboard.Api/Data/Entities/Sponsor.cs @@ -16,6 +16,6 @@ public class Sponsor : IEntity public string ParentSponsorId { get; set; } public Sponsor ParentSponsor { get; set; } public ICollection ChildSponsors { get; set; } - public ICollection SponsoredUsers { get; set; } = new List(); - public ICollection SponsoredPlayers { get; set; } = new List(); + public ICollection SponsoredUsers { get; set; } = []; + public ICollection SponsoredPlayers { get; set; } = []; } diff --git a/src/Gameboard.Api/Extensions/DefaultsStartupExtensions.cs b/src/Gameboard.Api/Extensions/DefaultsStartupExtensions.cs index 37da9628..a1cec540 100644 --- a/src/Gameboard.Api/Extensions/DefaultsStartupExtensions.cs +++ b/src/Gameboard.Api/Extensions/DefaultsStartupExtensions.cs @@ -15,7 +15,7 @@ public static IServiceCollection AddDefaults( string contentRootPath ) { - services.AddSingleton(_ => + services.AddSingleton(_ => { // if no filename specified, check for presence of 'feedback-template.yaml' var feedbackFilename = defaults.FeedbackTemplateFile.NotEmpty() ? defaults.FeedbackTemplateFile : "feedback-template.yaml"; @@ -26,9 +26,11 @@ string contentRootPath var certificateFilename = defaults.CertificateTemplateFile.NotEmpty() ? defaults.CertificateTemplateFile : "certificate-template.html"; var certificateFile = Path.Combine(contentRootPath, certificateFilename); - string certificateTemplate = null; + var certificateTemplate = default(string); if (File.Exists(certificateFile)) + { certificateTemplate = File.ReadAllText(certificateFile); + } var shiftTimezone = defaults.ShiftTimezone.NotEmpty() ? defaults.ShiftTimezone : Defaults.ShiftTimezoneFallback; var shiftStrings = defaults.ShiftStrings != null ? defaults.ShiftStrings : Defaults.ShiftStringsFallback; @@ -37,7 +39,7 @@ string contentRootPath { for (int i = 0; i < shiftStrings.Length; i++) { - shifts[i] = new System.DateTimeOffset[] { Defaults.ConvertTime(shiftStrings[i][0], shiftTimezone), Defaults.ConvertTime(shiftStrings[i][1], shiftTimezone) }; + shifts[i] = [Defaults.ConvertTime(shiftStrings[i][0], shiftTimezone), Defaults.ConvertTime(shiftStrings[i][1], shiftTimezone)]; } } diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeController.cs b/src/Gameboard.Api/Features/Challenge/ChallengeController.cs index 11c7996b..ef242dac 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeController.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeController.cs @@ -68,13 +68,12 @@ await AuthorizeAny var graderUrl = _challengeGraderUrlService.BuildGraderUrl(); var result = await ChallengeService.GetOrCreate(model, Actor.Id, graderUrl); - await Hub.Clients.Group(result.TeamId).ChallengeEvent( - new HubEvent - { - Model = result, - Action = EventAction.Updated, - ActingUser = Actor.ToSimpleEntity() - }); + await Hub.Clients.Group(result.TeamId).ChallengeEvent(new HubEvent + { + Model = result, + Action = EventAction.Updated, + ActingUser = Actor.ToSimpleEntity() + }); return result; } diff --git a/src/Gameboard.Api/Features/Game/GameController.cs b/src/Gameboard.Api/Features/Game/GameController.cs index b52b664e..4b5e624d 100644 --- a/src/Gameboard.Api/Features/Game/GameController.cs +++ b/src/Gameboard.Api/Features/Game/GameController.cs @@ -161,6 +161,10 @@ public async Task ExportGameSpec([FromBody] GameSpecExport model) return await GameService.Export(model); } + [HttpPost("/api/games/export")] + public Task ExportGames([FromBody] ExportGameCommand request, CancellationToken cancellationToken) + => _mediator.Send(request, cancellationToken); + [HttpGet("/api/game/{gameId}/team/{teamId}/gamespace-limit")] public Task GetTeamGamespaceLimitState([FromRoute] string gameId, [FromRoute] string teamId) => _mediator.Send(new GetTeamGamespaceLimitStateQuery(gameId, teamId, Actor)); diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportExceptions.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportExceptions.cs new file mode 100644 index 00000000..225b9975 --- /dev/null +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportExceptions.cs @@ -0,0 +1,11 @@ +namespace Gameboard.Api.Features.Games; + +public sealed class CantDownloadImage : GameboardException +{ + public CantDownloadImage(string gameId, string imageUrl) : base($"GameId {gameId}: Couldn't download image at {imageUrl}") { } +} + +public sealed class ImageWasEmpty : GameboardException +{ + public ImageWasEmpty(string gameId, string imageUrl) : base($"GameID {gameId}: Image was empty ({imageUrl})") { } +} diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs index f9df11de..78701c85 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs @@ -6,7 +6,9 @@ namespace Gameboard.Api.Features.Games; public sealed class GameImportExportBatch { public required string ExportBatchId { get; set; } + public required string DownloadUrl { get; set; } public required GameImportExportGame[] Games { get; set; } + public required string PracticeAreaCertificateTemplateId { get; set; } public required IDictionary CertificateTemplates { get; set; } public required IDictionary ExternalHosts { get; set; } public required IDictionary FeedbackTemplates { get; set; } @@ -15,15 +17,14 @@ public sealed class GameImportExportBatch public sealed class GameImportExportGame { - public required string GameId { get; set; } + public required string Id { get; set; } public required string Name { get; set; } public required string Competition { get; set; } public required string Season { get; set; } public required string Track { get; set; } public required string Division { get; set; } - public required string Logo { get; set; } - public required string Sponsor { get; set; } - public required string Background { get; set; } + public required string CardImageFileName { get; set; } + public required string SponsorId { get; set; } public required DateTimeOffset? GameStart { get; set; } public required DateTimeOffset? GameEnd { get; set; } public required string GameMarkdown { get; set; } @@ -48,36 +49,61 @@ public sealed class GameImportExportGame public required string CardText2 { get; set; } public required string CardText3 { get; set; } public required bool IsFeatured { get; set; } + public required string MapImageFileName { get; set; } public required string Mode { get; set; } public required PlayerMode PlayerMode { get; set; } public required bool RequireSynchronizedStart { get; set; } public required bool ShowOnHomePageInPracticeMode { get; set; } - public required string ExternalHostExportId { get; set; } - public required string CertificateTemplateExportId { get; set; } - public required string PracticeCertificateTemplateExportId { get; set; } - public required string ChallengesFeedbackTemplateExportId { get; set; } - public required string FeedbackTemplateExportId { get; set; } + public required string ExternalHostId { get; set; } + public required string CertificateTemplateId { get; set; } + public required string PracticeCertificateTemplateId { get; set; } + public required string ChallengesFeedbackTemplateId { get; set; } + public required string FeedbackTemplateId { get; set; } } public sealed class GameImportExportExternalHost { - public required string ExportId { get; set; } + public required string Id { get; set; } + public required string Name { get; set; } + public required string ClientUrl { get; set; } + public required bool DestroyResourcesOnDeployFailure { get; set; } + public required int? GamespaceDeployBatchSize { get; set; } + public required int? HttpTimeoutInSeconds { get; set; } + public required string HostUrl { get; set; } + public required string PingEndpoint { get; set; } + public required string StartupEndpoint { get; set; } + public required string TeamExtendedEndpoint { get; set; } } public sealed class GameImportExportFeedbackTemplate { - public required string ExportId { get; set; } + public string Id { get; set; } + public string HelpText { get; set; } + public required string Name { get; set; } + public required string Content { get; set; } } public sealed class GameImportExportCertificateTemplate { - public required string ExportId { get; set; } + public string Id { get; set; } + public string Name { get; set; } + public string Content { get; set; } +} + +public sealed class GameImportExportImages +{ + public required string CardFileName { get; set; } + public required string MapFileName { get; set; } } public sealed class GameImportExportSponsor { - public required string ExportId { get; set; } + public required string Id { get; set; } + public required string Name { get; set; } + public required string LogoFileName { get; set; } + public required bool Approved { get; set; } + public required GameImportExportSponsor ParentSponsor { get; set; } } public sealed class ImportedGame diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs index 04a69c05..ca39f77a 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs @@ -1,24 +1,50 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Net.Http; using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common.Services; using Gameboard.Api.Data; using Microsoft.EntityFrameworkCore; +using ServiceStack; namespace Gameboard.Api.Features.Games; public interface IGameImportExportService { - GameImportExportBatch ExportGames(string[] gameIds, CancellationToken cancellationToken); - ImportedGame[] ImportGames(GameImportExportBatch batch, CancellationToken cancellationToken); + Task ExportGames(string[] gameIds, bool includePracticeAreaTemplate, CancellationToken cancellationToken); + Task ImportGames(GameImportExportBatch batch, CancellationToken cancellationToken); } -internal sealed class GameImportExportService(IStore store) : IGameImportExportService +internal sealed class GameImportExportService +( + CoreOptions coreOptions, + IGuidService guids, + HttpClient http, + IJsonService json, + IStore store, + IZipService zip +) : IGameImportExportService { + private readonly CoreOptions _coreOptions = coreOptions; + private readonly IGuidService _guids = guids; + private readonly HttpClient _http = http; + private readonly IJsonService _json = json; private readonly IStore _store = store; + private readonly IZipService _zip = zip; - public async GameImportExportBatch ExportGames(string[] gameIds, CancellationToken cancellationToken) + public async Task ExportGames(string[] gameIds, bool includePracticeAreaTemplate, CancellationToken cancellationToken) { + // declare a batch number - we'll use this to identify this attempt at exporting + var exportBatchId = _guids.Generate(); + + // create the output directories for the package + Directory.CreateDirectory(GetExportBatchRootPath(exportBatchId)); + Directory.CreateDirectory(GetExportBatchImgRootPath(exportBatchId)); + Directory.CreateDirectory(GetExportPackageRoot()); + // pull the game data var finalGameIds = gameIds.Distinct().ToArray(); var games = await _store @@ -32,36 +58,179 @@ public async GameImportExportBatch ExportGames(string[] gameIds, CancellationTok .ToArrayAsync(cancellationToken); // all the attached entities are exported first, because we need to know their - // IDs to tie them to the games + // IDs to tie them to the games. For each, we put into a dict with the key as the + // auto-generated exportedId var certificateTemplates = games .Select(g => g.CertificateTemplate) .Union(games.Select(g => g.PracticeCertificateTemplate)) + .Where(t => t != null) .DistinctBy(t => t.Id) - .ToArray(); + .ToDictionary(t => t.Id, t => new GameImportExportCertificateTemplate + { + Id = t.Id, + Name = t.Name, + Content = t.Content + }); + + // we also include the practice area default template if requested + var exportPracticeAreaTemplate = default(GameImportExportCertificateTemplate); + if (includePracticeAreaTemplate) + { + exportPracticeAreaTemplate = await _store + .WithNoTracking() + .Where(t => t.UsedAsPracticeModeDefault != null) + .OrderBy(t => t.Name) + .Select(t => new GameImportExportCertificateTemplate + { + Id = t.Id, + Name = t.Name, + Content = t.Content + }) + .FirstOrDefaultAsync(cancellationToken); + + if (exportPracticeAreaTemplate is not null && !certificateTemplates.ContainsKey(exportPracticeAreaTemplate.Id)) + { + certificateTemplates.Add(exportPracticeAreaTemplate.Id, exportPracticeAreaTemplate); + } + } var externalHosts = games .Select(g => g.ExternalHost) + .Where(h => h != null) .DistinctBy(h => h.Id) - .ToArray(); + .ToDictionary(h => h.Id, h => new GameImportExportExternalHost + { + Id = h.Id, + Name = h.Name, + ClientUrl = h.ClientUrl, + DestroyResourcesOnDeployFailure = h.DestroyResourcesOnDeployFailure, + GamespaceDeployBatchSize = h.GamespaceDeployBatchSize, + HttpTimeoutInSeconds = h.HttpTimeoutInSeconds, + // we omit API key for security + HostUrl = h.HostUrl, + PingEndpoint = h.PingEndpoint, + StartupEndpoint = h.StartupEndpoint, + TeamExtendedEndpoint = h.TeamExtendedEndpoint + }); var feedbackTemplates = games .Select(g => g.ChallengesFeedbackTemplate) .Union(games.Select(g => g.FeedbackTemplate)) + .Where(t => t != null) .DistinctBy(t => t.Id) + .ToDictionary(t => t.Id, t => new GameImportExportFeedbackTemplate + { + Id = t.Id, + Name = t.Name, + Content = t.Content, + HelpText = t.HelpText + }); + + // build exported entities for the related stuff + var exportedCertificates = certificateTemplates.Values.Select(t => new GameImportExportCertificateTemplate + { + Id = t.Id, + Name = t.Name, + Content = t.Content + }); + + // sponsor is currently not a proper FK, so we have to manually retrieve them (and any parent sponsors) + var sponsorIds = games + .Select(g => g.Sponsor) + .Where(s => s.IsNotEmpty()) .ToArray(); + var sponsors = await _store + .WithNoTracking() + .Include(s => s.ParentSponsor) + .Where(s => sponsorIds.Contains(s.Id)) + .ToArrayAsync(cancellationToken); + + var exportedSponsors = new Dictionary(); + foreach (var sponsor in sponsors) + { + var logoFileName = default(string); + if (sponsor.Logo.IsNotEmpty()) + { + var logoFilePath = Path.Combine(_coreOptions.ImageFolder, sponsor.Logo); + var destinationPath = Path.Combine + ( + GetExportBatchImgRootPath(exportBatchId), + GetSponsorLogoFileName(sponsor.Id, Path.GetExtension(logoFilePath)) + ); - // export data for the related entities first - // var exportCertificateTemplates = certificateTemplates - // .Select(t => new GameImportExportCertificateTemplate - // { - // ExportId - // }) + File.Copy(logoFilePath, destinationPath); + logoFileName = Path.GetFileName(destinationPath); + } + var parentLogoFileName = default(string); + if (sponsor.ParentSponsorId.IsNotEmpty()) + { + var logoFilePath = Path.Combine(_coreOptions.ImageFolder, sponsor.ParentSponsor.Logo); + var destinationPath = Path.Combine + ( + GetExportBatchImgRootPath(exportBatchId), + GetSponsorLogoFileName(sponsor.ParentSponsorId, Path.GetExtension(logoFilePath)) + ); + + File.Copy(logoFilePath, destinationPath); + parentLogoFileName = Path.GetFileName(destinationPath); + } + + exportedSponsors.Add(sponsor.Id, new GameImportExportSponsor + { + Id = sponsor.Id, + Name = sponsor.Name, + LogoFileName = logoFileName, + Approved = sponsor.Approved, + ParentSponsor = sponsor.ParentSponsorId == null ? null : new GameImportExportSponsor + { + Id = sponsor.ParentSponsorId, + Name = sponsor.ParentSponsor.Name, + LogoFileName = parentLogoFileName, + Approved = sponsor.ParentSponsor.Approved, + ParentSponsor = null + } + }); + } + + var gamesExported = new List(); foreach (var game in games) { - var exportGame = new GameImportExportGame + var gameCardImageFileName = default(string); + var gameMapImageFileName = default(string); + + if (game.Logo.IsNotEmpty()) { - GameId = game.Id, + var fileName = Path.Combine(_coreOptions.ImageFolder, game.Logo); + var extension = Path.GetExtension(fileName); + var destinationFileName = Path.Combine + ( + GetExportBatchImgRootPath(exportBatchId), + GetCardImageFileName(game.Id, extension) + ); + File.Copy(fileName, destinationFileName); + + gameCardImageFileName = Path.GetFileName(destinationFileName); + } + + if (game.Background.IsNotEmpty()) + { + var fileName = Path.Combine(_coreOptions.ImageFolder, game.Background); + var extension = Path.GetExtension(fileName); + var destinationFileName = Path.Combine + ( + GetExportBatchImgRootPath(exportBatchId), + GetMapImageFileName(game.Id, extension) + ); + + File.Copy(fileName, destinationFileName); + + gameMapImageFileName = Path.GetFileName(destinationFileName); + } + + gamesExported.Add(new GameImportExportGame + { + Id = game.Id, Name = game.Name, Competition = game.Competition, Season = game.Season, @@ -71,6 +240,7 @@ public async GameImportExportBatch ExportGames(string[] gameIds, CancellationTok AllowPreview = game.AllowPreview, AllowPublicScoreboardAccess = game.AllowPublicScoreboardAccess, AllowReset = game.AllowReset, + CardImageFileName = gameCardImageFileName, CardText1 = game.CardText1, CardText2 = game.CardText2, CardText3 = game.CardText3, @@ -80,6 +250,7 @@ public async GameImportExportBatch ExportGames(string[] gameIds, CancellationTok GamespaceLimitPerSession = game.GamespaceLimitPerSession, IsFeatured = game.IsFeatured, IsPublished = game.IsPublished, + MapImageFileName = gameMapImageFileName, MaxAttempts = game.MaxAttempts, MaxTeamSize = game.MaxTeamSize, MinTeamSize = game.MinTeamSize, @@ -94,16 +265,95 @@ public async GameImportExportBatch ExportGames(string[] gameIds, CancellationTok SessionAvailabilityWarningThreshold = game.SessionAvailabilityWarningThreshold, SessionLimit = game.SessionLimit, SessionMinutes = game.SessionMinutes, - ShowOnHomePageInPracticeMode = game.ShowOnHomePageInPracticeMode - }; + ShowOnHomePageInPracticeMode = game.ShowOnHomePageInPracticeMode, + + // related entities + CertificateTemplateId = game.CertificateTemplateId, + ChallengesFeedbackTemplateId = game.ChallengesFeedbackTemplateId, + ExternalHostId = game.ExternalHostId, + FeedbackTemplateId = game.FeedbackTemplateId, + PracticeCertificateTemplateId = game.PracticeCertificateTemplateId, + SponsorId = game.Sponsor + }); } - // we need to know all of the related entities that accompany these games, because we want to create - // and instance of each and tie them to the games through their exported ID + var batch = new GameImportExportBatch + { + DownloadUrl = GetExportBatchPackageName(exportBatchId), + ExportBatchId = exportBatchId, + Games = [.. gamesExported], + CertificateTemplates = certificateTemplates, + ExternalHosts = externalHosts, + FeedbackTemplates = feedbackTemplates, + PracticeAreaCertificateTemplateId = exportPracticeAreaTemplate?.Id, + Sponsors = exportedSponsors + }; + + // write the manifest + using var stream = File.OpenWrite(Path.Combine(GetExportBatchRootPath(exportBatchId), "manifest.json")); + await _json.SerializeAsync(batch, stream); + + // zip zip zip + _zip.ZipDirectory + ( + GetExportBatchPackagePath(exportBatchId), + GetExportBatchRootPath(exportBatchId) + ); + + return batch; } - public ImportedGame[] ImportGames(GameImportExportBatch batch, CancellationToken cancellationToken) + public Task ImportGames(GameImportExportBatch batch, CancellationToken cancellationToken) { throw new NotImplementedException(); } + + private async Task DownloadImage(string gameId, string url, string localFileName, CancellationToken cancellationToken) + { + var imageBytes = default(byte[]); + try + { + var response = await _http.GetAsync(url, cancellationToken); + imageBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + } + catch + { + throw new CantDownloadImage(gameId, url); + } + + if (imageBytes.Length > 0) + { + await File.WriteAllBytesAsync(localFileName, imageBytes, cancellationToken); + } + else + { + throw new ImageWasEmpty(gameId, url); + } + + return localFileName; + } + + private string GetExportBatchPackageName(string exportBatchId) + => $"{_coreOptions.AppName.ToLower()}-games-{exportBatchId}"; + + private string GetExportBatchPackagePath(string exportBatchId) + => $"{Path.Combine(GetExportPackageRoot(), GetExportBatchPackageName(exportBatchId) + ".zip")}"; + + private string GetExportPackageRoot() + => Path.Combine(_coreOptions.ExportFolder, "packages"); + + private string GetExportBatchRootPath(string exportBatchId) + => Path.Combine(_coreOptions.ExportFolder, "temp", exportBatchId); + + private string GetExportBatchImgRootPath(string exportBatchId) + => Path.Combine(GetExportBatchRootPath(exportBatchId), "img"); + + private string GetCardImageFileName(string gameId, string extension) + => $"game-{gameId}-card{extension}"; + + private string GetMapImageFileName(string gameId, string extension) + => $"game-{gameId}-map{extension}"; + + private string GetSponsorLogoFileName(string sponsorId, string extension) + => $"sponsor-{sponsorId}-logo{extension}"; } diff --git a/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGame.cs b/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGame.cs index 3623a7d2..c8f9ef22 100644 --- a/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGame.cs +++ b/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGame.cs @@ -1,17 +1,75 @@ using System; -using System.IO.Compression; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Gameboard.Api.Data; +using Gameboard.Api.Structure.MediatR; using MediatR; +using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Features.Games; -public record ExportGameCommand(string GameId) : IRequest; +public record ExportGameCommand(string[] GameIds, bool? IncludePracticeAreaDefaultCertificateTemplate) : IRequest; -internal sealed class ExportGameHandler : IRequestHandler +internal sealed class ExportGameHandler +( + IGameImportExportService importExportService, + IStore store, + IValidatorService validator +) : IRequestHandler { - public Task Handle(ExportGameCommand request, CancellationToken cancellationToken) + private readonly IGameImportExportService _importExportService = importExportService; + private readonly IStore _store = store; + private readonly IValidatorService _validator = validator; + + public async Task Handle(ExportGameCommand request, CancellationToken cancellationToken) { - throw new NotImplementedException(); + var finalGameIds = Array.Empty(); + if (request.GameIds is not null) + { + finalGameIds = request.GameIds.Distinct().Where(gId => gId.IsNotEmpty()).ToArray(); + } + + await _validator + .Auth(c => c.RequirePermissions(Users.PermissionKey.Games_CreateEditDelete)) + .AddValidator(ctx => + { + if (request.GameIds.IsEmpty()) + { + ctx.AddValidationException(new MissingRequiredInput(nameof(request.GameIds))); + } + }) + .AddValidator(async ctx => + { + if ((request?.GameIds?.Length ?? 0) == 0) + { + return; + } + + var gamesExist = await _store + .WithNoTracking() + .Where(g => finalGameIds.Contains(g.Id)) + .Select(g => g.Id) + .ToArrayAsync(cancellationToken); + + if (gamesExist.Length != request.GameIds.Length) + { + foreach (var gameId in request.GameIds.Where(gId => !gamesExist.Contains(gId))) + { + ctx.AddValidationException(new ResourceNotFound(gameId)); + } + } + + }) + .Validate(cancellationToken); + + var batch = await _importExportService.ExportGames + ( + request.GameIds, + request.IncludePracticeAreaDefaultCertificateTemplate.GetValueOrDefault(), + cancellationToken + ); + + return new ExportGamesResult { ExportBatch = batch }; } } diff --git a/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs b/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs index 5d136e14..4bc1ecc1 100644 --- a/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs +++ b/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs @@ -1,78 +1,6 @@ -using System; - namespace Gameboard.Api.Features.Games; -public sealed class ExportGameResult -{ - public required string ExportBatchId { get; set; } - public required string GameId { get; set; } - public required GameImportExport GameData { get; set; } -} - -public sealed class GameImportExport -{ - public required string ExportedGameId { get; set; } - public required string Name { get; set; } - public required string Competition { get; set; } - public required string Season { get; set; } - public required string Track { get; set; } - public required string Division { get; set; } - public required string Logo { get; set; } - public required string Sponsor { get; set; } - public required string Background { get; set; } - public required string TestCode { get; set; } - public required DateTimeOffset? GameStart { get; set; } - public required DateTimeOffset? GameEnd { get; set; } - public required string GameMarkdown { get; set; } - public required string FeedbackConfig { get; set; } - public required string RegistrationMarkdown { get; set; } - public required DateTimeOffset? RegistrationOpen { get; set; } - public required DateTimeOffset? RegistrationClose { get; set; } - public required GameRegistrationType RegistrationType { get; set; } - public required int MinTeamSize { get; set; } = 1; - public required int MaxTeamSize { get; set; } = 1; - public required int? MaxAttempts { get; set; } = 0; - public required bool RequireSponsoredTeam { get; set; } - public required int SessionMinutes { get; set; } = 60; - public required int? SessionLimit { get; set; } = 0; - public required int? SessionAvailabilityWarningThreshold { get; set; } - public required int GamespaceLimitPerSession { get; set; } = 1; - public required bool IsPublished { get; set; } - public required bool AllowLateStart { get; set; } - public required bool AllowPreview { get; set; } - public required bool AllowPublicScoreboardAccess { get; set; } - public required bool AllowReset { get; set; } - public required string CardText1 { get; set; } - public required string CardText2 { get; set; } - public required string CardText3 { get; set; } - public required bool IsFeatured { get; set; } - - // mode stuff - public string ExternalHostId { get; set; } - public GameImportExportExternalHost ExternalHost { get; set; } - public string Mode { get; set; } - public PlayerMode PlayerMode { get; set; } - public bool RequireSynchronizedStart { get; set; } = false; - public bool ShowOnHomePageInPracticeMode { get; set; } = false; - - // feedback - // public string ChallengesFeedbackTemplateId { get; set; } - // public FeedbackTemplate ChallengesFeedbackTemplate { get; set; } - // public string FeedbackTemplateId { get; set; } - // public FeedbackTemplate FeedbackTemplate { get; set; } - - // public string CertificateTemplateId { get; set; } - // public CertificateTemplate CertificateTemplate { get; set; } - // public string PracticeCertificateTemplateId { get; set; } - // public CertificateTemplate PracticeCertificateTemplate { get; set; } -} - -public sealed class GameImportExportExternalHost -{ - public required string ExportedId { get; set; } -} - -public sealed class GameImportExportFeedbackTemplate +public sealed class ExportGamesResult { - public required string ExportedFeedbackTemplateId { get; set; } + public required GameImportExportBatch ExportBatch { get; set; } } diff --git a/src/Gameboard.Api/Features/GameEngine/Services/VmUrlResolver.cs b/src/Gameboard.Api/Features/GameEngine/Services/VmUrlResolver.cs index eff28576..6588c94d 100644 --- a/src/Gameboard.Api/Features/GameEngine/Services/VmUrlResolver.cs +++ b/src/Gameboard.Api/Features/GameEngine/Services/VmUrlResolver.cs @@ -22,13 +22,12 @@ public string ResolveUrl(GameEngineVmState vmState) var url = _appUrlService.ToAppAbsoluteUrl("mks"); var urlBuilder = new UriBuilder(url) { - Query = $"f=1&s={vmState.IsolationId}&v={vmState.Name}" + Query = $"f=1&s={vmState.IsolationId}&v={vmState.Name}", + // constructing the UrlBuilder this way makes it include the port by default + // (so don't do that) + Port = -1 }; - // constructing the UrlBuilder this way makes it include the port by default - // (so don't do that) - urlBuilder.Port = -1; - return urlBuilder.ToString(); } } diff --git a/src/Gameboard.Api/Structure/AppSettings.cs b/src/Gameboard.Api/Structure/AppSettings.cs index 99e0977e..2699ed85 100644 --- a/src/Gameboard.Api/Structure/AppSettings.cs +++ b/src/Gameboard.Api/Structure/AppSettings.cs @@ -171,6 +171,8 @@ public class CoreOptions public bool NamesImportFromIdp { get; set; } = false; public string ImageFolder { get; set; } = "wwwroot/img"; public string DocFolder { get; set; } = "wwwroot/doc"; + public string ExportFolder { get; set; } = "wwwroot/export"; + public string ImportFolder { get; set; } = "wwwroot/import"; public string SupportUploadsRequestPath { get; set; } = "supportfiles"; public string SupportUploadsFolder { get; set; } = "wwwroot/supportfiles"; public string ChallengeDocUrl { get; set; } diff --git a/src/Gameboard.Api/Structure/Exceptions.cs b/src/Gameboard.Api/Structure/Exceptions.cs index 1cb6e264..d18cae32 100644 --- a/src/Gameboard.Api/Structure/Exceptions.cs +++ b/src/Gameboard.Api/Structure/Exceptions.cs @@ -67,7 +67,6 @@ internal ValidationTypeFailure() : } public class ActionForbidden : Exception { } - public class EntityNotFound : Exception { } public class SessionNotAdjustable : Exception { } public class InvalidConsoleAction : Exception { } public class AlreadyExists : Exception { } From 420f89f336c34e2dc83776ae9158f881c8f10f05 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 14 Jan 2025 14:36:37 -0500 Subject: [PATCH 11/26] MVP import/export endpoints --- .gitignore | 1 + .../Fixtures/GameboardCustomization.cs | 4 +- .../Common/Services/JsonService.cs | 6 + .../Common/Services/ZipService.cs | 15 +- .../Data/Entities/FeedbackTemplate.cs | 6 +- .../Data/Entities/PracticeModeSettings.cs | 1 - ...93315_UpdateChallengeSpecModel.Designer.cs | 2238 ++++++++++++++++ ...20250114193315_UpdateChallengeSpecModel.cs | 39 + ...meboardDbContextPostgreSQLModelSnapshot.cs | 7 - ...93328_UpdateChallengeSpecModel.Designer.cs | 2239 +++++++++++++++++ ...20250114193328_UpdateChallengeSpecModel.cs | 39 + ...ameboardDbContextSqlServerModelSnapshot.cs | 7 - .../Features/Game/GameController.cs | 15 + .../GameImportExportExceptions.cs | 11 - .../ImportExport/GameImportExportModels.cs | 25 +- .../ImportExport/GameImportExportService.cs | 284 ++- .../Requests/ExportGame/ExportGame.cs | 2 +- .../Requests/ExportGame/ExportGameModels.cs | 0 .../Requests/ImportGames/ImportGames.cs | 43 + .../Requests/ImportGames/ImportGamesModels.cs | 10 + .../Features/Practice/PracticeModels.cs | 1 - .../Features/Practice/PracticeService.cs | 45 +- .../Practice/UpdatePracticeModeSettings.cs | 2 + .../Features/Support/AutoTagService.cs | 1 - .../Support/Requests/UpdateSupportSettings.cs | 4 +- src/Gameboard.Api/Gameboard.Api.csproj | 1 + src/Gameboard.Api/Structure/MimeTypes.cs | 2 + 27 files changed, 4966 insertions(+), 82 deletions(-) create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20250114193315_UpdateChallengeSpecModel.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20250114193315_UpdateChallengeSpecModel.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20250114193328_UpdateChallengeSpecModel.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20250114193328_UpdateChallengeSpecModel.cs delete mode 100644 src/Gameboard.Api/Features/Game/ImportExport/GameImportExportExceptions.cs rename src/Gameboard.Api/Features/Game/{ => ImportExport}/Requests/ExportGame/ExportGame.cs (97%) rename src/Gameboard.Api/Features/Game/{ => ImportExport}/Requests/ExportGame/ExportGameModels.cs (100%) create mode 100644 src/Gameboard.Api/Features/Game/ImportExport/Requests/ImportGames/ImportGames.cs create mode 100644 src/Gameboard.Api/Features/Game/ImportExport/Requests/ImportGames/ImportGamesModels.cs diff --git a/.gitignore b/.gitignore index 2e79c8fb..04adecc9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ src/Gameboard.Api/wwwroot/temp/* # stuff that gameboard makes as part of being itself src/Gameboard.Api/wwwroot/export/* +src/Gameboard.Api/wwwroot/import/* diff --git a/src/Gameboard.Api.Tests.Shared/Fixtures/GameboardCustomization.cs b/src/Gameboard.Api.Tests.Shared/Fixtures/GameboardCustomization.cs index b4273604..2a6ef245 100644 --- a/src/Gameboard.Api.Tests.Shared/Fixtures/GameboardCustomization.cs +++ b/src/Gameboard.Api.Tests.Shared/Fixtures/GameboardCustomization.cs @@ -97,10 +97,8 @@ private void RegisterDefaultEntityModels(IFixture fixture) fixture.Register(() => new PracticeModeSettings { Id = fixture.Create(), - CertificateHtmlTemplate = null, DefaultPracticeSessionLengthMinutes = 60, - IntroTextMarkdown = null, - SuggestedSearches = "" + SuggestedSearches = string.Empty }); fixture.Register(() => new() diff --git a/src/Gameboard.Api/Common/Services/JsonService.cs b/src/Gameboard.Api/Common/Services/JsonService.cs index 501dd0f7..a705ebd8 100644 --- a/src/Gameboard.Api/Common/Services/JsonService.cs +++ b/src/Gameboard.Api/Common/Services/JsonService.cs @@ -11,6 +11,7 @@ public interface IJsonService string Serialize(T obj) where T : class; Task SerializeAsync(T obj, Stream stream) where T : class; T Deserialize(string json) where T : class; + ValueTask DeserializeAsync(Stream stream) where T : class; } internal class JsonService : IJsonService @@ -61,6 +62,11 @@ public T Deserialize(string json) where T : class return JsonSerializer.Deserialize(json, Options); } + public ValueTask DeserializeAsync(Stream stream) where T : class + { + return JsonSerializer.DeserializeAsync(stream); + } + public string Serialize(T obj) where T : class { return JsonSerializer.Serialize(obj, Options); diff --git a/src/Gameboard.Api/Common/Services/ZipService.cs b/src/Gameboard.Api/Common/Services/ZipService.cs index 355c1e98..8e6f58fb 100644 --- a/src/Gameboard.Api/Common/Services/ZipService.cs +++ b/src/Gameboard.Api/Common/Services/ZipService.cs @@ -1,25 +1,30 @@ using System.IO; using System.IO.Compression; +using System.Threading.Tasks; namespace Gameboard.Api.Common.Services; public interface IZipService { public ZipArchive Zip(string outputPath, string[] filePaths, string relativeRoot = null); - public ZipArchive ZipDirectory(string outputPath, string directoryPath); + public Task ZipDirectory(string outputPath, string directoryPath); } internal sealed class ZipService : IZipService { - public ZipArchive ZipDirectory(string outputPath, string directoryPath) + public async Task ZipDirectory(string outputPath, string directoryPath) { using var stream = File.Open(outputPath, FileMode.Create, FileAccess.ReadWrite); - var archive = new ZipArchive(stream, ZipArchiveMode.Create); + using var archive = new ZipArchive(stream, ZipArchiveMode.Create); var fullDirectoryPath = Path.GetFullPath(directoryPath); - foreach (var file in Directory.EnumerateFileSystemEntries(fullDirectoryPath)) + foreach (var file in Directory.GetFiles(fullDirectoryPath, "*", SearchOption.AllDirectories)) { - archive.CreateEntryFromFile(file, Path.GetRelativePath(fullDirectoryPath, file)); + using var fileStream = File.Open(file, FileMode.Open, FileAccess.Read); + using var fileReader = new StreamReader(fileStream); + var entry = archive.CreateEntry(Path.GetRelativePath(fullDirectoryPath, file)); + using var entryStream = entry.Open(); + await fileStream.CopyToAsync(entryStream); } return archive; diff --git a/src/Gameboard.Api/Data/Entities/FeedbackTemplate.cs b/src/Gameboard.Api/Data/Entities/FeedbackTemplate.cs index 2e77d834..ff102a22 100644 --- a/src/Gameboard.Api/Data/Entities/FeedbackTemplate.cs +++ b/src/Gameboard.Api/Data/Entities/FeedbackTemplate.cs @@ -11,7 +11,7 @@ public sealed class FeedbackTemplate : IEntity public required string CreatedByUserId { get; set; } public Data.User CreatedByUser { get; set; } - public required ICollection Submissions { get; set; } = []; - public required ICollection UseAsFeedbackTemplateForGames { get; set; } = []; - public required ICollection UseAsFeedbackTemplateForGameChallenges { get; set; } = []; + public ICollection Submissions { get; set; } = []; + public ICollection UseAsFeedbackTemplateForGames { get; set; } = []; + public ICollection UseAsFeedbackTemplateForGameChallenges { get; set; } = []; } diff --git a/src/Gameboard.Api/Data/Entities/PracticeModeSettings.cs b/src/Gameboard.Api/Data/Entities/PracticeModeSettings.cs index f662a57b..43cbf3b4 100644 --- a/src/Gameboard.Api/Data/Entities/PracticeModeSettings.cs +++ b/src/Gameboard.Api/Data/Entities/PracticeModeSettings.cs @@ -6,7 +6,6 @@ public class PracticeModeSettings : IEntity { public string Id { get; set; } public int? AttemptLimit { get; set; } - public string CertificateHtmlTemplate { get; set; } public int DefaultPracticeSessionLengthMinutes { get; set; } public string IntroTextMarkdown { get; set; } public int? MaxConcurrentPracticeSessions { get; set; } diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20250114193315_UpdateChallengeSpecModel.Designer.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20250114193315_UpdateChallengeSpecModel.Designer.cs new file mode 100644 index 00000000..c3688484 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20250114193315_UpdateChallengeSpecModel.Designer.cs @@ -0,0 +1,2238 @@ +// +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("20250114193315_UpdateChallengeSpecModel")] + partial class UpdateChallengeSpecModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Gameboard.Api.Data.ApiKey", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ExpiresOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NULL"); + + b.Property("GeneratedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnerId") + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ArchivedChallenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Duration") + .HasColumnType("bigint"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Events") + .HasColumnType("text"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasGamespaceDeployed") + .HasColumnType("boolean"); + + b.Property("LastScoreTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("PlayerName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("Result") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("text"); + + b.Property("Submissions") + .HasColumnType("text"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamMembers") + .HasColumnType("text"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("ArchivedChallenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.AwardedChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeBonusId") + .HasColumnType("character varying(40)"); + + b.Property("ChallengeId") + .HasColumnType("character varying(40)"); + + b.Property("EnteredOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("InternalSummary") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeBonusId"); + + b.HasIndex("ChallengeId"); + + b.ToTable("AwardedChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.CertificateTemplate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("CertificateTemplate"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameEngineType") + .HasColumnType("integer"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GraderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasDeployedGamespace") + .HasColumnType("boolean"); + + b.Property("LastScoreTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PendingSubmission") + .HasColumnType("text"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("double precision"); + + b.Property("SpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("text"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("WhenCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeBonusType") + .HasColumnType("integer"); + + b.Property("ChallengeSpecId") + .HasColumnType("character varying(40)"); + + b.Property("Description") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PointValue") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeSpecId"); + + b.ToTable("ChallengeBonuses"); + + b.HasDiscriminator("ChallengeBonusType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Text") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeEvents"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RequiredId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RequiredScore") + .HasColumnType("double precision"); + + b.Property("TargetId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeGates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AverageDeploySeconds") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameEngineType") + .HasColumnType("integer"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("IsHidden") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("ShowSolutionGuideInCompetitiveMode") + .HasColumnType("boolean"); + + b.Property("SolutionGuideUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("Text") + .HasColumnType("text"); + + b.Property("X") + .HasColumnType("real"); + + b.Property("Y") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeSpecs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSubmission", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Answers") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChallengeId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("double precision") + .HasDefaultValue(0.0); + + b.Property("SubmittedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeSubmissions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.DenormalizedTeamScore", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CumulativeTimeMs") + .HasColumnType("double precision"); + + b.Property("GameId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("ScoreAdvanced") + .HasColumnType("double precision"); + + b.Property("ScoreAutoBonus") + .HasColumnType("double precision"); + + b.Property("ScoreChallenge") + .HasColumnType("double precision"); + + b.Property("ScoreManualBonus") + .HasColumnType("double precision"); + + b.Property("ScoreOverall") + .HasColumnType("double precision"); + + b.Property("SolveCountComplete") + .HasColumnType("integer"); + + b.Property("SolveCountNone") + .HasColumnType("integer"); + + b.Property("SolveCountPartial") + .HasColumnType("integer"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("DenormalizedTeamScores"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Extension", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("HostUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasAlternateKey("Type"); + + b.ToTable("Extensions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameHost", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClientUrl") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DestroyResourcesOnDeployFailure") + .HasColumnType("boolean"); + + b.Property("GamespaceDeployBatchSize") + .HasColumnType("integer"); + + b.Property("HostApiKey") + .HasMaxLength(70) + .HasColumnType("character varying(70)"); + + b.Property("HostUrl") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HttpTimeoutInSeconds") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PingEndpoint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StartupEndpoint") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TeamExtendedEndpoint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("ExternalGameHosts"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameTeam", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("DeployStatus") + .HasColumnType("integer"); + + b.Property("ExternalGameUrl") + .HasColumnType("text"); + + b.Property("GameId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasAlternateKey("TeamId", "GameId"); + + b.HasIndex("GameId"); + + b.ToTable("ExternalGameTeams"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Answers") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Submitted") + .HasColumnType("boolean"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackSubmission", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AttachedEntityType") + .HasColumnType("integer"); + + b.Property("FeedbackTemplateId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("WhenCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("WhenEdited") + .HasColumnType("timestamp with time zone"); + + b.Property("WhenFinalized") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FeedbackTemplateId"); + + b.HasIndex("UserId"); + + b.ToTable("FeedbackSubmissions"); + + b.HasDiscriminator("AttachedEntityType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("HelpText") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("FeedbackTemplates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AllowLateStart") + .HasColumnType("boolean"); + + b.Property("AllowPreview") + .HasColumnType("boolean"); + + b.Property("AllowPublicScoreboardAccess") + .HasColumnType("boolean"); + + b.Property("AllowReset") + .HasColumnType("boolean"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CertificateTemplateId") + .HasColumnType("text"); + + b.Property("CertificateTemplateLegacy") + .HasColumnType("text"); + + b.Property("ChallengesFeedbackTemplateId") + .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("FeedbackTemplateId") + .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("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("PracticeCertificateTemplateId") + .HasColumnType("text"); + + 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("SessionAvailabilityWarningThreshold") + .HasColumnType("integer"); + + 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("Track") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CertificateTemplateId"); + + b.HasIndex("ChallengesFeedbackTemplateId"); + + b.HasIndex("ExternalHostId"); + + b.HasIndex("FeedbackTemplateId"); + + b.HasIndex("PracticeCertificateTemplateId"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EnteredByUserId") + .HasColumnType("character varying(40)"); + + b.Property("EnteredOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("PointValue") + .HasColumnType("double precision"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualBonuses"); + + b.HasDiscriminator("Type"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Advanced") + .HasColumnType("boolean"); + + b.Property("AdvancedFromGameId") + .HasColumnType("character varying(40)"); + + b.Property("AdvancedFromPlayerId") + .HasColumnType("character varying(40)"); + + b.Property("AdvancedFromTeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AdvancedWithScore") + .HasColumnType("double precision"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CorrectCount") + .HasColumnType("integer"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("InviteCode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("IsLateStart") + .HasColumnType("boolean"); + + b.Property("IsReady") + .HasColumnType("boolean"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PartialCount") + .HasColumnType("integer"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("SessionBegin") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionMinutes") + .HasColumnType("double precision"); + + b.Property("SponsorId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("WhenCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AdvancedFromGameId"); + + b.HasIndex("AdvancedFromPlayerId"); + + b.HasIndex("GameId"); + + b.HasIndex("SponsorId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.HasIndex("Id", "TeamId"); + + b.HasIndex("UserId", "TeamId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AttemptLimit") + .HasColumnType("integer"); + + b.Property("CertificateTemplateId") + .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("CertificateTemplateId") + .IsUnique(); + + b.HasIndex("UpdatedByUserId") + .IsUnique(); + + b.ToTable("PracticeModeSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCertificate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("OwnerUserId") + .HasColumnType("character varying(40)"); + + b.Property("PublishedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PublishedCertificate"); + + b.HasDiscriminator("Mode"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("Logo") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentSponsorId") + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ParentSponsorId"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SupportPageGreeting") + .HasColumnType("text"); + + b.Property("UpdatedByUserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique(); + + b.ToTable("SupportSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettingsAutoTag", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConditionType") + .HasColumnType("integer"); + + b.Property("ConditionValue") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("SupportSettingsId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("SupportSettingsId"); + + b.ToTable("SupportSettingsAutoTags"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("EndsOn") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDismissible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("StartsOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SystemNotifications"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotificationInteraction", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DismissedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("SawCalloutOn") + .HasColumnType("timestamp with time zone"); + + b.Property("SawFullNotificationOn") + .HasColumnType("timestamp with time zone"); + + b.Property("SystemNotificationId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasAlternateKey("SystemNotificationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("SystemNotificationInteractions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Key") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseSerialColumn(b.Property("Key")); + + b.Property("Label") + .HasColumnType("text"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RequesterId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("StaffCreated") + .HasColumnType("boolean"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("PlayerId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TicketId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TicketId"); + + b.HasIndex("UserId"); + + b.ToTable("TicketActivity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasDefaultSponsor") + .HasColumnType("boolean"); + + b.Property("LastLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LoginCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("0"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayAudioOnBrowserNotification") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("SponsorId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("SponsorId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonusCompleteSolveRank", b => + { + b.HasBaseType("Gameboard.Api.Data.ChallengeBonus"); + + b.Property("SolveRank") + .HasColumnType("integer"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackSubmissionChallengeSpec", b => + { + b.HasBaseType("Gameboard.Api.Data.FeedbackSubmission"); + + b.Property("ChallengeSpecId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.HasIndex("ChallengeSpecId"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackSubmissionGame", b => + { + b.HasBaseType("Gameboard.Api.Data.FeedbackSubmission"); + + b.Property("GameId") + .IsRequired() + .HasColumnType("character varying(40)"); + + b.HasIndex("GameId"); + + b.HasDiscriminator().HasValue(1); + }); + + 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.CertificateTemplate", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedCertificateTemplates") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + 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.FeedbackSubmission", b => + { + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "FeedbackTemplate") + .WithMany("Submissions") + .HasForeignKey("FeedbackTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("FeedbackSubmissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Gameboard.Api.Features.Feedback.QuestionSubmission", "Responses", b1 => + { + b1.Property("FeedbackSubmissionId") + .HasColumnType("text"); + + b1.Property("Id") + .HasColumnType("text"); + + b1.Property("Answer") + .HasColumnType("text"); + + b1.Property("Prompt") + .HasColumnType("text"); + + b1.Property("ShortName") + .HasColumnType("text"); + + b1.HasKey("FeedbackSubmissionId", "Id"); + + b1.ToTable("FeedbackSubmissionResponses", (string)null); + + b1.WithOwner() + .HasForeignKey("FeedbackSubmissionId"); + }); + + b.Navigation("FeedbackTemplate"); + + b.Navigation("Responses"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedFeedbackTemplates") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.HasOne("Gameboard.Api.Data.CertificateTemplate", "CertificateTemplate") + .WithMany("UseAsTemplateForGames") + .HasForeignKey("CertificateTemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "ChallengesFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGames") + .HasForeignKey("ChallengesFeedbackTemplateId"); + + b.HasOne("Gameboard.Api.Data.ExternalGameHost", "ExternalHost") + .WithMany("UsedByGames") + .HasForeignKey("ExternalHostId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "FeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGameChallenges") + .HasForeignKey("FeedbackTemplateId"); + + b.HasOne("Gameboard.Api.Data.CertificateTemplate", "PracticeCertificateTemplate") + .WithMany("UseAsPracticeTemplateForGames") + .HasForeignKey("PracticeCertificateTemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CertificateTemplate"); + + b.Navigation("ChallengesFeedbackTemplate"); + + b.Navigation("ExternalHost"); + + b.Navigation("FeedbackTemplate"); + + b.Navigation("PracticeCertificateTemplate"); + }); + + 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.CertificateTemplate", "CertificateTemplate") + .WithOne("UsedAsPracticeModeDefault") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "CertificateTemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedPracticeModeSettings") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "UpdatedByUserId"); + + b.Navigation("CertificateTemplate"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.HasOne("Gameboard.Api.Data.Sponsor", "ParentSponsor") + .WithMany("ChildSponsors") + .HasForeignKey("ParentSponsorId"); + + b.Navigation("ParentSponsor"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedSupportSettings") + .HasForeignKey("Gameboard.Api.Data.SupportSettings", "UpdatedByUserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettingsAutoTag", b => + { + b.HasOne("Gameboard.Api.Data.SupportSettings", "SupportSettings") + .WithMany("AutoTags") + .HasForeignKey("SupportSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SupportSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedSystemNotifications") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotificationInteraction", b => + { + b.HasOne("Gameboard.Api.Data.SystemNotification", "SystemNotification") + .WithMany("Interactions") + .HasForeignKey("SystemNotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("SystemNotificationInteractions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemNotification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Tickets") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Creator") + .WithMany() + .HasForeignKey("CreatorId"); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Tickets") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Requester") + .WithMany() + .HasForeignKey("RequesterId"); + + b.Navigation("Assignee"); + + b.Navigation("Challenge"); + + b.Navigation("Creator"); + + b.Navigation("Player"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Ticket", "Ticket") + .WithMany("Activity") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Assignee"); + + b.Navigation("Ticket"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.HasOne("Gameboard.Api.Data.Sponsor", "Sponsor") + .WithMany("SponsoredUsers") + .HasForeignKey("SponsorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sponsor"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackSubmissionChallengeSpec", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("FeedbackSubmissions") + .HasForeignKey("ChallengeSpecId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChallengeSpec"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackSubmissionGame", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("FeedbackSubmissions") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + 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.CertificateTemplate", b => + { + b.Navigation("UseAsPracticeTemplateForGames"); + + b.Navigation("UseAsTemplateForGames"); + + b.Navigation("UsedAsPracticeModeDefault"); + }); + + 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("FeedbackSubmissions"); + + b.Navigation("PublishedPracticeCertificates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameHost", b => + { + b.Navigation("UsedByGames"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Navigation("Submissions"); + + b.Navigation("UseAsFeedbackTemplateForGameChallenges"); + + b.Navigation("UseAsFeedbackTemplateForGames"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("AdvancedPlayers"); + + b.Navigation("Challenges"); + + b.Navigation("DenormalizedTeamScores"); + + b.Navigation("ExternalGameTeams"); + + b.Navigation("Feedback"); + + b.Navigation("FeedbackSubmissions"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("AdvancedToPlayers"); + + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Navigation("ChildSponsors"); + + b.Navigation("SponsoredPlayers"); + + b.Navigation("SponsoredUsers"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.Navigation("AutoTags"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.Navigation("Interactions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("CreatedCertificateTemplates"); + + b.Navigation("CreatedFeedbackTemplates"); + + b.Navigation("CreatedSystemNotifications"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualBonuses"); + + b.Navigation("Feedback"); + + b.Navigation("FeedbackSubmissions"); + + 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/20250114193315_UpdateChallengeSpecModel.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20250114193315_UpdateChallengeSpecModel.cs new file mode 100644 index 00000000..73a78231 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20250114193315_UpdateChallengeSpecModel.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + /// + public partial class UpdateChallengeSpecModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CertificateHtmlTemplate", + table: "PracticeModeSettings"); + + migrationBuilder.DropColumn( + name: "TestCode", + table: "Games"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CertificateHtmlTemplate", + table: "PracticeModeSettings", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "TestCode", + table: "Games", + type: "character varying(64)", + maxLength: 64, + nullable: true); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs index b65e0504..91a5be4f 100644 --- a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs @@ -906,10 +906,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(40) .HasColumnType("character varying(40)"); - b.Property("TestCode") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - b.Property("Track") .HasMaxLength(64) .HasColumnType("character varying(64)"); @@ -1087,9 +1083,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AttemptLimit") .HasColumnType("integer"); - b.Property("CertificateHtmlTemplate") - .HasColumnType("text"); - b.Property("CertificateTemplateId") .HasColumnType("text"); diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20250114193328_UpdateChallengeSpecModel.Designer.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20250114193328_UpdateChallengeSpecModel.Designer.cs new file mode 100644 index 00000000..e024cd56 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20250114193328_UpdateChallengeSpecModel.Designer.cs @@ -0,0 +1,2239 @@ +// +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("20250114193328_UpdateChallengeSpecModel")] + partial class UpdateChallengeSpecModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Gameboard.Api.Data.ApiKey", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ExpiresOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasDefaultValueSql("NULL"); + + b.Property("GeneratedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasDefaultValueSql("NOW()"); + + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("OwnerId") + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ArchivedChallenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Duration") + .HasColumnType("bigint"); + + b.Property("EndTime") + .HasColumnType("datetimeoffset"); + + b.Property("Events") + .HasColumnType("nvarchar(max)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HasGamespaceDeployed") + .HasColumnType("bit"); + + b.Property("LastScoreTime") + .HasColumnType("datetimeoffset"); + + b.Property("LastSyncTime") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("PlayerName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("Result") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("StartTime") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Submissions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamMembers") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("ArchivedChallenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.AwardedChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeBonusId") + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .HasColumnType("nvarchar(40)"); + + b.Property("EnteredOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasDefaultValueSql("NOW()"); + + b.Property("InternalSummary") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeBonusId"); + + b.HasIndex("ChallengeId"); + + b.ToTable("AwardedChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.CertificateTemplate", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("CertificateTemplate"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("EndTime") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameEngineType") + .HasColumnType("int"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GraderKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HasDeployedGamespace") + .HasColumnType("bit"); + + b.Property("LastScoreTime") + .HasColumnType("datetimeoffset"); + + b.Property("LastSyncTime") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PendingSubmission") + .HasColumnType("nvarchar(max)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("float"); + + b.Property("SpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("StartTime") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeBonusType") + .HasColumnType("int"); + + b.Property("ChallengeSpecId") + .HasColumnType("nvarchar(40)"); + + b.Property("Description") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PointValue") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeSpecId"); + + b.ToTable("ChallengeBonuses"); + + b.HasDiscriminator("ChallengeBonusType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Text") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeEvents"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RequiredId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RequiredScore") + .HasColumnType("float"); + + b.Property("TargetId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeGates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AverageDeploySeconds") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Disabled") + .HasColumnType("bit"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameEngineType") + .HasColumnType("int"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsHidden") + .HasColumnType("bit"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("ShowSolutionGuideInCompetitiveMode") + .HasColumnType("bit"); + + b.Property("SolutionGuideUrl") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Text") + .HasColumnType("nvarchar(max)"); + + b.Property("X") + .HasColumnType("real"); + + b.Property("Y") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeSpecs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSubmission", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Answers") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("float") + .HasDefaultValue(0.0); + + b.Property("SubmittedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeSubmissions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.DenormalizedTeamScore", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CumulativeTimeMs") + .HasColumnType("float"); + + b.Property("GameId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Rank") + .HasColumnType("int"); + + b.Property("ScoreAdvanced") + .HasColumnType("float"); + + b.Property("ScoreAutoBonus") + .HasColumnType("float"); + + b.Property("ScoreChallenge") + .HasColumnType("float"); + + b.Property("ScoreManualBonus") + .HasColumnType("float"); + + b.Property("ScoreOverall") + .HasColumnType("float"); + + b.Property("SolveCountComplete") + .HasColumnType("int"); + + b.Property("SolveCountNone") + .HasColumnType("int"); + + b.Property("SolveCountPartial") + .HasColumnType("int"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamName") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("DenormalizedTeamScores"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Extension", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("HostUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasAlternateKey("Type"); + + b.ToTable("Extensions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameHost", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ClientUrl") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DestroyResourcesOnDeployFailure") + .HasColumnType("bit"); + + b.Property("GamespaceDeployBatchSize") + .HasColumnType("int"); + + b.Property("HostApiKey") + .HasMaxLength(70) + .HasColumnType("nvarchar(70)"); + + b.Property("HostUrl") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HttpTimeoutInSeconds") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PingEndpoint") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StartupEndpoint") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TeamExtendedEndpoint") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("ExternalGameHosts"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameTeam", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("DeployStatus") + .HasColumnType("int"); + + b.Property("ExternalGameUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("GameId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasAlternateKey("TeamId", "GameId"); + + b.HasIndex("GameId"); + + b.ToTable("ExternalGameTeams"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Answers") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Submitted") + .HasColumnType("bit"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackSubmission", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AttachedEntityType") + .HasColumnType("int"); + + b.Property("FeedbackTemplateId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.Property("WhenEdited") + .HasColumnType("datetimeoffset"); + + b.Property("WhenFinalized") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("FeedbackTemplateId"); + + b.HasIndex("UserId"); + + b.ToTable("FeedbackSubmissions"); + + b.HasDiscriminator("AttachedEntityType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("HelpText") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("FeedbackTemplates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AllowLateStart") + .HasColumnType("bit"); + + b.Property("AllowPreview") + .HasColumnType("bit"); + + b.Property("AllowPublicScoreboardAccess") + .HasColumnType("bit"); + + b.Property("AllowReset") + .HasColumnType("bit"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CertificateTemplateId") + .HasColumnType("nvarchar(450)"); + + b.Property("CertificateTemplateLegacy") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengesFeedbackTemplateId") + .HasColumnType("nvarchar(450)"); + + 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("FeedbackTemplateId") + .HasColumnType("nvarchar(450)"); + + 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("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("PracticeCertificateTemplateId") + .HasColumnType("nvarchar(450)"); + + 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("SessionAvailabilityWarningThreshold") + .HasColumnType("int"); + + 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("Track") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("CertificateTemplateId"); + + b.HasIndex("ChallengesFeedbackTemplateId"); + + b.HasIndex("ExternalHostId"); + + b.HasIndex("FeedbackTemplateId"); + + b.HasIndex("PracticeCertificateTemplateId"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EnteredByUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("EnteredOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetimeoffset") + .HasDefaultValueSql("NOW()"); + + b.Property("PointValue") + .HasColumnType("float"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualBonuses"); + + b.HasDiscriminator("Type"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Advanced") + .HasColumnType("bit"); + + b.Property("AdvancedFromGameId") + .HasColumnType("nvarchar(40)"); + + b.Property("AdvancedFromPlayerId") + .HasColumnType("nvarchar(40)"); + + b.Property("AdvancedFromTeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AdvancedWithScore") + .HasColumnType("float"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrectCount") + .HasColumnType("int"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("InviteCode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsLateStart") + .HasColumnType("bit"); + + b.Property("IsReady") + .HasColumnType("bit"); + + b.Property("Mode") + .HasColumnType("int"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PartialCount") + .HasColumnType("int"); + + b.Property("Rank") + .HasColumnType("int"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionBegin") + .HasColumnType("datetimeoffset"); + + b.Property("SessionEnd") + .HasColumnType("datetimeoffset"); + + b.Property("SessionMinutes") + .HasColumnType("float"); + + b.Property("SponsorId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AdvancedFromGameId"); + + b.HasIndex("AdvancedFromPlayerId"); + + b.HasIndex("GameId"); + + b.HasIndex("SponsorId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.HasIndex("Id", "TeamId"); + + b.HasIndex("UserId", "TeamId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AttemptLimit") + .HasColumnType("int"); + + b.Property("CertificateTemplateId") + .HasColumnType("nvarchar(450)"); + + 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("CertificateTemplateId") + .IsUnique() + .HasFilter("[CertificateTemplateId] IS NOT NULL"); + + b.HasIndex("UpdatedByUserId") + .IsUnique() + .HasFilter("[UpdatedByUserId] IS NOT NULL"); + + b.ToTable("PracticeModeSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCertificate", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Mode") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("PublishedCertificate"); + + b.HasDiscriminator("Mode"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Approved") + .HasColumnType("bit"); + + b.Property("Logo") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentSponsorId") + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ParentSponsorId"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("SupportPageGreeting") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedByUserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique(); + + b.ToTable("SupportSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettingsAutoTag", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConditionType") + .HasColumnType("int"); + + b.Property("ConditionValue") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("SupportSettingsId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SupportSettingsId"); + + b.ToTable("SupportSettingsAutoTags"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("EndsOn") + .HasColumnType("datetimeoffset"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsDismissible") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NotificationType") + .HasColumnType("int"); + + b.Property("StartsOn") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SystemNotifications"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotificationInteraction", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("DismissedOn") + .HasColumnType("datetimeoffset"); + + b.Property("SawCalloutOn") + .HasColumnType("datetimeoffset"); + + b.Property("SawFullNotificationOn") + .HasColumnType("datetimeoffset"); + + b.Property("SystemNotificationId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasAlternateKey("SystemNotificationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("SystemNotificationInteractions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("CreatorId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Key") + .HasColumnType("int") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn); + + b.Property("Label") + .HasColumnType("nvarchar(max)"); + + b.Property("LastUpdated") + .HasColumnType("datetimeoffset"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RequesterId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("StaffCreated") + .HasColumnType("bit"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("PlayerId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Message") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TicketId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TicketId"); + + b.HasIndex("UserId"); + + b.ToTable("TicketActivity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HasDefaultSponsor") + .HasColumnType("bit"); + + b.Property("LastLoginDate") + .HasColumnType("datetimeoffset"); + + b.Property("LoginCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("0"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayAudioOnBrowserNotification") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("SponsorId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SponsorId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeBonusCompleteSolveRank", b => + { + b.HasBaseType("Gameboard.Api.Data.ChallengeBonus"); + + b.Property("SolveRank") + .HasColumnType("int"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackSubmissionChallengeSpec", b => + { + b.HasBaseType("Gameboard.Api.Data.FeedbackSubmission"); + + b.Property("ChallengeSpecId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.HasIndex("ChallengeSpecId"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackSubmissionGame", b => + { + b.HasBaseType("Gameboard.Api.Data.FeedbackSubmission"); + + b.Property("GameId") + .IsRequired() + .HasColumnType("nvarchar(40)"); + + b.HasIndex("GameId"); + + b.HasDiscriminator().HasValue(1); + }); + + 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.CertificateTemplate", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedCertificateTemplates") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + 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.FeedbackSubmission", b => + { + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "FeedbackTemplate") + .WithMany("Submissions") + .HasForeignKey("FeedbackTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("FeedbackSubmissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Gameboard.Api.Features.Feedback.QuestionSubmission", "Responses", b1 => + { + b1.Property("FeedbackSubmissionId") + .HasColumnType("nvarchar(450)"); + + b1.Property("Id") + .HasColumnType("nvarchar(450)"); + + b1.Property("Answer") + .HasColumnType("nvarchar(max)"); + + b1.Property("Prompt") + .HasColumnType("nvarchar(max)"); + + b1.Property("ShortName") + .HasColumnType("nvarchar(max)"); + + b1.HasKey("FeedbackSubmissionId", "Id"); + + b1.ToTable("FeedbackSubmissionResponses", (string)null); + + b1.WithOwner() + .HasForeignKey("FeedbackSubmissionId"); + }); + + b.Navigation("FeedbackTemplate"); + + b.Navigation("Responses"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedFeedbackTemplates") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.HasOne("Gameboard.Api.Data.CertificateTemplate", "CertificateTemplate") + .WithMany("UseAsTemplateForGames") + .HasForeignKey("CertificateTemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "ChallengesFeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGames") + .HasForeignKey("ChallengesFeedbackTemplateId"); + + b.HasOne("Gameboard.Api.Data.ExternalGameHost", "ExternalHost") + .WithMany("UsedByGames") + .HasForeignKey("ExternalHostId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.FeedbackTemplate", "FeedbackTemplate") + .WithMany("UseAsFeedbackTemplateForGameChallenges") + .HasForeignKey("FeedbackTemplateId"); + + b.HasOne("Gameboard.Api.Data.CertificateTemplate", "PracticeCertificateTemplate") + .WithMany("UseAsPracticeTemplateForGames") + .HasForeignKey("PracticeCertificateTemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CertificateTemplate"); + + b.Navigation("ChallengesFeedbackTemplate"); + + b.Navigation("ExternalHost"); + + b.Navigation("FeedbackTemplate"); + + b.Navigation("PracticeCertificateTemplate"); + }); + + 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.CertificateTemplate", "CertificateTemplate") + .WithOne("UsedAsPracticeModeDefault") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "CertificateTemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedPracticeModeSettings") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "UpdatedByUserId"); + + b.Navigation("CertificateTemplate"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.HasOne("Gameboard.Api.Data.Sponsor", "ParentSponsor") + .WithMany("ChildSponsors") + .HasForeignKey("ParentSponsorId"); + + b.Navigation("ParentSponsor"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedSupportSettings") + .HasForeignKey("Gameboard.Api.Data.SupportSettings", "UpdatedByUserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettingsAutoTag", b => + { + b.HasOne("Gameboard.Api.Data.SupportSettings", "SupportSettings") + .WithMany("AutoTags") + .HasForeignKey("SupportSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SupportSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.HasOne("Gameboard.Api.Data.User", "CreatedByUser") + .WithMany("CreatedSystemNotifications") + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotificationInteraction", b => + { + b.HasOne("Gameboard.Api.Data.SystemNotification", "SystemNotification") + .WithMany("Interactions") + .HasForeignKey("SystemNotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("SystemNotificationInteractions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemNotification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Tickets") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Creator") + .WithMany() + .HasForeignKey("CreatorId"); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Tickets") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Requester") + .WithMany() + .HasForeignKey("RequesterId"); + + b.Navigation("Assignee"); + + b.Navigation("Challenge"); + + b.Navigation("Creator"); + + b.Navigation("Player"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Ticket", "Ticket") + .WithMany("Activity") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Assignee"); + + b.Navigation("Ticket"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.HasOne("Gameboard.Api.Data.Sponsor", "Sponsor") + .WithMany("SponsoredUsers") + .HasForeignKey("SponsorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sponsor"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackSubmissionChallengeSpec", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("FeedbackSubmissions") + .HasForeignKey("ChallengeSpecId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChallengeSpec"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackSubmissionGame", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("FeedbackSubmissions") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + 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.CertificateTemplate", b => + { + b.Navigation("UseAsPracticeTemplateForGames"); + + b.Navigation("UseAsTemplateForGames"); + + b.Navigation("UsedAsPracticeModeDefault"); + }); + + 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("FeedbackSubmissions"); + + b.Navigation("PublishedPracticeCertificates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ExternalGameHost", b => + { + b.Navigation("UsedByGames"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.FeedbackTemplate", b => + { + b.Navigation("Submissions"); + + b.Navigation("UseAsFeedbackTemplateForGameChallenges"); + + b.Navigation("UseAsFeedbackTemplateForGames"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("AdvancedPlayers"); + + b.Navigation("Challenges"); + + b.Navigation("DenormalizedTeamScores"); + + b.Navigation("ExternalGameTeams"); + + b.Navigation("Feedback"); + + b.Navigation("FeedbackSubmissions"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("AdvancedToPlayers"); + + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Navigation("ChildSponsors"); + + b.Navigation("SponsoredPlayers"); + + b.Navigation("SponsoredUsers"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SupportSettings", b => + { + b.Navigation("AutoTags"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.SystemNotification", b => + { + b.Navigation("Interactions"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("CreatedCertificateTemplates"); + + b.Navigation("CreatedFeedbackTemplates"); + + b.Navigation("CreatedSystemNotifications"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualBonuses"); + + b.Navigation("Feedback"); + + b.Navigation("FeedbackSubmissions"); + + 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/20250114193328_UpdateChallengeSpecModel.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20250114193328_UpdateChallengeSpecModel.cs new file mode 100644 index 00000000..99ad6ef2 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20250114193328_UpdateChallengeSpecModel.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + /// + public partial class UpdateChallengeSpecModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CertificateHtmlTemplate", + table: "PracticeModeSettings"); + + migrationBuilder.DropColumn( + name: "TestCode", + table: "Games"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CertificateHtmlTemplate", + table: "PracticeModeSettings", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "TestCode", + table: "Games", + type: "nvarchar(64)", + maxLength: 64, + nullable: true); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs index 065bebb8..364a4355 100644 --- a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs @@ -907,10 +907,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(40) .HasColumnType("nvarchar(40)"); - b.Property("TestCode") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - b.Property("Track") .HasMaxLength(64) .HasColumnType("nvarchar(64)"); @@ -1088,9 +1084,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AttemptLimit") .HasColumnType("int"); - b.Property("CertificateHtmlTemplate") - .HasColumnType("nvarchar(max)"); - b.Property("CertificateTemplateId") .HasColumnType("nvarchar(450)"); diff --git a/src/Gameboard.Api/Features/Game/GameController.cs b/src/Gameboard.Api/Features/Game/GameController.cs index 4b5e624d..269a59b1 100644 --- a/src/Gameboard.Api/Features/Game/GameController.cs +++ b/src/Gameboard.Api/Features/Game/GameController.cs @@ -22,6 +22,7 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using ServiceStack; namespace Gameboard.Api.Controllers { @@ -165,6 +166,20 @@ public async Task ExportGameSpec([FromBody] GameSpecExport model) public Task ExportGames([FromBody] ExportGameCommand request, CancellationToken cancellationToken) => _mediator.Send(request, cancellationToken); + [HttpPost("/api/games/import")] + public async Task ImportGames([FromForm] IFormFile packageFile, CancellationToken cancellationToken) + { + var package = Array.Empty(); + + using (var memoryStream = new MemoryStream()) + { + await packageFile.CopyToAsync(memoryStream, cancellationToken); + package = memoryStream.GetBufferAsBytes(); + } + + return await _mediator.Send(new ImportGamesCommand(package), cancellationToken); + } + [HttpGet("/api/game/{gameId}/team/{teamId}/gamespace-limit")] public Task GetTeamGamespaceLimitState([FromRoute] string gameId, [FromRoute] string teamId) => _mediator.Send(new GetTeamGamespaceLimitStateQuery(gameId, teamId, Actor)); diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportExceptions.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportExceptions.cs deleted file mode 100644 index 225b9975..00000000 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportExceptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Gameboard.Api.Features.Games; - -public sealed class CantDownloadImage : GameboardException -{ - public CantDownloadImage(string gameId, string imageUrl) : base($"GameId {gameId}: Couldn't download image at {imageUrl}") { } -} - -public sealed class ImageWasEmpty : GameboardException -{ - public ImageWasEmpty(string gameId, string imageUrl) : base($"GameID {gameId}: Image was empty ({imageUrl})") { } -} diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs index 78701c85..0433a058 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Storage; namespace Gameboard.Api.Features.Games; @@ -55,11 +56,30 @@ public sealed class GameImportExportGame public required bool RequireSynchronizedStart { get; set; } public required bool ShowOnHomePageInPracticeMode { get; set; } - public required string ExternalHostId { get; set; } public required string CertificateTemplateId { get; set; } - public required string PracticeCertificateTemplateId { get; set; } public required string ChallengesFeedbackTemplateId { get; set; } + public required GameImportExportChallengeSpec[] Specs { get; set; } + public required string ExternalHostId { get; set; } public required string FeedbackTemplateId { get; set; } + public required string PracticeCertificateTemplateId { get; set; } +} + +public sealed class GameImportExportChallengeSpec +{ + public required string Description { get; set; } + public required bool Disabled { get; set; } + public required string ExternalId { get; set; } + public required GameEngineType GameEngineType { get; set; } + public required bool IsHidden { get; set; } + public required string Name { get; set; } + public required int Points { get; set; } + public required bool ShowSolutionGuideInCompetitiveMode { get; set; } + public required string Tag { get; set; } + public required string Tags { get; set; } + public required string Text { get; set; } + public required float X { get; set; } + public required float Y { get; set; } + public required float R { get; set; } } public sealed class GameImportExportExternalHost @@ -110,5 +130,4 @@ public sealed class ImportedGame { public required string Id { get; set; } public required string Name { get; set; } - public required string ExportId { get; set; } } diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs index ca39f77a..895bccb2 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs @@ -7,35 +7,44 @@ using System.Threading.Tasks; using Gameboard.Api.Common.Services; using Gameboard.Api.Data; +using Gameboard.Api.Features.Practice; using Microsoft.EntityFrameworkCore; -using ServiceStack; +using SharpCompress.Archives; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Readers; +using SharpCompress.Writers; namespace Gameboard.Api.Features.Games; public interface IGameImportExportService { - Task ExportGames(string[] gameIds, bool includePracticeAreaTemplate, CancellationToken cancellationToken); - Task ImportGames(GameImportExportBatch batch, CancellationToken cancellationToken); + Task ExportPackage(string[] gameIds, bool includePracticeAreaTemplate, CancellationToken cancellationToken); + Task ImportPackage(byte[] package, CancellationToken cancellationToken); } internal sealed class GameImportExportService ( + IActingUserService actingUser, CoreOptions coreOptions, IGuidService guids, HttpClient http, IJsonService json, + IPracticeService practice, IStore store, IZipService zip ) : IGameImportExportService { + private readonly IActingUserService _actingUser = actingUser; private readonly CoreOptions _coreOptions = coreOptions; private readonly IGuidService _guids = guids; private readonly HttpClient _http = http; private readonly IJsonService _json = json; + private readonly IPracticeService _practice = practice; private readonly IStore _store = store; private readonly IZipService _zip = zip; - public async Task ExportGames(string[] gameIds, bool includePracticeAreaTemplate, CancellationToken cancellationToken) + public async Task ExportPackage(string[] gameIds, bool includePracticeAreaTemplate, CancellationToken cancellationToken) { // declare a batch number - we'll use this to identify this attempt at exporting var exportBatchId = _guids.Generate(); @@ -50,11 +59,13 @@ public async Task ExportGames(string[] gameIds, bool incl var games = await _store .WithNoTracking() .Include(g => g.CertificateTemplate) - .Include(g => g.ExternalHost) - .Include(g => g.PracticeCertificateTemplate) .Include(g => g.ChallengesFeedbackTemplate) .Include(g => g.FeedbackTemplate) + .Include(g => g.ExternalHost) + .Include(g => g.PracticeCertificateTemplate) + .Include(g => g.Specs) .Where(g => finalGameIds.Contains(g.Id)) + .AsSplitQuery() .ToArrayAsync(cancellationToken); // all the attached entities are exported first, because we need to know their @@ -267,6 +278,26 @@ public async Task ExportGames(string[] gameIds, bool incl SessionMinutes = game.SessionMinutes, ShowOnHomePageInPracticeMode = game.ShowOnHomePageInPracticeMode, + // specs + Specs = game.Specs.Select(s => new GameImportExportChallengeSpec + { + Description = s.Description, + Disabled = s.Disabled, + ExternalId = s.ExternalId, + GameEngineType = s.GameEngineType, + IsHidden = s.IsHidden, + Name = s.Name, + Points = s.Points, + ShowSolutionGuideInCompetitiveMode = s.ShowSolutionGuideInCompetitiveMode, + Tag = s.Tag, + Tags = s.Tags, + Text = s.Text, + X = s.X, + Y = s.Y, + R = s.R + }) + .ToArray(), + // related entities CertificateTemplateId = game.CertificateTemplateId, ChallengesFeedbackTemplateId = game.ChallengesFeedbackTemplateId, @@ -290,47 +321,231 @@ public async Task ExportGames(string[] gameIds, bool incl }; // write the manifest - using var stream = File.OpenWrite(Path.Combine(GetExportBatchRootPath(exportBatchId), "manifest.json")); - await _json.SerializeAsync(batch, stream); + using (var stream = File.OpenWrite(Path.Combine(GetExportBatchRootPath(exportBatchId), "manifest.json"))) + { + await _json.SerializeAsync(batch, stream); + } // zip zip zip - _zip.ZipDirectory - ( - GetExportBatchPackagePath(exportBatchId), - GetExportBatchRootPath(exportBatchId) - ); + using (var archive = ZipArchive.Create()) + { + archive.AddAllFromDirectory(GetExportBatchRootPath(exportBatchId)); + archive.SaveTo(GetExportBatchPackagePath(exportBatchId), new WriterOptions(CompressionType.Deflate)); + } return batch; } - public Task ImportGames(GameImportExportBatch batch, CancellationToken cancellationToken) + public async Task ImportPackage(byte[] package, CancellationToken cancellationToken) { - throw new NotImplementedException(); - } + var importBatchId = _guids.Generate(); + var actingUser = _actingUser.Get(); + Directory.CreateDirectory(GetImportBatchRoot(importBatchId)); + Directory.CreateDirectory(GetImportPackageRoot()); + + // extract the data + var tempArchivePath = Path.Combine(GetImportPackageRoot(), importBatchId) + ".zip"; + using (var tempFile = File.Open(tempArchivePath, FileMode.Create)) + { + await tempFile.WriteAsync(package, cancellationToken); + } - private async Task DownloadImage(string gameId, string url, string localFileName, CancellationToken cancellationToken) - { - var imageBytes = default(byte[]); - try + using (var tempArchiveStream = File.OpenRead(tempArchivePath)) { - var response = await _http.GetAsync(url, cancellationToken); - imageBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + using (var reader = ReaderFactory.Open(tempArchiveStream)) + { + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + reader.WriteEntryToDirectory(GetImportBatchRoot(importBatchId), new ExtractionOptions() + { + ExtractFullPath = true, + Overwrite = true + }); + } + } + } } - catch + + // copy image files + foreach (var imgPath in Directory.EnumerateFileSystemEntries(GetImportBatchImageRoot(importBatchId))) { - throw new CantDownloadImage(gameId, url); + var fileName = Path.GetFileName(imgPath); + File.Copy(imgPath, Path.Combine(_coreOptions.ImageFolder, fileName), true); } - if (imageBytes.Length > 0) + // now read the manifest + var importBatch = default(GameImportExportBatch); + using (var manifestStream = File.OpenRead(Path.Combine(GetImportBatchRoot(importBatchId), "manifest.json"))) { - await File.WriteAllBytesAsync(localFileName, imageBytes, cancellationToken); + importBatch = await _json.DeserializeAsync(manifestStream); } - else + + // certificate templates + if (importBatch.CertificateTemplates.Any()) { - throw new ImageWasEmpty(gameId, url); + var certificateTemplates = importBatch + .CertificateTemplates + .Values + .Select(t => new CertificateTemplate + { + Content = t.Content, + CreatedByUserId = actingUser.Id, + Name = t.Name + }) + .ToArray(); + + await _store.SaveAddRange(certificateTemplates); + + if (importBatch.PracticeAreaCertificateTemplateId.IsNotEmpty()) + { + var updatedSettings = await _practice.GetSettings(cancellationToken); + updatedSettings.CertificateTemplateId = importBatch.PracticeAreaCertificateTemplateId; + await _practice.UpdateSettings(updatedSettings, actingUser.Id, cancellationToken); + } } - return localFileName; + // external hosts + if (importBatch.ExternalHosts.Any()) + { + await _store.SaveAddRange + ( + importBatch + .ExternalHosts + .Values + .Select(h => new ExternalGameHost + { + ClientUrl = h.ClientUrl, + DestroyResourcesOnDeployFailure = h.DestroyResourcesOnDeployFailure, + GamespaceDeployBatchSize = h.GamespaceDeployBatchSize, + HostUrl = h.HostUrl, + HttpTimeoutInSeconds = h.HttpTimeoutInSeconds, + Name = h.Name, + PingEndpoint = h.PingEndpoint, + StartupEndpoint = h.StartupEndpoint, + TeamExtendedEndpoint = h.TeamExtendedEndpoint + }) + .ToArray() + ); + } + + // feedback templates + var feedbackTemplates = importBatch + .FeedbackTemplates + .Values + .Select(t => new FeedbackTemplate + { + Content = t.Content, + CreatedByUserId = actingUser.Id, + HelpText = t.HelpText, + Name = t.Name + }) + .ToArray(); + await _store.SaveAddRange(feedbackTemplates); + + // sponsors + if (importBatch.Sponsors.Any()) + { + await _store.SaveAddRange + ( + importBatch + .Sponsors + .Values + .Select(s => new Data.Sponsor + { + Approved = s.Approved, + Logo = s.LogoFileName, + Name = s.Name, + ParentSponsor = s.ParentSponsor is null ? null : new Data.Sponsor + { + Approved = s.ParentSponsor.Approved, + Logo = s.ParentSponsor.LogoFileName, + Name = s.ParentSponsor.Id, + } + }) + .ToArray() + ); + } + + // and now games! + var importedGames = importBatch.Games.Select(g => new Data.Game + { + Name = g.Name, + Competition = g.Competition, + Season = g.Season, + Track = g.Track, + Division = g.Division, + AllowLateStart = g.AllowLateStart, + AllowPreview = g.AllowPreview, + AllowPublicScoreboardAccess = g.AllowPublicScoreboardAccess, + AllowReset = g.AllowReset, + Background = g.MapImageFileName, + CardText1 = g.CardText1, + CardText2 = g.CardText2, + CardText3 = g.CardText3, + GameStart = g.GameStart ?? DateTime.MinValue, + GameEnd = g.GameEnd ?? DateTime.MinValue, + GameMarkdown = g.GameMarkdown, + GamespaceLimitPerSession = g.GamespaceLimitPerSession, + IsFeatured = g.IsFeatured, + IsPublished = g.IsPublished, + Logo = g.CardImageFileName, + MaxAttempts = g.MaxAttempts ?? 0, + MaxTeamSize = g.MaxTeamSize, + MinTeamSize = g.MinTeamSize, + Mode = g.Mode, + PlayerMode = g.PlayerMode, + RegistrationClose = g.RegistrationClose ?? DateTime.MinValue, + RegistrationOpen = g.RegistrationOpen ?? DateTime.MinValue, + RegistrationMarkdown = g.RegistrationMarkdown, + RegistrationType = g.RegistrationType, + RequireSynchronizedStart = g.RequireSynchronizedStart, + RequireSponsoredTeam = g.RequireSponsoredTeam, + SessionAvailabilityWarningThreshold = g.SessionAvailabilityWarningThreshold, + SessionLimit = g.SessionLimit ?? 0, + SessionMinutes = g.SessionMinutes, + ShowOnHomePageInPracticeMode = g.ShowOnHomePageInPracticeMode, + + // specs + Specs = g.Specs.Select(s => new Data.ChallengeSpec + { + Id = _guids.Generate(), + Description = s.Description, + Disabled = s.Disabled, + ExternalId = s.ExternalId, + GameEngineType = s.GameEngineType, + IsHidden = s.IsHidden, + Name = s.Name, + Points = s.Points, + ShowSolutionGuideInCompetitiveMode = s.ShowSolutionGuideInCompetitiveMode, + Tag = s.Tag, + Tags = s.Tags, + Text = s.Text, + X = s.X, + Y = s.Y, + R = s.R + }) + .ToArray(), + + // related entities + CertificateTemplateId = g.CertificateTemplateId, + ChallengesFeedbackTemplateId = g.ChallengesFeedbackTemplateId, + ExternalHostId = g.ExternalHostId, + FeedbackTemplateId = g.FeedbackTemplateId, + PracticeCertificateTemplateId = g.PracticeCertificateTemplateId, + Sponsor = g.SponsorId + }) + .ToArray(); + + await _store.SaveAddRange(importedGames); + + return importedGames.Select(g => new ImportedGame + { + Id = g.Id, + Name = g.Name + }) + .ToArray(); } private string GetExportBatchPackageName(string exportBatchId) @@ -346,7 +561,16 @@ private string GetExportBatchRootPath(string exportBatchId) => Path.Combine(_coreOptions.ExportFolder, "temp", exportBatchId); private string GetExportBatchImgRootPath(string exportBatchId) - => Path.Combine(GetExportBatchRootPath(exportBatchId), "img"); + => Path.Combine(GetExportBatchRootPath(exportBatchId), "images"); + + private string GetImportPackageRoot() + => Path.Combine(_coreOptions.ImportFolder, "packages"); + + private string GetImportBatchRoot(string importBatchId) + => Path.Combine(_coreOptions.ImportFolder, "temp", importBatchId); + + private string GetImportBatchImageRoot(string importBatchId) + => Path.Combine(_coreOptions.ImportFolder, "temp", importBatchId, "images"); private string GetCardImageFileName(string gameId, string extension) => $"game-{gameId}-card{extension}"; diff --git a/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGame.cs b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGame.cs similarity index 97% rename from src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGame.cs rename to src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGame.cs index c8f9ef22..05578223 100644 --- a/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGame.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGame.cs @@ -63,7 +63,7 @@ await _validator }) .Validate(cancellationToken); - var batch = await _importExportService.ExportGames + var batch = await _importExportService.ExportPackage ( request.GameIds, request.IncludePracticeAreaDefaultCertificateTemplate.GetValueOrDefault(), diff --git a/src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGameModels.cs similarity index 100% rename from src/Gameboard.Api/Features/Game/Requests/ExportGame/ExportGameModels.cs rename to src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGameModels.cs diff --git a/src/Gameboard.Api/Features/Game/ImportExport/Requests/ImportGames/ImportGames.cs b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ImportGames/ImportGames.cs new file mode 100644 index 00000000..70f919fc --- /dev/null +++ b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ImportGames/ImportGames.cs @@ -0,0 +1,43 @@ +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common.Services; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Users; +using Gameboard.Api.Structure.MediatR; +using MediatR; + +namespace Gameboard.Api.Features.Games; + +public record ImportGamesCommand(byte[] ImportPackage) : IRequest; + +internal sealed class ImportGamesHandler +( + IActingUserService actingUser, + CoreOptions coreOptions, + IGameImportExportService importExportService, + IStore store, + IValidatorService validator +) : IRequestHandler +{ + private readonly IActingUserService _actingUser = actingUser; + private readonly CoreOptions _coreOptions = coreOptions; + private readonly IGameImportExportService _importExportService = importExportService; + private readonly IStore _store = store; + private readonly IValidatorService _validator = validator; + + public async Task Handle(ImportGamesCommand request, CancellationToken cancellationToken) + { + await _validator + .Auth(c => c.RequirePermissions(PermissionKey.Games_CreateEditDelete)) + .AddValidator(ctx => + { + if (request.ImportPackage.IsEmpty()) + { + ctx.AddValidationException(new MissingRequiredInput(nameof(request.ImportPackage))); + } + }) + .Validate(cancellationToken); + + return await _importExportService.ImportPackage(request.ImportPackage, cancellationToken); + } +} diff --git a/src/Gameboard.Api/Features/Game/ImportExport/Requests/ImportGames/ImportGamesModels.cs b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ImportGames/ImportGamesModels.cs new file mode 100644 index 00000000..029494ed --- /dev/null +++ b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ImportGames/ImportGamesModels.cs @@ -0,0 +1,10 @@ +using Gameboard.Api.Structure; + +namespace Gameboard.Api.Features.Games; + +public record ImportGamesResponse(ImportedGame[] ImportedGames); + +public sealed class InvalidImportPackage : GameboardValidationException +{ + public InvalidImportPackage() : base($"Your import package doesn't appear to be valid. Try extracting it to ensure that it has a manifest with at least one game in it.") { } +} diff --git a/src/Gameboard.Api/Features/Practice/PracticeModels.cs b/src/Gameboard.Api/Features/Practice/PracticeModels.cs index 43b3e25b..c9ed8b99 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeModels.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeModels.cs @@ -23,7 +23,6 @@ public sealed class PracticeSession public sealed class PracticeModeSettingsApiModel { public int? AttemptLimit { get; set; } - public string CertificateHtmlTemplate { get; set; } public string CertificateTemplateId { get; set; } public required int DefaultPracticeSessionLengthMinutes { get; set; } public required string IntroTextMarkdown { get; set; } diff --git a/src/Gameboard.Api/Features/Practice/PracticeService.cs b/src/Gameboard.Api/Features/Practice/PracticeService.cs index 5d7bd7f0..85ba5c5b 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeService.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeService.cs @@ -22,6 +22,7 @@ public interface IPracticeService Task> GetVisibleChallengeTags(CancellationToken cancellationToken); Task> GetVisibleChallengeTags(IEnumerable requestedTags, CancellationToken cancellationToken); IEnumerable UnescapeSuggestedSearches(string input); + Task UpdateSettings(PracticeModeSettingsApiModel settings, string actingUserId, CancellationToken cancellationToken); } public enum CanPlayPracticeChallengeResult @@ -33,12 +34,14 @@ public enum CanPlayPracticeChallengeResult internal partial class PracticeService ( + IGuidService guids, IMapper mapper, INowService now, ISlugService slugService, IStore store ) : IPracticeService { + private readonly IGuidService _guids = guids; private readonly IMapper _mapper = mapper; private readonly INowService _now = now; private readonly ISlugService _slugService = slugService; @@ -115,13 +118,7 @@ public async Task GetSettings(CancellationToken ca // if we don't have any settings, make up some defaults if (settings is null) { - return new PracticeModeSettingsApiModel - { - CertificateHtmlTemplate = null, - DefaultPracticeSessionLengthMinutes = 60, - IntroTextMarkdown = null, - SuggestedSearches = [] - }; + return _mapper.Map(GetDefaultSettings()); } var apiModel = _mapper.Map(settings); @@ -142,6 +139,33 @@ public async Task> GetVisibleChallengeTags(IEnumerable t.ToLower()).Intersect(settings.SuggestedSearches).ToArray(); } + public async Task UpdateSettings(PracticeModeSettingsApiModel update, string actingUserId, CancellationToken cancellationToken) + { + var settings = await _store.FirstOrDefaultAsync(cancellationToken); + if (settings is null) + { + settings.Id = settings.Id.IsEmpty() ? _guids.Generate() : settings.Id; + settings.AttemptLimit = update.AttemptLimit; + settings.CertificateTemplateId = update.CertificateTemplateId; + settings.DefaultPracticeSessionLengthMinutes = update.DefaultPracticeSessionLengthMinutes; + settings.IntroTextMarkdown = update.IntroTextMarkdown; + settings.MaxConcurrentPracticeSessions = update.MaxConcurrentPracticeSessions; + settings.MaxPracticeSessionLengthMinutes = update.MaxPracticeSessionLengthMinutes; + settings.SuggestedSearches = EscapeSuggestedSearches(update.SuggestedSearches); + settings.UpdatedByUserId = actingUserId; + settings.UpdatedOn = _now.Get(); + } + + // force a value for default session length, becaues it's required + if (settings.DefaultPracticeSessionLengthMinutes <= 0) + { + settings.DefaultPracticeSessionLengthMinutes = 60; + } + + await _store.SaveUpdate(settings, cancellationToken); + return settings; + } + private async Task> GetActiveSessionUsers() => await GetActivePracticeSessionsQueryBase() .Select(p => p.UserId) @@ -152,4 +176,11 @@ private async Task> GetActiveSessionUsers() .WithNoTracking() .Where(p => p.SessionEnd > _now.Get()) .Where(p => p.Mode == PlayerMode.Practice); + + private PracticeModeSettings GetDefaultSettings() + => new() + { + DefaultPracticeSessionLengthMinutes = 60, + MaxPracticeSessionLengthMinutes = 240, + }; } diff --git a/src/Gameboard.Api/Features/Practice/UpdatePracticeModeSettings.cs b/src/Gameboard.Api/Features/Practice/UpdatePracticeModeSettings.cs index f4ad408b..b2af1759 100644 --- a/src/Gameboard.Api/Features/Practice/UpdatePracticeModeSettings.cs +++ b/src/Gameboard.Api/Features/Practice/UpdatePracticeModeSettings.cs @@ -87,7 +87,9 @@ public async Task Handle(UpdatePracticeModeSettingsCommand request, Cancellation // force a value for default session length, becaues it's required if (updatedSettings.DefaultPracticeSessionLengthMinutes <= 0) + { updatedSettings.DefaultPracticeSessionLengthMinutes = 60; + } await _store.SaveUpdate(updatedSettings, cancellationToken); } diff --git a/src/Gameboard.Api/Features/Support/AutoTagService.cs b/src/Gameboard.Api/Features/Support/AutoTagService.cs index eb4f29f0..909f1488 100644 --- a/src/Gameboard.Api/Features/Support/AutoTagService.cs +++ b/src/Gameboard.Api/Features/Support/AutoTagService.cs @@ -68,7 +68,6 @@ public async Task> GetAutoTags(Data.Ticket ticket, Cancellat .ToArrayAsync(cancellationToken); var autoTags = await _store.WithNoTracking().ToArrayAsync(); - Console.WriteLine($"DEBURG: {autoTagConfig.Length} matching thingies, but {autoTags.Length} total."); return [.. autoTagConfig.Select(c => c.Tag).OrderBy(t => t)]; diff --git a/src/Gameboard.Api/Features/Support/Requests/UpdateSupportSettings.cs b/src/Gameboard.Api/Features/Support/Requests/UpdateSupportSettings.cs index 8e764055..9854c526 100644 --- a/src/Gameboard.Api/Features/Support/Requests/UpdateSupportSettings.cs +++ b/src/Gameboard.Api/Features/Support/Requests/UpdateSupportSettings.cs @@ -40,7 +40,7 @@ await _validatorService await _store .Create(new SupportSettings { - AutoTags = request.Settings.AutoTags.ToArray(), + AutoTags = [.. request.Settings.AutoTags], SupportPageGreeting = request.Settings.SupportPageGreeting, UpdatedByUserId = _actingUserService.Get().Id, UpdatedOn = _nowService.Get() @@ -48,7 +48,7 @@ await _store } else { - existingSettings.AutoTags = request.Settings.AutoTags.ToArray(); + existingSettings.AutoTags = [.. request.Settings.AutoTags]; existingSettings.SupportPageGreeting = request.Settings.SupportPageGreeting; existingSettings.UpdatedByUserId = _actingUserService.Get().Id; existingSettings.UpdatedOn = _nowService.Get(); diff --git a/src/Gameboard.Api/Gameboard.Api.csproj b/src/Gameboard.Api/Gameboard.Api.csproj index 6a3458df..2daf446b 100644 --- a/src/Gameboard.Api/Gameboard.Api.csproj +++ b/src/Gameboard.Api/Gameboard.Api.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Gameboard.Api/Structure/MimeTypes.cs b/src/Gameboard.Api/Structure/MimeTypes.cs index e32aff1f..db987b0d 100644 --- a/src/Gameboard.Api/Structure/MimeTypes.cs +++ b/src/Gameboard.Api/Structure/MimeTypes.cs @@ -7,5 +7,7 @@ public static class MimeTypes public static string ImagePng { get => "image/png"; } public static string ImageSvg { get => "image/svg+xml"; } public static string ImageWebp { get => "image/webp"; } + public const string OctetStream = "application/octet-stream"; public static string TextCsv { get => "text/csv"; } + public const string Zip = "application/zip"; } From 64ab1cb243b11cb7d666620de065f15cd8b77522 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 14 Jan 2025 14:38:00 -0500 Subject: [PATCH 12/26] Fix 'top performance' for #594 --- .../Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs index 0a47b954..4f10f627 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs @@ -88,6 +88,7 @@ await _validator .Where(s => s.GameId == request.GameId) .Where(s => s.Rank > 0) .OrderByDescending(s => s.ScoreOverall) + .ThenBy(s => s.CumulativeTimeMs) .FirstOrDefaultAsync(cancellationToken); var topScoringTeamName = string.Empty; From 5b5a600bf2c9c20f1d1fac1e97f813ee9df98db4 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 14 Jan 2025 14:41:10 -0500 Subject: [PATCH 13/26] Add 'completed teams' to game center for #594 --- .../GetGameCenterContext.cs | 10 +++++++- .../GetGameCenterContextModels.cs | 25 ++++++++++--------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs index 4f10f627..43988327 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs @@ -93,7 +93,9 @@ await _validator var topScoringTeamName = string.Empty; if (topScore is not null) + { topScoringTeamName = (await _teamService.ResolveCaptain(topScore.TeamId, cancellationToken)).ApprovedName; + } var playerActivity = await _store .WithNoTracking() @@ -105,7 +107,8 @@ await _validator p.TeamId, p.Mode, IsActive = p.SessionBegin <= nowish && p.SessionEnd >= nowish, - IsStarted = p.SessionBegin != DateTimeOffset.MinValue + IsStarted = p.SessionBegin != DateTimeOffset.MinValue, + IsEnded = p.Mode == PlayerMode.Competition && p.SessionEnd != DateTimeOffset.MinValue && p.SessionEnd < nowish }) .GroupBy(p => p.GameId) .Select(gr => new GameCenterContextStats @@ -133,6 +136,11 @@ await _validator .Select(p => p.TeamId) .Distinct() .Count(), + TeamCountComplete = gr + .Where(p => p.IsEnded) + .Select(p => p.TeamId) + .Distinct() + .Count(), TeamCountCompetitive = gr .Where(p => p.Mode == PlayerMode.Competition) .Select(p => p.TeamId) diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs index de52fb7d..a21d5594 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs @@ -28,16 +28,17 @@ public sealed class GameCenterContext public sealed class GameCenterContextStats { - public int AttemptCountPractice { get; set; } - public int PlayerCountActive { get; set; } - public int PlayerCountCompetitive { get; set; } - public int PlayerCountPractice { get; set; } - public int PlayerCountTotal { get; set; } - public int TeamCountActive { get; set; } - public int TeamCountCompetitive { get; set; } - public int TeamCountPractice { get; set; } - public int TeamCountNotStarted { get; set; } - public int TeamCountTotal { get; set; } - public double? TopScore { get; set; } - public string TopScoreTeamName { get; set; } + public required int AttemptCountPractice { get; set; } + public required int PlayerCountActive { get; set; } + public required int PlayerCountCompetitive { get; set; } + public required int PlayerCountPractice { get; set; } + public required int PlayerCountTotal { get; set; } + public required int TeamCountActive { get; set; } + public required int TeamCountCompetitive { get; set; } + public required int TeamCountComplete { get; set; } + public required int TeamCountPractice { get; set; } + public required int TeamCountNotStarted { get; set; } + public required int TeamCountTotal { get; set; } + public required double? TopScore { get; set; } + public required string TopScoreTeamName { get; set; } } From d80426887b2fca2a0046e03662f741ca2e604a4a Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 14 Jan 2025 15:44:51 -0500 Subject: [PATCH 14/26] Remove old cert stuff. Rename RequirePermissions to Require. Fix tests --- ...ificatesController_GetCertificatesTests.cs | 122 ++++++++++++++++ .../Features/Players/PlayerControllerTests.cs | 116 --------------- .../Certificates/CertificatesServiceTests.cs | 136 ++++++++++++++++++ .../Features/Player/PlayerServiceTests.cs | 74 ---------- .../Data/Extensions/QueryExtensions.cs | 3 - .../GetAppActiveChallenges.cs | 2 +- .../GetAppActiveTeams/GetAppActiveTeams.cs | 2 +- .../Admin/Requests/GetAppOverviewStats.cs | 2 +- .../Requests/GetExternalGameAdminContext.cs | 2 +- .../GetGameCenterContext.cs | 8 +- .../GetGameCenterContextModels.cs | 24 ++-- .../GetGameCenterPractice.cs | 2 +- .../GetPlayersCsvExport.cs | 2 +- .../GetTeamCenterContext.cs | 2 +- .../Admin/Requests/SendAnnouncement.cs | 2 +- .../UpdatePlayerNameChangeRequest.cs | 2 +- .../DeleteCertificateTemplate.cs | 2 +- .../GetCompetitiveCertificates.cs | 2 +- .../Requests/GetPracticeModeCertificates.cs | 2 +- .../GetTemplatePreviewHtml.cs | 2 +- .../ListCertificateTemplates.cs | 2 +- .../UpsertCertificateTemplate.cs | 2 +- .../GetChallengePlayConfig.cs | 2 +- .../GetChallengeProgress.cs | 2 +- .../AddManualBonus/AddManualBonusValidator.cs | 2 +- .../ConfigureGameAutoBonusesValidator.cs | 2 +- .../DeleteGameAutoBonusesConfigValidator.cs | 2 +- .../DeleteManualBonusCommand.cs | 2 +- .../ListManualBonuses/ListManualBonuses.cs | 2 +- .../GetChallengeSpecQuestionPerformance.cs | 2 +- .../CreateFeedbackTemplate.cs | 2 +- .../DeleteFeedbackTemplate.cs | 2 +- .../GetFeedbackSubmission.cs | 2 +- .../ListFeedbackTemplates.cs | 2 +- .../UpdateFeedbackTemplate.cs | 2 +- .../DeleteExternalGameHost.cs | 2 +- .../External/Requests/GetExternalGameHost.cs | 2 +- .../External/Requests/GetExternalGameHosts.cs | 2 +- .../Requests/ExportGame/ExportGame.cs | 2 +- .../Requests/ImportGames/ImportGames.cs | 2 +- .../Game/Requests/DeleteGameCommand.cs | 2 +- .../GetGamePlayState/GetGamePlayState.cs | 2 +- .../Requests/UpdatePlayerReadyStateCommand.cs | 2 +- .../ResourceDeployment/DeployGameResources.cs | 2 +- .../GetGameState/GetGameStateValidator.cs | 2 +- src/Gameboard.Api/Features/Player/Player.cs | 2 - .../Features/Player/PlayerController.cs | 24 ---- .../Features/Player/Services/PlayerService.cs | 100 +------------ .../Practice/Requests/GetPracticeSession.cs | 2 +- .../Practice/UpdatePracticeModeSettings.cs | 2 +- .../Queries/FeedbackReport/FeedbackReport.cs | 2 +- .../FeedbackReport/FeedbackReportExport.cs | 2 +- .../PracticeModeReportPlayerModeSummary.cs | 2 +- .../Features/Reports/ReportsQueryValidator.cs | 2 +- .../Scores/GameScoreQuery/GameScoreQuery.cs | 2 +- .../GetTeamScoreQuery/GetTeamScoreQuery.cs | 2 +- .../Sponsor/Handlers/CreateSponsor.cs | 2 +- .../Sponsor/Handlers/DeleteSponsor.cs | 2 +- .../Sponsor/Handlers/SetSponsorAvatar.cs | 2 +- .../Sponsor/Handlers/UpdateSponsor.cs | 2 +- .../Requests/DeleteSupportSettingsAutoTag.cs | 2 +- .../Requests/GetSupportSettingsAutoTags.cs | 2 +- .../Support/Requests/UpdateSupportSettings.cs | 2 +- .../Requests/UpsertSupportSettingsAutoTag.cs | 2 +- .../Requests/CreateSystemNotification.cs | 2 +- .../Requests/DeleteSystemNotification.cs | 2 +- .../Requests/GetAdminSystemNotifications.cs | 2 +- .../Requests/UpdateSystemNotification.cs | 2 +- .../AddPlayerToTeam/AddPlayerToTeam.cs | 2 +- .../Teams/Requests/AddToTeam/AddToTeam.cs | 2 +- .../AdminEnrollTeamValidator.cs | 2 +- .../AdminExtendTeamSession.cs | 5 +- .../Requests/AdvanceTeams/AdvanceTeams.cs | 2 +- .../GetTeamEventHorizon.cs | 56 ++++---- .../GetTeamEventHorizonModels.cs | 1 + .../Features/Teams/Requests/GetTeams.cs | 2 +- .../GetTeamsMailMetadata.cs | 2 +- .../Requests/RemoveFromTeam/RemoveFromTeam.cs | 2 +- .../ResetTeamSesssion/ResetTeamSession.cs | 1 - .../ResetTeamSessionCommandValidator.cs | 2 +- .../Requests/UpdateTeamReadyStateCommand.cs | 2 +- .../Permissions/UserRolePermissionsModels.cs | 5 + .../User/Requests/GetUserActiveChallenges.cs | 2 +- .../RequestNameChange/RequestNameChange.cs | 2 +- .../Features/User/Requests/TryCreateUsers.cs | 2 +- .../UserRolePermissionOverview.cs | 2 +- .../UserRolePermissionsValidator.cs | 53 ++++--- 87 files changed, 417 insertions(+), 455 deletions(-) create mode 100644 src/Gameboard.Api.Tests.Integration/Tests/Features/Certificates/CertificatesController_GetCertificatesTests.cs delete mode 100644 src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Certificates/CertificatesController_GetCertificatesTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Certificates/CertificatesController_GetCertificatesTests.cs new file mode 100644 index 00000000..61f6ec2c --- /dev/null +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Certificates/CertificatesController_GetCertificatesTests.cs @@ -0,0 +1,122 @@ +namespace Gameboard.Api.Tests.Integration; + +public class CertificatesControllerTests_GetCertificatesTests(GameboardTestContext testContext) : IClassFixture +{ + private readonly GameboardTestContext _testContext = testContext; + + // [Theory, GbIntegrationAutoData] + // public async Task GetCertificates_WhenScoreConstrained_ReturnsExpectedCount + // ( + // int score, + // string scoringUserId, + // string scoringPlayerId, + // string nonScoringUserId, + // string nonScoringPlayerId, + // IFixture fixture + // ) + // { + // // given + // var now = DateTimeOffset.UtcNow; + + // await _testContext.WithDataState(state => + // { + // state.Add(fixture, g => + // { + // g.GameEnd = now - TimeSpan.FromDays(1); + // g.CertificateTemplateLegacy = "This is a template with a {{player_count}}."; + // g.Players = + // [ + // // i almost broke my brain trying to get GbIntegrationAutoData to work with + // // inline autodata, so I'm just doing two checks here + // state.Build(fixture, p => + // { + // p.Id = scoringPlayerId; + // p.User = state.Build(fixture, u => u.Id = scoringUserId); + // p.UserId = scoringUserId; + // p.SessionEnd = now - TimeSpan.FromDays(-2); + // p.TeamId = "teamId"; + // p.Score = score; + // }), + // state.Build(fixture, p => + // { + // p.Id = nonScoringPlayerId; + // p.User = state.Build(fixture, u => u.Id = nonScoringUserId); + // p.UserId = nonScoringUserId; + // p.SessionEnd = now - TimeSpan.FromDays(-2); + // p.TeamId = "teamId"; + // p.Score = score; + // }) + // ]; + // }); + // }); + + // var httpClient = _testContext.CreateHttpClientWithActingUser(u => u.Id = scoringUserId); + + // // when + // var certs = await httpClient + // .GetAsync("/api/certificates") + // .DeserializeResponseAs>(); + + // // then + // certs?.Count().ShouldBe(1); + // certs?.First().Player.Id.ShouldBe(scoringPlayerId); + // } + + // [Theory, GbIntegrationAutoData] + // public async Task GetCertificates_WithTeamsAndNonScorers_ReturnsExpected(string teamId, string userId, IFixture fixture) + // { + // // given + // var now = DateTimeOffset.UtcNow; + // var recentDate = DateTime.UtcNow.AddDays(-1); + + // await _testContext.WithDataState(state => + // { + // state.Add(fixture, g => + // { + // g.CertificateTemplateLegacy = "This is a template with a {{player_count}} and a {{team_count}}."; + // g.GameEnd = now - TimeSpan.FromDays(1); + // g.Players = new List + // { + // // three players with nonzero score (2 on the same team) + // state.Build(fixture, p => + // { + // p.SessionEnd = recentDate; + // p.Score = 20; + // p.User = state.Build(fixture, u => u.Id = userId); + // }), + // state.Build(fixture, p => + // { + // p.SessionEnd = recentDate; + // p.Score = 30; + // p.TeamId = teamId; + // }), + // state.Build(fixture, p => + // { + // p.SessionEnd = recentDate; + // p.Score = 30; + // p.TeamId = teamId; + // }), + // // one player with zero score + // state.Build(fixture, p => + // { + // p.SessionEnd = recentDate; + // p.Score = 0; + // }), + // }; + // }); + // }); + + // var httpClient = _testContext.CreateHttpClientWithActingUser(u => u.Id = userId); + + // // when + // var certsResponse = await httpClient + // .GetAsync("/api/certificates") + // .DeserializeResponseAs>(); + + // // then + // var certs = certsResponse.ToArray(); + // certs.ShouldNotBeNull(); + // certs.Count().ShouldBe(1); + // certs.First().Html.ShouldBe("This is a template with a 3 and a 2."); + // } +} diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerTests.cs index f739d2bb..23b86891 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerTests.cs @@ -50,120 +50,4 @@ await _testContext // assert updatedPlayer?.NameStatus.ShouldBe(AppConstants.NameStatusNotUnique); } - - [Theory, GbIntegrationAutoData] - public async Task GetCertificates_WhenScoreConstrained_ReturnsExpectedCount - ( - int score, - string scoringUserId, - string scoringPlayerId, - string nonScoringUserId, - string nonScoringPlayerId, - IFixture fixture - ) - { - // given - var now = DateTimeOffset.UtcNow; - - await _testContext.WithDataState(state => - { - state.Add(fixture, g => - { - g.GameEnd = now - TimeSpan.FromDays(1); - g.CertificateTemplateLegacy = "This is a template with a {{player_count}}."; - g.Players = - [ - // i almost broke my brain trying to get GbIntegrationAutoData to work with - // inline autodata, so I'm just doing two checks here - state.Build(fixture, p => - { - p.Id = scoringPlayerId; - p.User = state.Build(fixture, u => u.Id = scoringUserId); - p.UserId = scoringUserId; - p.SessionEnd = now - TimeSpan.FromDays(-2); - p.TeamId = "teamId"; - p.Score = score; - }), - state.Build(fixture, p => - { - p.Id = nonScoringPlayerId; - p.User = state.Build(fixture, u => u.Id = nonScoringUserId); - p.UserId = nonScoringUserId; - p.SessionEnd = now - TimeSpan.FromDays(-2); - p.TeamId = "teamId"; - p.Score = score; - }) - ]; - }); - }); - - var httpClient = _testContext.CreateHttpClientWithActingUser(u => u.Id = scoringUserId); - - // when - var certs = await httpClient - .GetAsync("/api/certificates") - .DeserializeResponseAs>(); - - // then - certs?.Count().ShouldBe(1); - certs?.First().Player.Id.ShouldBe(scoringPlayerId); - } - - [Theory, GbIntegrationAutoData] - public async Task GetCertificates_WithTeamsAndNonScorers_ReturnsExpected(string teamId, string userId, IFixture fixture) - { - // given - var now = DateTimeOffset.UtcNow; - var recentDate = DateTime.UtcNow.AddDays(-1); - - await _testContext.WithDataState(state => - { - state.Add(fixture, g => - { - g.CertificateTemplateLegacy = "This is a template with a {{player_count}} and a {{team_count}}."; - g.GameEnd = now - TimeSpan.FromDays(1); - g.Players = new List - { - // three players with nonzero score (2 on the same team) - state.Build(fixture, p => - { - p.SessionEnd = recentDate; - p.Score = 20; - p.User = state.Build(fixture, u => u.Id = userId); - }), - state.Build(fixture, p => - { - p.SessionEnd = recentDate; - p.Score = 30; - p.TeamId = teamId; - }), - state.Build(fixture, p => - { - p.SessionEnd = recentDate; - p.Score = 30; - p.TeamId = teamId; - }), - // one player with zero score - state.Build(fixture, p => - { - p.SessionEnd = recentDate; - p.Score = 0; - }), - }; - }); - }); - - var httpClient = _testContext.CreateHttpClientWithActingUser(u => u.Id = userId); - - // when - var certsResponse = await httpClient - .GetAsync("/api/certificates") - .DeserializeResponseAs>(); - - // then - var certs = certsResponse.ToArray(); - certs.ShouldNotBeNull(); - certs.Count().ShouldBe(1); - certs.First().Html.ShouldBe("This is a template with a 3 and a 2."); - } } diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Certificates/CertificatesServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Certificates/CertificatesServiceTests.cs index 831f0c66..4c14c114 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Certificates/CertificatesServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Certificates/CertificatesServiceTests.cs @@ -72,4 +72,140 @@ public void GetDurationDescription_WithHms_HidesSeconds() // then result.ToLower().ShouldBe("1 hour and 3 minutes"); } + + [Theory, GameboardAutoData] + public async Task MakeCertificates_WhenScoreZero_ReturnsEmptyArray + ( + string certificateTemplateId, + string gameId, + string teamId, + IFixture fixture + ) + { + // when a team scores 0 + var userId = fixture.Create(); + var fakeStore = A.Fake(); + var fakePlayers = new Data.Player[] + { + new() + { + PartialCount = 0, + Game = new Data.Game + { + Id = gameId, + CertificateTemplateId = certificateTemplateId, + GameEnd = DateTimeOffset.UtcNow - TimeSpan.FromDays(1) + }, + Score = 1, + SessionEnd = DateTimeOffset.UtcNow - TimeSpan.FromDays(2), + TeamId = teamId, + UserId = userId, + User = new Data.User { Id = userId } + } + }.ToList().BuildMock(); + + var fakeScores = new DenormalizedTeamScore[] + { + new() + { + GameId = gameId, + CumulativeTimeMs = 100, + ScoreAdvanced = 0, + ScoreAutoBonus = 0, + ScoreChallenge = 0, + ScoreManualBonus = 0, + SolveCountNone = 0, + SolveCountPartial = 1, + SolveCountComplete = 0, + TeamId = teamId, + TeamName = null, + Rank = 1, + ScoreOverall = 0 + } + }.BuildMock(); + + A.CallTo(() => fakeStore.WithNoTracking()).Returns(fakePlayers); + A.CallTo(() => fakeStore.WithNoTracking()).Returns(fakeScores); + + var sut = new CertificatesService + ( + A.Fake(), + new NowService(), + fakeStore, + A.Fake() + ); + // act + var result = await sut.GetCompetitiveCertificates(userId, CancellationToken.None); + + // assert + result.Count().ShouldBe(0); + } + + [Theory, GameboardAutoData] + public async Task MakeCertificates_WhenScore1_ReturnsOneCertificate + ( + string certificateTemplateId, + string gameId, + string teamId, + IFixture fixture + ) + { + // arrange + var userId = fixture.Create(); + var fakeStore = A.Fake(); + var fakePlayers = new Data.Player[] + { + new() + { + PartialCount = 0, + Game = new Data.Game + { + Id = gameId, + CertificateTemplateId = certificateTemplateId, + GameEnd = DateTimeOffset.UtcNow - TimeSpan.FromDays(1) + }, + Score = 1, + SessionEnd = DateTimeOffset.UtcNow - TimeSpan.FromDays(2), + TeamId = teamId, + UserId = userId, + User = new Data.User { Id = userId } + } + }.ToList().BuildMock(); + + var fakeScores = new DenormalizedTeamScore[] + { + new() + { + GameId = gameId, + CumulativeTimeMs = 100, + ScoreAdvanced = 0, + ScoreAutoBonus = 0, + ScoreChallenge = 1, + ScoreManualBonus = 0, + SolveCountNone = 0, + SolveCountPartial = 1, + SolveCountComplete = 0, + TeamId = teamId, + TeamName = null, + Rank = 1, + ScoreOverall = 1 + } + }.BuildMock(); + + A.CallTo(() => fakeStore.WithNoTracking()).Returns(fakePlayers); + A.CallTo(() => fakeStore.WithNoTracking()).Returns(fakeScores); + + var sut = new CertificatesService + ( + A.Fake(), + new NowService(), + fakeStore, + A.Fake() + ); + // act + var result = await sut.GetCompetitiveCertificates(userId, CancellationToken.None); + + // assert + result.Count().ShouldBe(1); + } } diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs deleted file mode 100644 index 6d998579..00000000 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Gameboard.Api.Data; -using Gameboard.Api.Services; - -namespace Gameboard.Api.Tests.Unit; - -public class PlayerServiceTests -{ - [Theory, GameboardAutoData] - public async Task MakeCertificates_WhenScoreZero_ReturnsEmptyArray(IFixture fixture) - { - // arrange - var userId = fixture.Create(); - var fakeStore = A.Fake(); - var fakePlayers = new Data.Player[] - { - new Data.Player - { - PartialCount = 1, - Game = new Data.Game - { - CertificateTemplateLegacy = fixture.Create(), - GameEnd = DateTimeOffset.UtcNow - TimeSpan.FromDays(1) - }, - Score = 0, - SessionEnd = DateTimeOffset.UtcNow - TimeSpan.FromDays(2), - User = new Api.Data.User { Id = userId } - } - }.ToList().BuildMock(); - - A.CallTo(() => fakeStore.WithNoTracking()).Returns(fakePlayers); - - var sut = PlayerServiceTestHelpers.GetTestableSut(store: fakeStore); - - // act - var result = await sut.MakeCertificates(userId); - - // assert - result.ShouldBe([]); - } - - [Theory, GameboardAutoData] - public async Task MakeCertificates_WhenScore1_ReturnsOneCertificate(IFixture fixture) - { - // arrange - var userId = fixture.Create(); - var fakeStore = A.Fake(); - var fakePlayers = new Data.Player[] - { - new() - { - PartialCount = 0, - Game = new Data.Game - { - CertificateTemplateLegacy = fixture.Create(), - GameEnd = DateTimeOffset.UtcNow - TimeSpan.FromDays(1) - }, - Score = 1, - SessionEnd = DateTimeOffset.UtcNow - TimeSpan.FromDays(2), - UserId = userId, - User = new Data.User { Id = userId } - } - }.ToList().BuildMock(); - - A.CallTo(() => fakeStore.WithNoTracking()).Returns(fakePlayers); - - var sut = PlayerServiceTestHelpers.GetTestableSut(store: fakeStore); - - // act - var result = await sut.MakeCertificates(userId); - - // assert - result.Count().ShouldBe(1); - } -} diff --git a/src/Gameboard.Api/Data/Extensions/QueryExtensions.cs b/src/Gameboard.Api/Data/Extensions/QueryExtensions.cs index 085362ac..51d11ebf 100644 --- a/src/Gameboard.Api/Data/Extensions/QueryExtensions.cs +++ b/src/Gameboard.Api/Data/Extensions/QueryExtensions.cs @@ -53,7 +53,4 @@ private static IQueryable WhereDate(IQueryable query, Expression WhereIsFullySolved(this IQueryable query) => query.Where(c => c.Score >= c.Points); - - public static IQueryable WhereIsScoringPlayer(this IQueryable query) - => query.Where(p => p.Score > 0); } diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetAppActiveChallenges/GetAppActiveChallenges.cs b/src/Gameboard.Api/Features/Admin/Requests/GetAppActiveChallenges/GetAppActiveChallenges.cs index 5557e40f..5181e2cc 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetAppActiveChallenges/GetAppActiveChallenges.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetAppActiveChallenges/GetAppActiveChallenges.cs @@ -32,7 +32,7 @@ IValidatorService validatorService public async Task Handle(GetAppActiveChallengesQuery request, CancellationToken cancellationToken) { await _validatorService - .Auth(config => config.RequirePermissions(PermissionKey.Admin_View)) + .Auth(config => config.Require(PermissionKey.Admin_View)) .Validate(cancellationToken); var challenges = await _appOverviewService diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetAppActiveTeams/GetAppActiveTeams.cs b/src/Gameboard.Api/Features/Admin/Requests/GetAppActiveTeams/GetAppActiveTeams.cs index 4092c1ea..2329c611 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetAppActiveTeams/GetAppActiveTeams.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetAppActiveTeams/GetAppActiveTeams.cs @@ -35,7 +35,7 @@ public async Task Handle(GetAppActiveTeamsQuery reque { // authorize await _validatorService - .Auth(config => config.RequirePermissions(PermissionKey.Admin_View)) + .Auth(config => config.Require(PermissionKey.Admin_View)) .Validate(cancellationToken); // pull active teams/games diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetAppOverviewStats.cs b/src/Gameboard.Api/Features/Admin/Requests/GetAppOverviewStats.cs index adef1e74..0a2a3b58 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetAppOverviewStats.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetAppOverviewStats.cs @@ -37,7 +37,7 @@ public async Task Handle(GetAppOverviewStatsQuery r { // authorize await _validatorService - .Auth(config => config.RequirePermissions(PermissionKey.Admin_View)) + .Auth(config => config.Require(PermissionKey.Admin_View)) .Validate(cancellationToken); // pull data diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetExternalGameAdminContext.cs b/src/Gameboard.Api/Features/Admin/Requests/GetExternalGameAdminContext.cs index 5c40314e..16701824 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetExternalGameAdminContext.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetExternalGameAdminContext.cs @@ -23,7 +23,7 @@ public async Task Handle(GetExternalGameAdminContextRequest r { // authorize/validate await _validator - .Auth(config => config.RequirePermissions(Users.PermissionKey.Games_CreateEditDelete)) + .Auth(config => config.Require(Users.PermissionKey.Games_CreateEditDelete)) .AddValidator ( _gameExists diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs index 43988327..279f2fbb 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs @@ -36,7 +36,7 @@ IValidatorService validator public async Task Handle(GetGameCenterContextQuery request, CancellationToken cancellationToken) { await _validator - .Auth(config => config.RequirePermissions(PermissionKey.Admin_View)) + .Auth(config => config.Require(PermissionKey.Admin_View)) .AddValidator(_gameExists.UseProperty(r => r.GameId)) .Validate(request, cancellationToken); @@ -182,7 +182,11 @@ await _validator IsPublished = gameData.IsPublished, IsRegistrationActive = gameData.IsRegistrationActive, IsTeamGame = gameData.IsTeamGame, - Stats = playerActivity ?? new(), + Stats = playerActivity ?? new() + { + AttemptCountPractice = gameData.IsPracticeMode ? 0 : null, + TopScore = null + }, // aggregates ChallengeCount = challengeData?.ChallengeCount ?? 0, diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs index a21d5594..53ea9621 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContextModels.cs @@ -28,17 +28,17 @@ public sealed class GameCenterContext public sealed class GameCenterContextStats { - public required int AttemptCountPractice { get; set; } - public required int PlayerCountActive { get; set; } - public required int PlayerCountCompetitive { get; set; } - public required int PlayerCountPractice { get; set; } - public required int PlayerCountTotal { get; set; } - public required int TeamCountActive { get; set; } - public required int TeamCountCompetitive { get; set; } - public required int TeamCountComplete { get; set; } - public required int TeamCountPractice { get; set; } - public required int TeamCountNotStarted { get; set; } - public required int TeamCountTotal { get; set; } + public required int? AttemptCountPractice { get; set; } + public int PlayerCountActive { get; set; } + public int PlayerCountCompetitive { get; set; } + public int PlayerCountPractice { get; set; } + public int PlayerCountTotal { get; set; } + public int TeamCountActive { get; set; } + public int TeamCountCompetitive { get; set; } + public int TeamCountComplete { get; set; } + public int TeamCountPractice { get; set; } + public int TeamCountNotStarted { get; set; } + public int TeamCountTotal { get; set; } public required double? TopScore { get; set; } - public required string TopScoreTeamName { get; set; } + public string TopScoreTeamName { get; set; } } diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPractice.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPractice.cs index f70284ec..c5532794 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPractice.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPractice.cs @@ -45,7 +45,7 @@ public async Task Handle(GetGameCenterPracticeContext { // auth/validate _validatorService - .Auth(c => c.RequirePermissions(PermissionKey.Admin_View)) + .Auth(c => c.Require(PermissionKey.Admin_View)) .AddValidator(_gameExists.UseProperty(r => r.GameId)); await _validatorService.Validate(request, cancellationToken); diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetPlayersCsvExport/GetPlayersCsvExport.cs b/src/Gameboard.Api/Features/Admin/Requests/GetPlayersCsvExport/GetPlayersCsvExport.cs index ee9000a2..ecaca432 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetPlayersCsvExport/GetPlayersCsvExport.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetPlayersCsvExport/GetPlayersCsvExport.cs @@ -29,7 +29,7 @@ public async Task Handle(GetPlayersCsvExportQuery r { // authorize/validate _validatorService - .Auth(c => c.RequirePermissions(Users.PermissionKey.Admin_View)) + .Auth(c => c.Require(Users.PermissionKey.Admin_View)) .AddValidator(_gameExists.UseProperty(r => r.GameId)); var teamIds = request.TeamIds?.Where(t => t.IsNotEmpty()).Distinct().ToArray(); diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetTeamCenterContext/GetTeamCenterContext.cs b/src/Gameboard.Api/Features/Admin/Requests/GetTeamCenterContext/GetTeamCenterContext.cs index 482b3ce1..b45a0e15 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetTeamCenterContext/GetTeamCenterContext.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetTeamCenterContext/GetTeamCenterContext.cs @@ -32,7 +32,7 @@ await _validatorService .Auth(config => { config - .RequirePermissions(PermissionKey.Admin_View) + .Require(PermissionKey.Admin_View) .Unless ( () => _store diff --git a/src/Gameboard.Api/Features/Admin/Requests/SendAnnouncement.cs b/src/Gameboard.Api/Features/Admin/Requests/SendAnnouncement.cs index f489eabb..51fcdfc4 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/SendAnnouncement.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/SendAnnouncement.cs @@ -23,7 +23,7 @@ public async Task Handle(SendAnnouncementCommand request, CancellationToken canc { // auth/validate await _validator - .Auth(config => config.RequirePermissions(PermissionKey.Teams_SendAnnouncements)) + .Auth(config => config.Require(PermissionKey.Teams_SendAnnouncements)) .AddValidator((req, ctx) => { if (req.ContentMarkdown.IsEmpty()) diff --git a/src/Gameboard.Api/Features/Admin/Requests/UpdatePlayerNameChangeRequest/UpdatePlayerNameChangeRequest.cs b/src/Gameboard.Api/Features/Admin/Requests/UpdatePlayerNameChangeRequest/UpdatePlayerNameChangeRequest.cs index f871716d..f041e4d3 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/UpdatePlayerNameChangeRequest/UpdatePlayerNameChangeRequest.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/UpdatePlayerNameChangeRequest/UpdatePlayerNameChangeRequest.cs @@ -26,7 +26,7 @@ ValidatorService validatorService public async Task Handle(UpdatePlayerNameChangeRequestCommand request, CancellationToken cancellationToken) { await _validatorService - .Auth(config => config.RequirePermissions(PermissionKey.Teams_ApproveNameChanges)) + .Auth(config => config.Require(PermissionKey.Teams_ApproveNameChanges)) .AddValidator(_playerExists.UseProperty(r => r.PlayerId)) .AddValidator((req, ctx) => Task.FromResult(req.Args.ApprovedName.IsNotEmpty() && req.Args.ApprovedName.Length > 2)) .Validate(request, cancellationToken); diff --git a/src/Gameboard.Api/Features/Certificates/Requests/DeleteCertificateTemplate/DeleteCertificateTemplate.cs b/src/Gameboard.Api/Features/Certificates/Requests/DeleteCertificateTemplate/DeleteCertificateTemplate.cs index 82408047..82180006 100644 --- a/src/Gameboard.Api/Features/Certificates/Requests/DeleteCertificateTemplate/DeleteCertificateTemplate.cs +++ b/src/Gameboard.Api/Features/Certificates/Requests/DeleteCertificateTemplate/DeleteCertificateTemplate.cs @@ -23,7 +23,7 @@ IValidatorService validator public async Task Handle(DeleteCertificateTemplateCommand request, CancellationToken cancellationToken) { await _validator - .Auth(c => c.RequirePermissions(PermissionKey.Games_CreateEditDelete)) + .Auth(c => c.Require(PermissionKey.Games_CreateEditDelete)) .AddEntityExistsValidator(request.TemplateId) .Validate(cancellationToken); diff --git a/src/Gameboard.Api/Features/Certificates/Requests/GetCompetitiveCertificates/GetCompetitiveCertificates.cs b/src/Gameboard.Api/Features/Certificates/Requests/GetCompetitiveCertificates/GetCompetitiveCertificates.cs index 726ae396..3da7952a 100644 --- a/src/Gameboard.Api/Features/Certificates/Requests/GetCompetitiveCertificates/GetCompetitiveCertificates.cs +++ b/src/Gameboard.Api/Features/Certificates/Requests/GetCompetitiveCertificates/GetCompetitiveCertificates.cs @@ -28,7 +28,7 @@ await _validator ( c => c .RequireAuthentication() - .RequirePermissions(PermissionKey.Admin_View) + .Require(PermissionKey.Admin_View) .UnlessUserIdIn(request.OwnerUserId) ) .Validate(cancellationToken); diff --git a/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificates.cs b/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificates.cs index fdd195ab..bf918597 100644 --- a/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificates.cs +++ b/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificates.cs @@ -27,7 +27,7 @@ await _validatorService .Auth ( a => a - .RequirePermissions(Users.PermissionKey.Admin_View) + .Require(Users.PermissionKey.Admin_View) .UnlessUserIdIn(request.CertificateOwnerUserId) ) .AddValidator(_userExists.UseProperty(r => r.CertificateOwnerUserId)) diff --git a/src/Gameboard.Api/Features/Certificates/Requests/GetTemplatePreviewHtml/GetTemplatePreviewHtml.cs b/src/Gameboard.Api/Features/Certificates/Requests/GetTemplatePreviewHtml/GetTemplatePreviewHtml.cs index 38e14150..1edaf8ff 100644 --- a/src/Gameboard.Api/Features/Certificates/Requests/GetTemplatePreviewHtml/GetTemplatePreviewHtml.cs +++ b/src/Gameboard.Api/Features/Certificates/Requests/GetTemplatePreviewHtml/GetTemplatePreviewHtml.cs @@ -21,7 +21,7 @@ IValidatorService validator public async Task Handle(GetCertificateTemplatePreviewHtml request, CancellationToken cancellationToken) { await _validator - .Auth(c => c.RequirePermissions(PermissionKey.Admin_View)) + .Auth(c => c.Require(PermissionKey.Admin_View)) .AddEntityExistsValidator(request.TemplateId) .Validate(cancellationToken); diff --git a/src/Gameboard.Api/Features/Certificates/Requests/ListCertificateTemplates/ListCertificateTemplates.cs b/src/Gameboard.Api/Features/Certificates/Requests/ListCertificateTemplates/ListCertificateTemplates.cs index 8fbd1295..ffeaee8f 100644 --- a/src/Gameboard.Api/Features/Certificates/Requests/ListCertificateTemplates/ListCertificateTemplates.cs +++ b/src/Gameboard.Api/Features/Certificates/Requests/ListCertificateTemplates/ListCertificateTemplates.cs @@ -22,7 +22,7 @@ IValidatorService validator public async Task> Handle(ListCertificateTemplatesQuery request, CancellationToken cancellationToken) { await _validator - .Auth(c => c.RequirePermissions(PermissionKey.Games_CreateEditDelete)) + .Auth(c => c.Require(PermissionKey.Games_CreateEditDelete)) .Validate(cancellationToken); return await _certificatesService diff --git a/src/Gameboard.Api/Features/Certificates/Requests/UpsertCertificateTemplate/UpsertCertificateTemplate.cs b/src/Gameboard.Api/Features/Certificates/Requests/UpsertCertificateTemplate/UpsertCertificateTemplate.cs index 95899f56..bc2e96b7 100644 --- a/src/Gameboard.Api/Features/Certificates/Requests/UpsertCertificateTemplate/UpsertCertificateTemplate.cs +++ b/src/Gameboard.Api/Features/Certificates/Requests/UpsertCertificateTemplate/UpsertCertificateTemplate.cs @@ -27,7 +27,7 @@ IValidatorService validatorService public async Task Handle(UpsertCertificateTemplateCommand request, CancellationToken cancellationToken) { _validator - .Auth(c => c.RequirePermissions(Users.PermissionKey.Games_CreateEditDelete)) + .Auth(c => c.Require(Users.PermissionKey.Games_CreateEditDelete)) .AddValidator(ctx => { if (request.Args.Name.IsEmpty()) diff --git a/src/Gameboard.Api/Features/Challenge/Requests/GetChallengePlayConfig/GetChallengePlayConfig.cs b/src/Gameboard.Api/Features/Challenge/Requests/GetChallengePlayConfig/GetChallengePlayConfig.cs index 538c1071..920bab07 100644 --- a/src/Gameboard.Api/Features/Challenge/Requests/GetChallengePlayConfig/GetChallengePlayConfig.cs +++ b/src/Gameboard.Api/Features/Challenge/Requests/GetChallengePlayConfig/GetChallengePlayConfig.cs @@ -40,7 +40,7 @@ await _validatorService .Auth ( config => config - .RequirePermissions(Users.PermissionKey.Teams_Observe) + .Require(Users.PermissionKey.Teams_Observe) .Unless(async () => { var challengeTeamId = await _store diff --git a/src/Gameboard.Api/Features/Challenge/Requests/GetChallengeProgress/GetChallengeProgress.cs b/src/Gameboard.Api/Features/Challenge/Requests/GetChallengeProgress/GetChallengeProgress.cs index 12935fb9..6de2e853 100644 --- a/src/Gameboard.Api/Features/Challenge/Requests/GetChallengeProgress/GetChallengeProgress.cs +++ b/src/Gameboard.Api/Features/Challenge/Requests/GetChallengeProgress/GetChallengeProgress.cs @@ -37,7 +37,7 @@ await _validatorService .Auth ( c => c - .RequirePermissions(Users.PermissionKey.Teams_Observe) + .Require(Users.PermissionKey.Teams_Observe) .Unless(() => _challengeService.UserIsPlayingChallenge(request.ChallengeId, _actingUserService.Get()?.Id)) ) .AddValidator(_challengeExists.UseValue(request.ChallengeId)) diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusValidator.cs b/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusValidator.cs index 8481513e..2392631f 100644 --- a/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusValidator.cs +++ b/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusValidator.cs @@ -27,7 +27,7 @@ IValidatorService validatorService public async Task Validate(AddManualBonusCommand request, CancellationToken cancellationToken) { _validatorService - .Auth(c => c.RequirePermissions(PermissionKey.Scores_AwardManualBonuses)) + .Auth(c => c.Require(PermissionKey.Scores_AwardManualBonuses)) .AddValidator((req, context) => { if ((req.ChallengeId.IsEmpty() && req.TeamId.IsEmpty()) || (req.ChallengeId.IsNotEmpty() && req.TeamId.IsNotEmpty())) diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/ConfigureGameAutoBonuses/ConfigureGameAutoBonusesValidator.cs b/src/Gameboard.Api/Features/ChallengeBonuses/ConfigureGameAutoBonuses/ConfigureGameAutoBonusesValidator.cs index 7acd48ea..b70630ae 100644 --- a/src/Gameboard.Api/Features/ChallengeBonuses/ConfigureGameAutoBonuses/ConfigureGameAutoBonusesValidator.cs +++ b/src/Gameboard.Api/Features/ChallengeBonuses/ConfigureGameAutoBonuses/ConfigureGameAutoBonusesValidator.cs @@ -25,7 +25,7 @@ IValidatorService validatorService public async Task Validate(ConfigureGameAutoBonusesCommand request, CancellationToken cancellationToken) { _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Games_CreateEditDelete)) + .Auth(a => a.Require(PermissionKey.Games_CreateEditDelete)) .AddValidator(_gameExists.UseProperty(r => r.Parameters.GameId)); // we're going to bulldoze all existing configuration for now to make this simpler, so we need to diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/DeleteGameAutoBonusesConfig/DeleteGameAutoBonusesConfigValidator.cs b/src/Gameboard.Api/Features/ChallengeBonuses/DeleteGameAutoBonusesConfig/DeleteGameAutoBonusesConfigValidator.cs index 5eb49b69..fca0319e 100644 --- a/src/Gameboard.Api/Features/ChallengeBonuses/DeleteGameAutoBonusesConfig/DeleteGameAutoBonusesConfigValidator.cs +++ b/src/Gameboard.Api/Features/ChallengeBonuses/DeleteGameAutoBonusesConfig/DeleteGameAutoBonusesConfigValidator.cs @@ -30,7 +30,7 @@ IValidatorService validatorService public async Task Validate(DeleteGameAutoBonusesConfigCommand request, CancellationToken cancellationToken) { await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Games_CreateEditDelete)) + .Auth(a => a.Require(PermissionKey.Games_CreateEditDelete)) .AddValidator(_gameExists.UseProperty(r => r.GameId)) .AddValidator ( diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/DeleteManualBonus/DeleteManualBonusCommand.cs b/src/Gameboard.Api/Features/ChallengeBonuses/DeleteManualBonus/DeleteManualBonusCommand.cs index 44876f86..6d5ba5e7 100644 --- a/src/Gameboard.Api/Features/ChallengeBonuses/DeleteManualBonus/DeleteManualBonusCommand.cs +++ b/src/Gameboard.Api/Features/ChallengeBonuses/DeleteManualBonus/DeleteManualBonusCommand.cs @@ -30,7 +30,7 @@ public async Task Handle(DeleteManualBonusCommand request, CancellationToken can { // authorize and validate await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Scores_AwardManualBonuses)) + .Auth(a => a.Require(PermissionKey.Scores_AwardManualBonuses)) .AddValidator(_bonusExists.UseProperty(r => r.ManualBonusId)) .Validate(request, cancellationToken); diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/ListManualBonuses/ListManualBonuses.cs b/src/Gameboard.Api/Features/ChallengeBonuses/ListManualBonuses/ListManualBonuses.cs index 3f2a95cd..805e6389 100644 --- a/src/Gameboard.Api/Features/ChallengeBonuses/ListManualBonuses/ListManualBonuses.cs +++ b/src/Gameboard.Api/Features/ChallengeBonuses/ListManualBonuses/ListManualBonuses.cs @@ -28,7 +28,7 @@ internal class ListManualChallengeBonusesHandler( public async Task> Handle(ListManualChallengeBonusesQuery request, CancellationToken cancellationToken) { await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Scores_AwardManualBonuses)) + .Auth(a => a.Require(PermissionKey.Scores_AwardManualBonuses)) .AddValidator(_challengeExists.UseProperty(r => r.ChallengeId)) .Validate(request, cancellationToken); diff --git a/src/Gameboard.Api/Features/ChallengeSpec/Requests/GetChallengeSpecQuestionPerformance.cs b/src/Gameboard.Api/Features/ChallengeSpec/Requests/GetChallengeSpecQuestionPerformance.cs index 5c8c7a0d..34870eac 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/Requests/GetChallengeSpecQuestionPerformance.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/Requests/GetChallengeSpecQuestionPerformance.cs @@ -29,7 +29,7 @@ public async Task Handle(GetChallenge { // auth/validate await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Reports_View)) + .Auth(a => a.Require(PermissionKey.Reports_View)) .AddValidator(_specExists.UseProperty(r => r.ChallengeSpecId)) .Validate(request, cancellationToken); diff --git a/src/Gameboard.Api/Features/Feedback/Requests/CreateFeedbackTemplate/CreateFeedbackTemplate.cs b/src/Gameboard.Api/Features/Feedback/Requests/CreateFeedbackTemplate/CreateFeedbackTemplate.cs index 4c873ad8..f5b64cc7 100644 --- a/src/Gameboard.Api/Features/Feedback/Requests/CreateFeedbackTemplate/CreateFeedbackTemplate.cs +++ b/src/Gameboard.Api/Features/Feedback/Requests/CreateFeedbackTemplate/CreateFeedbackTemplate.cs @@ -24,7 +24,7 @@ IValidatorService validatorService public async Task Handle(CreateFeedbackTemplateCommand request, CancellationToken cancellationToken) { await _validatorService - .Auth(c => c.RequirePermissions(Users.PermissionKey.Games_CreateEditDelete)) + .Auth(c => c.Require(Users.PermissionKey.Games_CreateEditDelete)) .AddValidator(request.Template.Name.IsEmpty(), new MissingRequiredInput(nameof(request.Template.Name))) .AddValidator(request.Template.Content.IsEmpty(), new MissingRequiredInput(nameof(request.Template.Content))) .AddValidator(async ctx => diff --git a/src/Gameboard.Api/Features/Feedback/Requests/DeleteFeedbackTemplate/DeleteFeedbackTemplate.cs b/src/Gameboard.Api/Features/Feedback/Requests/DeleteFeedbackTemplate/DeleteFeedbackTemplate.cs index caeb0558..094da703 100644 --- a/src/Gameboard.Api/Features/Feedback/Requests/DeleteFeedbackTemplate/DeleteFeedbackTemplate.cs +++ b/src/Gameboard.Api/Features/Feedback/Requests/DeleteFeedbackTemplate/DeleteFeedbackTemplate.cs @@ -25,7 +25,7 @@ IValidatorService validator public async Task Handle(DeleteFeedbackTemplateCommand request, CancellationToken cancellationToken) { await _validator - .Auth(c => c.RequirePermissions(Users.PermissionKey.Games_CreateEditDelete)) + .Auth(c => c.Require(Users.PermissionKey.Games_CreateEditDelete)) .AddValidator(_templateExists.UseValue(request.FeedbackTemplateId)) .Validate(cancellationToken); diff --git a/src/Gameboard.Api/Features/Feedback/Requests/GetFeedbackSubmission/GetFeedbackSubmission.cs b/src/Gameboard.Api/Features/Feedback/Requests/GetFeedbackSubmission/GetFeedbackSubmission.cs index ac2ca7c8..09cdf387 100644 --- a/src/Gameboard.Api/Features/Feedback/Requests/GetFeedbackSubmission/GetFeedbackSubmission.cs +++ b/src/Gameboard.Api/Features/Feedback/Requests/GetFeedbackSubmission/GetFeedbackSubmission.cs @@ -30,7 +30,7 @@ await _validator ( c => c .RequireAuthentication() - .RequirePermissions(Users.PermissionKey.Admin_View) + .Require(Users.PermissionKey.Admin_View) .UnlessUserIdIn(request.Request.UserId) ) .AddValidator(ctx => diff --git a/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs b/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs index 046e8528..c65f5b62 100644 --- a/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs +++ b/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs @@ -19,7 +19,7 @@ internal sealed class ListFeedbackTemplatesHandler(IMapper mapper, IStore store, public async Task Handle(ListFeedbackTemplatesQuery request, CancellationToken cancellationToken) { await _validatorService - .Auth(c => c.RequirePermissions(Users.PermissionKey.Games_CreateEditDelete)) + .Auth(c => c.Require(Users.PermissionKey.Games_CreateEditDelete)) .Validate(cancellationToken); var templates = await _mapper diff --git a/src/Gameboard.Api/Features/Feedback/Requests/UpdateFeedbackTemplate/UpdateFeedbackTemplate.cs b/src/Gameboard.Api/Features/Feedback/Requests/UpdateFeedbackTemplate/UpdateFeedbackTemplate.cs index 327af3e2..882d58cb 100644 --- a/src/Gameboard.Api/Features/Feedback/Requests/UpdateFeedbackTemplate/UpdateFeedbackTemplate.cs +++ b/src/Gameboard.Api/Features/Feedback/Requests/UpdateFeedbackTemplate/UpdateFeedbackTemplate.cs @@ -26,7 +26,7 @@ IValidatorService validator public async Task Handle(UpdateFeedbackTemplateCommand request, CancellationToken cancellationToken) { await _validator - .Auth(c => c.RequirePermissions(Users.PermissionKey.Games_CreateEditDelete)) + .Auth(c => c.Require(Users.PermissionKey.Games_CreateEditDelete)) .AddEntityExistsValidator(request.Request.Id) .AddValidator(request.Request.Name.IsEmpty(), new MissingRequiredInput(nameof(request.Request.Name))) .AddValidator(request.Request.Content.IsEmpty(), new MissingRequiredInput(nameof(request.Request.Content))) diff --git a/src/Gameboard.Api/Features/Game/External/Requests/DeleteExternalGameHost/DeleteExternalGameHost.cs b/src/Gameboard.Api/Features/Game/External/Requests/DeleteExternalGameHost/DeleteExternalGameHost.cs index 31c16833..298ab759 100644 --- a/src/Gameboard.Api/Features/Game/External/Requests/DeleteExternalGameHost/DeleteExternalGameHost.cs +++ b/src/Gameboard.Api/Features/Game/External/Requests/DeleteExternalGameHost/DeleteExternalGameHost.cs @@ -33,7 +33,7 @@ public async Task Handle(DeleteExternalGameHostCommand request, CancellationToke { // auth and validate await _validator - .Auth(c => c.RequirePermissions(Users.PermissionKey.Games_CreateEditDelete)) + .Auth(c => c.Require(Users.PermissionKey.Games_CreateEditDelete)) .AddValidator((req, ctx) => { if (req.DeleteHostId.IsEmpty()) diff --git a/src/Gameboard.Api/Features/Game/External/Requests/GetExternalGameHost.cs b/src/Gameboard.Api/Features/Game/External/Requests/GetExternalGameHost.cs index a4fe13e8..5b496c65 100644 --- a/src/Gameboard.Api/Features/Game/External/Requests/GetExternalGameHost.cs +++ b/src/Gameboard.Api/Features/Game/External/Requests/GetExternalGameHost.cs @@ -20,7 +20,7 @@ IValidatorService validator public async Task Handle(GetExternalGameHostQuery request, CancellationToken cancellationToken) { await _validator - .Auth(config => config.RequirePermissions(PermissionKey.Games_CreateEditDelete)) + .Auth(config => config.Require(PermissionKey.Games_CreateEditDelete)) .Validate(request, cancellationToken); return await diff --git a/src/Gameboard.Api/Features/Game/External/Requests/GetExternalGameHosts.cs b/src/Gameboard.Api/Features/Game/External/Requests/GetExternalGameHosts.cs index ff26ec2b..5edb1c73 100644 --- a/src/Gameboard.Api/Features/Game/External/Requests/GetExternalGameHosts.cs +++ b/src/Gameboard.Api/Features/Game/External/Requests/GetExternalGameHosts.cs @@ -26,7 +26,7 @@ IValidatorService validatorService public async Task Handle(GetExternalGameHostsQuery request, CancellationToken cancellationToken) { await _validatorService - .Auth(config => config.RequirePermissions(Users.PermissionKey.Games_CreateEditDelete)) + .Auth(config => config.Require(Users.PermissionKey.Games_CreateEditDelete)) .Validate(cancellationToken); return new GetExternalGameHostsResponse diff --git a/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGame.cs b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGame.cs index 05578223..1dff5e72 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGame.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGame.cs @@ -31,7 +31,7 @@ public async Task Handle(ExportGameCommand request, Cancellat } await _validator - .Auth(c => c.RequirePermissions(Users.PermissionKey.Games_CreateEditDelete)) + .Auth(c => c.Require(Users.PermissionKey.Games_CreateEditDelete)) .AddValidator(ctx => { if (request.GameIds.IsEmpty()) diff --git a/src/Gameboard.Api/Features/Game/ImportExport/Requests/ImportGames/ImportGames.cs b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ImportGames/ImportGames.cs index 70f919fc..db7b2a2a 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/Requests/ImportGames/ImportGames.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ImportGames/ImportGames.cs @@ -28,7 +28,7 @@ IValidatorService validator public async Task Handle(ImportGamesCommand request, CancellationToken cancellationToken) { await _validator - .Auth(c => c.RequirePermissions(PermissionKey.Games_CreateEditDelete)) + .Auth(c => c.Require(PermissionKey.Games_CreateEditDelete)) .AddValidator(ctx => { if (request.ImportPackage.IsEmpty()) diff --git a/src/Gameboard.Api/Features/Game/Requests/DeleteGameCommand.cs b/src/Gameboard.Api/Features/Game/Requests/DeleteGameCommand.cs index 96766b86..130d64f5 100644 --- a/src/Gameboard.Api/Features/Game/Requests/DeleteGameCommand.cs +++ b/src/Gameboard.Api/Features/Game/Requests/DeleteGameCommand.cs @@ -30,7 +30,7 @@ public async Task Handle(DeleteGameCommand request, CancellationToken cancellati { // auth/validate await _validatorService - .Auth(config => config.RequirePermissions(PermissionKey.Games_CreateEditDelete)) + .Auth(config => config.Require(PermissionKey.Games_CreateEditDelete)) .AddValidator(_gameExists.UseProperty(r => r.GameId)) .AddValidator ( diff --git a/src/Gameboard.Api/Features/Game/Requests/GetGamePlayState/GetGamePlayState.cs b/src/Gameboard.Api/Features/Game/Requests/GetGamePlayState/GetGamePlayState.cs index 6a4d5d39..d43e9929 100644 --- a/src/Gameboard.Api/Features/Game/Requests/GetGamePlayState/GetGamePlayState.cs +++ b/src/Gameboard.Api/Features/Game/Requests/GetGamePlayState/GetGamePlayState.cs @@ -41,7 +41,7 @@ await _validatorService .Auth ( config => config - .RequirePermissions(Users.PermissionKey.Admin_View) + .Require(Users.PermissionKey.Admin_View) .Unless(() => _gameService.IsUserPlaying(gameId, request.ActingUserId)) ) .AddValidator(_userExists.UseProperty(r => r.ActingUserId)) diff --git a/src/Gameboard.Api/Features/Game/Requests/UpdatePlayerReadyStateCommand.cs b/src/Gameboard.Api/Features/Game/Requests/UpdatePlayerReadyStateCommand.cs index 31208f2d..1641ba1f 100644 --- a/src/Gameboard.Api/Features/Game/Requests/UpdatePlayerReadyStateCommand.cs +++ b/src/Gameboard.Api/Features/Game/Requests/UpdatePlayerReadyStateCommand.cs @@ -35,7 +35,7 @@ await _validatorService .Auth ( config => config - .RequirePermissions(PermissionKey.Teams_SetSyncStartReady) + .Require(PermissionKey.Teams_SetSyncStartReady) .UnlessUserIdIn(player?.UserId) ) diff --git a/src/Gameboard.Api/Features/Game/ResourceDeployment/DeployGameResources.cs b/src/Gameboard.Api/Features/Game/ResourceDeployment/DeployGameResources.cs index 713bdb63..e31cdfdb 100644 --- a/src/Gameboard.Api/Features/Game/ResourceDeployment/DeployGameResources.cs +++ b/src/Gameboard.Api/Features/Game/ResourceDeployment/DeployGameResources.cs @@ -40,7 +40,7 @@ public async Task Handle(DeployGameResourcesCommand request, CancellationToken c { // auth and validate await _validator - .Auth(config => config.RequirePermissions(PermissionKey.Teams_DeployGameResources)) + .Auth(config => config.Require(PermissionKey.Teams_DeployGameResources)) .AddValidator(_gameExists.UseIdProperty(r => r.GameId)) .Validate(request, cancellationToken); diff --git a/src/Gameboard.Api/Features/GameEngine/GetGameState/GetGameStateValidator.cs b/src/Gameboard.Api/Features/GameEngine/GetGameState/GetGameStateValidator.cs index be74cfde..5c905eb5 100644 --- a/src/Gameboard.Api/Features/GameEngine/GetGameState/GetGameStateValidator.cs +++ b/src/Gameboard.Api/Features/GameEngine/GetGameState/GetGameStateValidator.cs @@ -26,7 +26,7 @@ await _validatorService .Auth ( a => a - .RequirePermissions(Users.PermissionKey.Admin_View) + .Require(Users.PermissionKey.Admin_View) .Unless ( () => _teamService.IsOnTeam(request.TeamId, _actingUser.Id), diff --git a/src/Gameboard.Api/Features/Player/Player.cs b/src/Gameboard.Api/Features/Player/Player.cs index d4f90cd6..630c5862 100644 --- a/src/Gameboard.Api/Features/Player/Player.cs +++ b/src/Gameboard.Api/Features/Player/Player.cs @@ -125,7 +125,6 @@ public class PlayerDataFilter : SearchFilter public const string FilterActiveOnly = "active"; public const string FilterCompleteOnly = "complete"; - public const string FilterScoredOnly = "scored"; public const string FilterAdvancedOnly = "advanced"; public const string FilterDismissedOnly = "dismissed"; public const string FilterCollapseTeams = "collapse"; @@ -143,7 +142,6 @@ public class PlayerDataFilter : SearchFilter public bool WantsComplete => Filter.Contains(FilterCompleteOnly); public bool WantsAdvanced => Filter.Contains(FilterAdvancedOnly); public bool WantsDismissed => Filter.Contains(FilterDismissedOnly); - public bool WantsScored => Filter.Contains(FilterScoredOnly); public bool WantsGame => gid.NotEmpty(); public bool WantsUser => uid.NotEmpty(); public bool WantsTeam => tid.NotEmpty(); diff --git a/src/Gameboard.Api/Features/Player/PlayerController.cs b/src/Gameboard.Api/Features/Player/PlayerController.cs index 78926f08..e4042c49 100644 --- a/src/Gameboard.Api/Features/Player/PlayerController.cs +++ b/src/Gameboard.Api/Features/Player/PlayerController.cs @@ -248,30 +248,6 @@ await AuthorizeAny await _teamService.PromoteCaptain(teamId, playerId, Actor, cancellationToken); } - /// - /// Get Player Certificate - /// - /// player id - /// - [HttpGet("/api/certificate/{id}")] - [Authorize] - public async Task GetCertificate([FromRoute] string id) - { - await Validate(new Entity { Id = id }); - await Authorize(IsSelf(id)); - - return await PlayerService.MakeCertificate(id); - } - - /// - /// Get List of Player Certificates - /// - /// - [HttpGet("/api/certificates")] - [Authorize] - public Task> GetCertificates() - => PlayerService.MakeCertificates(Actor.Id); - private async Task IsSelf(string playerId) { return await PlayerService.MapId(playerId) == Actor.Id; diff --git a/src/Gameboard.Api/Features/Player/Services/PlayerService.cs b/src/Gameboard.Api/Features/Player/Services/PlayerService.cs index ea286434..8bb93652 100644 --- a/src/Gameboard.Api/Features/Player/Services/PlayerService.cs +++ b/src/Gameboard.Api/Features/Player/Services/PlayerService.cs @@ -315,7 +315,7 @@ public async Task List(PlayerDataFilter model, bool sudo = false) if (model.WantsTeam) q = q.Where(p => p.TeamId == model.tid); - if (model.WantsCollapsed || model.WantsActive || model.WantsScored) + if (model.WantsCollapsed || model.WantsActive) q = q.Where(p => p.Role == PlayerRole.Manager); if (model.WantsActive) @@ -339,9 +339,6 @@ public async Task List(PlayerDataFilter model, bool sudo = false) if (model.WantsDisallowed) q = q.Where(u => !string.IsNullOrEmpty(u.NameStatus)); - if (model.WantsScored) - q = q.WhereIsScoringPlayer(); - if (model.Term.NotEmpty()) { var term = model.Term.ToLower(); @@ -626,101 +623,6 @@ await _store } } - public async Task MakeCertificate(string id) - { - var player = await _store - .WithNoTracking() - .Include(p => p.Game) - .Include(p => p.User) - .ThenInclude(u => u.PublishedCompetitiveCertificates) - .Where(p => p.Challenges.All(c => c.PlayerMode == PlayerMode.Competition)) - .FirstOrDefaultAsync(p => p.Id == id); - - var playerCount = await _store - .WithNoTracking() - .Where(p => p.GameId == player.GameId && p.SessionEnd > DateTimeOffset.MinValue) - .Where(p => p.Challenges.All(c => c.PlayerMode == PlayerMode.Competition)) - .CountAsync(); - - var teamCount = await _store - .WithNoTracking() - .Where(p => p.GameId == player.GameId && - p.SessionEnd > DateTimeOffset.MinValue) - .Where(p => p.Challenges.All(c => c.PlayerMode == PlayerMode.Competition)) - .GroupBy(p => p.TeamId) - .CountAsync(); - - return CertificateFromTemplate(player, playerCount, teamCount); - } - - public async Task> MakeCertificates(string uid) - { - var now = DateTimeOffset.UtcNow; - - var completedSessions = await _store - .WithNoTracking() - .Include(p => p.Game) - .Include(p => p.User) - .ThenInclude(u => u.PublishedCompetitiveCertificates) - .Where - ( - p => p.UserId == uid && - p.SessionEnd > DateTimeOffset.MinValue && - p.Game.GameEnd < now && - p.Game.CertificateTemplateLegacy != null && - p.Game.CertificateTemplateLegacy.Length > 0 - ) - .Where(p => p.Challenges.All(c => c.PlayerMode == PlayerMode.Competition)) - .WhereIsScoringPlayer() - .OrderByDescending(p => p.Game.GameEnd) - .ToArrayAsync(); - - return completedSessions.Select - ( - c => CertificateFromTemplate - ( - c, - _store.WithNoTracking() - .Where(p => p.Game == c.Game && p.SessionEnd > DateTimeOffset.MinValue) - .Where(p => p.Challenges.All(c => c.PlayerMode == PlayerMode.Competition)) - .WhereIsScoringPlayer() - .Count(), - _store.WithNoTracking() - .Where(p => p.Game == c.Game && p.SessionEnd > DateTimeOffset.MinValue) - .WhereIsScoringPlayer() - .Where(p => p.Challenges.All(c => c.PlayerMode == PlayerMode.Competition)) - .GroupBy(p => p.TeamId).Count() - ) - ).ToArray(); - } - - private PlayerCertificate CertificateFromTemplate(Data.Player player, int playerCount, int teamCount) - { - var certificateHTML = player.Game.CertificateTemplateLegacy; - if (certificateHTML.IsEmpty()) - return null; - - certificateHTML = certificateHTML.Replace("{{leaderboard_name}}", player.ApprovedName); - certificateHTML = certificateHTML.Replace("{{user_name}}", player.User.ApprovedName); - certificateHTML = certificateHTML.Replace("{{score}}", player.Score.ToString()); - certificateHTML = certificateHTML.Replace("{{rank}}", player.Rank.ToString()); - certificateHTML = certificateHTML.Replace("{{game_name}}", player.Game.Name); - certificateHTML = certificateHTML.Replace("{{competition}}", player.Game.Competition); - certificateHTML = certificateHTML.Replace("{{season}}", player.Game.Season); - certificateHTML = certificateHTML.Replace("{{track}}", player.Game.Track); - certificateHTML = certificateHTML.Replace("{{date}}", player.SessionEnd.ToString("MMMM dd, yyyy")); - certificateHTML = certificateHTML.Replace("{{player_count}}", playerCount.ToString()); - certificateHTML = certificateHTML.Replace("{{team_count}}", teamCount.ToString()); - - return new PlayerCertificate - { - Game = _mapper.Map(player.Game), - PublishedOn = player.User.PublishedCompetitiveCertificates.FirstOrDefault(c => c.GameId == player.Game.Id)?.PublishedOn, - Player = _mapper.Map(player), - Html = certificateHTML - }; - } - private async Task RegisterPracticeSession(NewPlayer model, Data.User user, CancellationToken cancellationToken) { // load practice settings diff --git a/src/Gameboard.Api/Features/Practice/Requests/GetPracticeSession.cs b/src/Gameboard.Api/Features/Practice/Requests/GetPracticeSession.cs index 0685e354..8d9ab0f9 100644 --- a/src/Gameboard.Api/Features/Practice/Requests/GetPracticeSession.cs +++ b/src/Gameboard.Api/Features/Practice/Requests/GetPracticeSession.cs @@ -37,7 +37,7 @@ await _validatorService .Auth ( config => config - .RequirePermissions(PermissionKey.Teams_Observe) + .Require(PermissionKey.Teams_Observe) .UnlessUserIdIn(request.UserId) ) .Validate(cancellationToken); diff --git a/src/Gameboard.Api/Features/Practice/UpdatePracticeModeSettings.cs b/src/Gameboard.Api/Features/Practice/UpdatePracticeModeSettings.cs index b2af1759..878e28ce 100644 --- a/src/Gameboard.Api/Features/Practice/UpdatePracticeModeSettings.cs +++ b/src/Gameboard.Api/Features/Practice/UpdatePracticeModeSettings.cs @@ -35,7 +35,7 @@ IValidatorService validatorService public Task Validate(UpdatePracticeModeSettingsCommand request, CancellationToken cancellationToken) { return _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Practice_EditSettings)) + .Auth(a => a.Require(PermissionKey.Practice_EditSettings)) .AddValidator((request, context) => { if (request.Settings.MaxConcurrentPracticeSessions.HasValue && request.Settings.MaxConcurrentPracticeSessions < 0) diff --git a/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReport.cs b/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReport.cs index 254a2fef..5a2dcc00 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReport.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReport.cs @@ -27,7 +27,7 @@ IValidatorService validator public async Task> Handle(FeedbackReportQuery request, CancellationToken cancellationToken) { await _validator - .Auth(c => c.RequirePermissions(Users.PermissionKey.Reports_View)) + .Auth(c => c.Require(Users.PermissionKey.Reports_View)) .Validate(cancellationToken); var results = await _feedbackReportService.GetBaseQuery(request.Parameters, cancellationToken); diff --git a/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReportExport.cs b/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReportExport.cs index 62d94280..98d8afad 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReportExport.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReportExport.cs @@ -22,7 +22,7 @@ IValidatorService validatorService public async Task Handle(FeedbackReportExportQuery request, CancellationToken cancellationToken) { await _validator - .Auth(c => c.RequirePermissions(PermissionKey.Reports_View)) + .Auth(c => c.Require(PermissionKey.Reports_View)) .Validate(cancellationToken); var results = await _reportService.GetBaseQuery(request.Parameters, cancellationToken); diff --git a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportPlayerModeSummary.cs b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportPlayerModeSummary.cs index 7d3e3a17..6309e54e 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportPlayerModeSummary.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportPlayerModeSummary.cs @@ -18,7 +18,7 @@ internal class PracticeModeReportPlayerModeSummaryHandler(IPracticeModeReportSer public async Task Handle(PracticeModeReportPlayerModeSummaryQuery request, CancellationToken cancellationToken) { await _validatorService - .Auth(c => c.RequirePermissions(PermissionKey.Reports_View)) + .Auth(c => c.Require(PermissionKey.Reports_View)) .AddValidator(_userExists.UseProperty(r => r.UserId)) .Validate(request, cancellationToken); diff --git a/src/Gameboard.Api/Features/Reports/ReportsQueryValidator.cs b/src/Gameboard.Api/Features/Reports/ReportsQueryValidator.cs index fa01e5ac..2d533ae6 100644 --- a/src/Gameboard.Api/Features/Reports/ReportsQueryValidator.cs +++ b/src/Gameboard.Api/Features/Reports/ReportsQueryValidator.cs @@ -12,7 +12,7 @@ internal class ReportsQueryValidator(IValidatorService validatorSe public async Task Validate(IReportQuery request, CancellationToken cancellationToken) { await _validatorService - .Auth(config => config.RequirePermissions(PermissionKey.Reports_View)) + .Auth(config => config.Require(PermissionKey.Reports_View)) .Validate(request, cancellationToken); } } diff --git a/src/Gameboard.Api/Features/Scores/GameScoreQuery/GameScoreQuery.cs b/src/Gameboard.Api/Features/Scores/GameScoreQuery/GameScoreQuery.cs index e13fad44..31f40da1 100644 --- a/src/Gameboard.Api/Features/Scores/GameScoreQuery/GameScoreQuery.cs +++ b/src/Gameboard.Api/Features/Scores/GameScoreQuery/GameScoreQuery.cs @@ -34,7 +34,7 @@ public async Task Handle(GameScoreQuery request, CancellationToken ca .Auth(config => { config - .RequirePermissions(PermissionKey.Scores_ViewLive) + .Require(PermissionKey.Scores_ViewLive) .Unless(async () => { var now = _nowService.Get(); diff --git a/src/Gameboard.Api/Features/Scores/GetTeamScoreQuery/GetTeamScoreQuery.cs b/src/Gameboard.Api/Features/Scores/GetTeamScoreQuery/GetTeamScoreQuery.cs index e08fdba9..cb7b6019 100644 --- a/src/Gameboard.Api/Features/Scores/GetTeamScoreQuery/GetTeamScoreQuery.cs +++ b/src/Gameboard.Api/Features/Scores/GetTeamScoreQuery/GetTeamScoreQuery.cs @@ -47,7 +47,7 @@ await _validatorService .Auth(config => { config - .RequirePermissions(PermissionKey.Scores_ViewLive) + .Require(PermissionKey.Scores_ViewLive) .Unless(() => _scoreService.CanAccessTeamScoreDetail(request.TeamId, cancellationToken), new CantAccessThisScore("not on requested team")); }) .AddValidator(_teamExists.UseProperty(r => r.TeamId)) diff --git a/src/Gameboard.Api/Features/Sponsor/Handlers/CreateSponsor.cs b/src/Gameboard.Api/Features/Sponsor/Handlers/CreateSponsor.cs index 0b4bd952..da9c48f5 100644 --- a/src/Gameboard.Api/Features/Sponsor/Handlers/CreateSponsor.cs +++ b/src/Gameboard.Api/Features/Sponsor/Handlers/CreateSponsor.cs @@ -25,7 +25,7 @@ public async Task Handle(CreateSponsorCommand request, { // authorize/validate await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Sponsors_CreateEdit)) + .Auth(a => a.Require(PermissionKey.Sponsors_CreateEdit)) .AddValidator((request, context) => { if (request.Model.Name.IsEmpty()) diff --git a/src/Gameboard.Api/Features/Sponsor/Handlers/DeleteSponsor.cs b/src/Gameboard.Api/Features/Sponsor/Handlers/DeleteSponsor.cs index 1b77452e..46bf63fd 100644 --- a/src/Gameboard.Api/Features/Sponsor/Handlers/DeleteSponsor.cs +++ b/src/Gameboard.Api/Features/Sponsor/Handlers/DeleteSponsor.cs @@ -31,7 +31,7 @@ public async Task Handle(DeleteSponsorCommand request, CancellationToken cancell { // authorize/validate await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Sponsors_CreateEdit)) + .Auth(a => a.Require(PermissionKey.Sponsors_CreateEdit)) .AddValidator(_sponsorExists.UseProperty(r => r.SponsorId)) .Validate(request, cancellationToken); diff --git a/src/Gameboard.Api/Features/Sponsor/Handlers/SetSponsorAvatar.cs b/src/Gameboard.Api/Features/Sponsor/Handlers/SetSponsorAvatar.cs index b1de7683..6155785b 100644 --- a/src/Gameboard.Api/Features/Sponsor/Handlers/SetSponsorAvatar.cs +++ b/src/Gameboard.Api/Features/Sponsor/Handlers/SetSponsorAvatar.cs @@ -32,7 +32,7 @@ IValidatorService validatorService public async Task Handle(SetSponsorAvatarCommand request, CancellationToken cancellationToken) { - _validatorService.Auth(c => c.RequirePermissions(Users.PermissionKey.Sponsors_CreateEdit)); + _validatorService.Auth(c => c.Require(Users.PermissionKey.Sponsors_CreateEdit)); _validatorService.AddValidator(_sponsorExists.UseProperty(r => r.SponsorId)); _validatorService.AddValidator ( diff --git a/src/Gameboard.Api/Features/Sponsor/Handlers/UpdateSponsor.cs b/src/Gameboard.Api/Features/Sponsor/Handlers/UpdateSponsor.cs index 4c8d7fad..631fbc9b 100644 --- a/src/Gameboard.Api/Features/Sponsor/Handlers/UpdateSponsor.cs +++ b/src/Gameboard.Api/Features/Sponsor/Handlers/UpdateSponsor.cs @@ -29,7 +29,7 @@ public async Task Handle(UpdateSponsorCommand request, CancellationToke { // validate/authorize await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Sponsors_CreateEdit)) + .Auth(a => a.Require(PermissionKey.Sponsors_CreateEdit)) .AddValidator(_sponsorExists.UseProperty(r => r.Model.Id)) .AddValidator((req, ctx) => { diff --git a/src/Gameboard.Api/Features/Support/Requests/DeleteSupportSettingsAutoTag.cs b/src/Gameboard.Api/Features/Support/Requests/DeleteSupportSettingsAutoTag.cs index d7599ebe..5c0345c7 100644 --- a/src/Gameboard.Api/Features/Support/Requests/DeleteSupportSettingsAutoTag.cs +++ b/src/Gameboard.Api/Features/Support/Requests/DeleteSupportSettingsAutoTag.cs @@ -23,7 +23,7 @@ internal sealed class DeleteSupportSettingsAutoTagHandler( public async Task Handle(DeleteSupportSettingsAutoTagCommand request, CancellationToken cancellationToken) { await _validatorService - .Auth(c => c.RequirePermissions(Users.PermissionKey.Support_EditSettings)) + .Auth(c => c.Require(Users.PermissionKey.Support_EditSettings)) .AddValidator(_tagExists.UseValue(request.Id)) .Validate(cancellationToken); diff --git a/src/Gameboard.Api/Features/Support/Requests/GetSupportSettingsAutoTags.cs b/src/Gameboard.Api/Features/Support/Requests/GetSupportSettingsAutoTags.cs index d3cd0280..decf6ee5 100644 --- a/src/Gameboard.Api/Features/Support/Requests/GetSupportSettingsAutoTags.cs +++ b/src/Gameboard.Api/Features/Support/Requests/GetSupportSettingsAutoTags.cs @@ -20,7 +20,7 @@ internal sealed class GetSupportSettingsAutoTagsHandler(IStore store, IValidator public async Task> Handle(GetSupportSettingsAutoTagsQuery request, CancellationToken cancellationToken) { await _validatorService - .Auth(b => b.RequirePermissions(Users.PermissionKey.Support_EditSettings)) + .Auth(b => b.Require(Users.PermissionKey.Support_EditSettings)) .Validate(cancellationToken); var autoTags = await _store diff --git a/src/Gameboard.Api/Features/Support/Requests/UpdateSupportSettings.cs b/src/Gameboard.Api/Features/Support/Requests/UpdateSupportSettings.cs index 9854c526..6ee5d6a2 100644 --- a/src/Gameboard.Api/Features/Support/Requests/UpdateSupportSettings.cs +++ b/src/Gameboard.Api/Features/Support/Requests/UpdateSupportSettings.cs @@ -27,7 +27,7 @@ IValidatorService validatorService public async Task Handle(UpdateSupportSettingsCommand request, CancellationToken cancellationToken) { await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Support_EditSettings)) + .Auth(a => a.Require(PermissionKey.Support_EditSettings)) .Validate(cancellationToken); var existingSettings = await _store diff --git a/src/Gameboard.Api/Features/Support/Requests/UpsertSupportSettingsAutoTag.cs b/src/Gameboard.Api/Features/Support/Requests/UpsertSupportSettingsAutoTag.cs index 457f0cda..2abc6626 100644 --- a/src/Gameboard.Api/Features/Support/Requests/UpsertSupportSettingsAutoTag.cs +++ b/src/Gameboard.Api/Features/Support/Requests/UpsertSupportSettingsAutoTag.cs @@ -21,7 +21,7 @@ IValidatorService validatorService public async Task Handle(UpsertSupportSettingsAutoTagCommand request, CancellationToken cancellationToken) { await _validatorService - .Auth(c => c.RequirePermissions(Users.PermissionKey.Support_EditSettings)) + .Auth(c => c.Require(Users.PermissionKey.Support_EditSettings)) .AddValidator(ctx => { if (request.AutoTag.ConditionValue.IsEmpty()) diff --git a/src/Gameboard.Api/Features/SystemNotifications/Requests/CreateSystemNotification.cs b/src/Gameboard.Api/Features/SystemNotifications/Requests/CreateSystemNotification.cs index 3741fb03..31b5cb8c 100644 --- a/src/Gameboard.Api/Features/SystemNotifications/Requests/CreateSystemNotification.cs +++ b/src/Gameboard.Api/Features/SystemNotifications/Requests/CreateSystemNotification.cs @@ -34,7 +34,7 @@ public async Task Handle(CreateSystemNotificationCommand { // validate await _validatorService - .Auth(c => c.RequirePermissions(Users.PermissionKey.SystemNotifications_CreateEdit)) + .Auth(c => c.Require(Users.PermissionKey.SystemNotifications_CreateEdit)) .AddValidator ( (req, ctx) => diff --git a/src/Gameboard.Api/Features/SystemNotifications/Requests/DeleteSystemNotification.cs b/src/Gameboard.Api/Features/SystemNotifications/Requests/DeleteSystemNotification.cs index 5715f0da..d72a2724 100644 --- a/src/Gameboard.Api/Features/SystemNotifications/Requests/DeleteSystemNotification.cs +++ b/src/Gameboard.Api/Features/SystemNotifications/Requests/DeleteSystemNotification.cs @@ -26,7 +26,7 @@ public async Task Handle(DeleteSystemNotificationCommand request, CancellationTo { // validate/authorize await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.SystemNotifications_CreateEdit)) + .Auth(a => a.Require(PermissionKey.SystemNotifications_CreateEdit)) .AddValidator(_notificationExists.UseProperty(r => r.SystemNotificationId)) .Validate(request, cancellationToken); diff --git a/src/Gameboard.Api/Features/SystemNotifications/Requests/GetAdminSystemNotifications.cs b/src/Gameboard.Api/Features/SystemNotifications/Requests/GetAdminSystemNotifications.cs index e09e5343..3033d474 100644 --- a/src/Gameboard.Api/Features/SystemNotifications/Requests/GetAdminSystemNotifications.cs +++ b/src/Gameboard.Api/Features/SystemNotifications/Requests/GetAdminSystemNotifications.cs @@ -20,7 +20,7 @@ internal class GetAdminSystemNotificationsHandler(IStore store, IValidatorServic public async Task> Handle(GetAdminSystemNotificationsQuery request, CancellationToken cancellationToken) { await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.SystemNotifications_CreateEdit)) + .Auth(a => a.Require(PermissionKey.SystemNotifications_CreateEdit)) .Validate(cancellationToken); return await _store diff --git a/src/Gameboard.Api/Features/SystemNotifications/Requests/UpdateSystemNotification.cs b/src/Gameboard.Api/Features/SystemNotifications/Requests/UpdateSystemNotification.cs index f5c42850..1e7e0680 100644 --- a/src/Gameboard.Api/Features/SystemNotifications/Requests/UpdateSystemNotification.cs +++ b/src/Gameboard.Api/Features/SystemNotifications/Requests/UpdateSystemNotification.cs @@ -27,7 +27,7 @@ IValidatorService validatorService public async Task Handle(UpdateSystemNotificationCommand request, CancellationToken cancellationToken) { await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.SystemNotifications_CreateEdit)) + .Auth(a => a.Require(PermissionKey.SystemNotifications_CreateEdit)) .AddValidator(_notificationExists.UseProperty(r => r.Update.Id)) .AddValidator ( diff --git a/src/Gameboard.Api/Features/Teams/Requests/AddPlayerToTeam/AddPlayerToTeam.cs b/src/Gameboard.Api/Features/Teams/Requests/AddPlayerToTeam/AddPlayerToTeam.cs index e44e5006..7e058809 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/AddPlayerToTeam/AddPlayerToTeam.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/AddPlayerToTeam/AddPlayerToTeam.cs @@ -52,7 +52,7 @@ await _validatorService .Auth ( config => config - .RequirePermissions(PermissionKey.Teams_Enroll) + .Require(PermissionKey.Teams_Enroll) .Unless(() => _store.AnyAsync(p => p.UserId == _actingUserService.Get().Id && p.Id == request.PlayerId, cancellationToken)) ) .AddValidator(_playerExists.UseValue(request.PlayerId)) diff --git a/src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeam.cs b/src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeam.cs index 4bc67e58..768a567f 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeam.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeam.cs @@ -36,7 +36,7 @@ await _validator .Auth ( c => c - .RequirePermissions(Users.PermissionKey.Teams_Enroll) + .Require(Users.PermissionKey.Teams_Enroll) .Unless ( async () => await _store diff --git a/src/Gameboard.Api/Features/Teams/Requests/AdminEnrollTeam/AdminEnrollTeamValidator.cs b/src/Gameboard.Api/Features/Teams/Requests/AdminEnrollTeam/AdminEnrollTeamValidator.cs index ea82fc4e..cb79adbe 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/AdminEnrollTeam/AdminEnrollTeamValidator.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/AdminEnrollTeam/AdminEnrollTeamValidator.cs @@ -31,7 +31,7 @@ public async Task Validate(AdminEnrollTeamRequest request, CancellationToken can throw new NotImplementedException($"This feature only allows registration for competitive games."); await _validator - .Auth(c => c.RequirePermissions(PermissionKey.Teams_Enroll)) + .Auth(c => c.Require(PermissionKey.Teams_Enroll)) .AddValidator(_gameExists.UseProperty(r => r.GameId)) .AddValidator(async (req, ctx) => { diff --git a/src/Gameboard.Api/Features/Teams/Requests/AdminExtendTeamSessions/AdminExtendTeamSession.cs b/src/Gameboard.Api/Features/Teams/Requests/AdminExtendTeamSessions/AdminExtendTeamSession.cs index 05f6134d..87bf3261 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/AdminExtendTeamSessions/AdminExtendTeamSession.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/AdminExtendTeamSessions/AdminExtendTeamSession.cs @@ -26,7 +26,7 @@ IValidatorService validatorService public async Task Handle(AdminExtendTeamSessionRequest request, CancellationToken cancellationToken) { await _validatorService - .Auth(c => c.RequirePermissions(Users.PermissionKey.Teams_EditSession)) + .Auth(c => c.Require(Users.PermissionKey.Teams_EditSession)) .Validate(cancellationToken); var teams = await _teamService.GetTeams(request.TeamIds); @@ -39,7 +39,8 @@ await _validatorService NewSessionEnd = team.SessionEnd.ToUniversalTime().AddMinutes(request.ExtensionDurationInMinutes), TeamId = team.TeamId }, cancellationToken)); - }; + } + ; return new AdminExtendTeamSessionResponse ( diff --git a/src/Gameboard.Api/Features/Teams/Requests/AdvanceTeams/AdvanceTeams.cs b/src/Gameboard.Api/Features/Teams/Requests/AdvanceTeams/AdvanceTeams.cs index d6a994b6..7a776e81 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/AdvanceTeams/AdvanceTeams.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/AdvanceTeams/AdvanceTeams.cs @@ -28,7 +28,7 @@ public async Task Handle(AdvanceTeamsCommand request, CancellationToken cancella var finalTeamIds = request.TeamIds.Distinct().ToArray(); await _validator - .Auth(c => c.RequirePermissions(PermissionKey.Teams_Enroll)) + .Auth(c => c.Require(PermissionKey.Teams_Enroll)) .AddEntityExistsValidator(request.GameId) .AddValidator(async ctx => { diff --git a/src/Gameboard.Api/Features/Teams/Requests/GetTeamEventHorizon/GetTeamEventHorizon.cs b/src/Gameboard.Api/Features/Teams/Requests/GetTeamEventHorizon/GetTeamEventHorizon.cs index ba454971..5a2fff50 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/GetTeamEventHorizon/GetTeamEventHorizon.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/GetTeamEventHorizon/GetTeamEventHorizon.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Gameboard.Api.Common.Services; using Gameboard.Api.Data; +using Gameboard.Api.Features.Scores; using Gameboard.Api.Features.Users; using Gameboard.Api.Structure.MediatR; using Gameboard.Api.Structure.MediatR.Validators; @@ -15,35 +16,26 @@ namespace Gameboard.Api.Features.Teams; public record GetTeamEventHorizonQuery(string TeamId) : IRequest; -internal class GetTeamEventHorizonHandler : IRequestHandler +internal class GetTeamEventHorizonHandler +( + IActingUserService actingUserService, + IGuidService guidService, + IJsonService jsonService, + IScoringService scoringService, + IStore store, + TeamExistsValidator teamExists, + ITeamService teamService, + IValidatorService validator +) : IRequestHandler { - private readonly IActingUserService _actingUserService; - private readonly IGuidService _guidService; - private readonly IJsonService _jsonService; - private readonly IStore _store; - private readonly TeamExistsValidator _teamExists; - private readonly ITeamService _teamService; - private readonly IValidatorService _validator; - - public GetTeamEventHorizonHandler - ( - IActingUserService actingUserService, - IGuidService guidService, - IJsonService jsonService, - IStore store, - TeamExistsValidator teamExists, - ITeamService teamService, - IValidatorService validator - ) - { - _actingUserService = actingUserService; - _guidService = guidService; - _jsonService = jsonService; - _store = store; - _teamExists = teamExists; - _teamService = teamService; - _validator = validator; - } + private readonly IActingUserService _actingUserService = actingUserService; + private readonly IGuidService _guidService = guidService; + private readonly IJsonService _jsonService = jsonService; + private readonly IScoringService _scoring = scoringService; + private readonly IStore _store = store; + private readonly TeamExistsValidator _teamExists = teamExists; + private readonly ITeamService _teamService = teamService; + private readonly IValidatorService _validator = validator; public async Task Handle(GetTeamEventHorizonQuery request, CancellationToken cancellationToken) { @@ -52,7 +44,7 @@ public async Task Handle(GetTeamEventHorizonQuery request, Cancell await _validator .Auth(config => config - .RequirePermissions(PermissionKey.Teams_Observe) + .Require(PermissionKey.Teams_Observe) .Unless ( () => _store @@ -94,6 +86,9 @@ await _validator .Where(cs => specIds.Contains(cs.Id)) .ToArrayAsync(cancellationToken); + // and we want their scores so we can note that for the challenge + var score = await _scoring.GetTeamScore(request.TeamId, cancellationToken); + // manually compose the events since they come from different sources var events = new List(); @@ -146,7 +141,8 @@ await _validator Challenges = challenges.Select(c => new EventHorizonTeamChallenge { Id = c.Id, - SpecId = c.SpecId + Score = score.Challenges.Where(challenge => challenge.Id == c.Id).FirstOrDefault()?.Score?.TotalScore ?? 0, + SpecId = c.SpecId, }), Session = new EventHorizonSession { diff --git a/src/Gameboard.Api/Features/Teams/Requests/GetTeamEventHorizon/GetTeamEventHorizonModels.cs b/src/Gameboard.Api/Features/Teams/Requests/GetTeamEventHorizon/GetTeamEventHorizonModels.cs index 76bb1b51..34e53d6d 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/GetTeamEventHorizon/GetTeamEventHorizonModels.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/GetTeamEventHorizon/GetTeamEventHorizonModels.cs @@ -100,6 +100,7 @@ public sealed class EventHorizonChallengeSpec public sealed class EventHorizonTeamChallenge { public required string Id { get; set; } + public required double Score { get; set; } public required string SpecId { get; set; } } diff --git a/src/Gameboard.Api/Features/Teams/Requests/GetTeams.cs b/src/Gameboard.Api/Features/Teams/Requests/GetTeams.cs index 96fbc45e..6125af28 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/GetTeams.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/GetTeams.cs @@ -20,7 +20,7 @@ IValidatorService validatorService public async Task> Handle(GetTeamsQuery request, CancellationToken cancellationToken) { await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Admin_View)) + .Auth(a => a.Require(PermissionKey.Admin_View)) .Validate(cancellationToken); return await _teamService.GetTeams(request.TeamIds); diff --git a/src/Gameboard.Api/Features/Teams/Requests/GetTeamsMailMetadata/GetTeamsMailMetadata.cs b/src/Gameboard.Api/Features/Teams/Requests/GetTeamsMailMetadata/GetTeamsMailMetadata.cs index 83dfa006..32f48061 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/GetTeamsMailMetadata/GetTeamsMailMetadata.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/GetTeamsMailMetadata/GetTeamsMailMetadata.cs @@ -18,7 +18,7 @@ internal class GetTeamsMailMetadataHandler(PlayerService playerService, IValidat public async Task> Handle(GetTeamsMailMetadataQuery request, CancellationToken cancellationToken) { await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Teams_Observe)) + .Auth(a => a.Require(PermissionKey.Teams_Observe)) .Validate(cancellationToken); return await _playerService.LoadGameTeamsMailMetadata(request.GameId); diff --git a/src/Gameboard.Api/Features/Teams/Requests/RemoveFromTeam/RemoveFromTeam.cs b/src/Gameboard.Api/Features/Teams/Requests/RemoveFromTeam/RemoveFromTeam.cs index c4f0e2ae..5350e02b 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/RemoveFromTeam/RemoveFromTeam.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/RemoveFromTeam/RemoveFromTeam.cs @@ -30,7 +30,7 @@ IValidatorService validatorService public async Task Handle(RemoveFromTeamCommand request, CancellationToken cancellationToken) { await _validator - .Auth(c => c.RequirePermissions(PermissionKey.Teams_Enroll)) + .Auth(c => c.Require(PermissionKey.Teams_Enroll)) .AddValidator(_playerExists.UseValue(request.PlayerId)) .AddValidator(async ctx => { diff --git a/src/Gameboard.Api/Features/Teams/Requests/ResetTeamSesssion/ResetTeamSession.cs b/src/Gameboard.Api/Features/Teams/Requests/ResetTeamSesssion/ResetTeamSession.cs index bafbeccc..a90b347d 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/ResetTeamSesssion/ResetTeamSession.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/ResetTeamSesssion/ResetTeamSession.cs @@ -101,7 +101,6 @@ public async Task Handle(ResetTeamSessionCommand request, CancellationToken canc player.CorrectCount = 0; player.IsReady = false; player.PartialCount = 0; - player.Score = (int)advancedScore; player.SessionBegin = DateTimeOffset.MinValue; player.SessionEnd = DateTimeOffset.MinValue; player.SessionMinutes = 0; diff --git a/src/Gameboard.Api/Features/Teams/Requests/ResetTeamSesssion/ResetTeamSessionCommandValidator.cs b/src/Gameboard.Api/Features/Teams/Requests/ResetTeamSesssion/ResetTeamSessionCommandValidator.cs index f0bab7b4..7002f336 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/ResetTeamSesssion/ResetTeamSessionCommandValidator.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/ResetTeamSesssion/ResetTeamSessionCommandValidator.cs @@ -35,7 +35,7 @@ await _validatorService .Auth(config => { config - .RequirePermissions(PermissionKey.Play_IgnoreSessionResetSettings) + .Require(PermissionKey.Play_IgnoreSessionResetSettings) .Unless ( () => _store diff --git a/src/Gameboard.Api/Features/Teams/Requests/UpdateTeamReadyStateCommand.cs b/src/Gameboard.Api/Features/Teams/Requests/UpdateTeamReadyStateCommand.cs index afe4e4ae..fbcf8c1d 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/UpdateTeamReadyStateCommand.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/UpdateTeamReadyStateCommand.cs @@ -22,7 +22,7 @@ IValidatorService validatorService public async Task Handle(UpdateTeamReadyStateCommand request, CancellationToken cancellationToken) { await _validatorService - .Auth(a => a.RequirePermissions(Users.PermissionKey.Teams_SetSyncStartReady)) + .Auth(a => a.Require(Users.PermissionKey.Teams_SetSyncStartReady)) .AddValidator(_teamExists.UseProperty(r => r.TeamId)) .Validate(request, cancellationToken); diff --git a/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsModels.cs b/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsModels.cs index da4d803e..cafd25a6 100644 --- a/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsModels.cs +++ b/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsModels.cs @@ -72,3 +72,8 @@ public sealed class UserRolePermissionException(UserRoleKey role, IEnumerable oneOfPermissions) + : GameboardValidationException($"This operation requires at least one of the following permission(s): {string.Join(",", oneOfPermissions)}. Your role ({role}) does not any of these.") +{ +} diff --git a/src/Gameboard.Api/Features/User/Requests/GetUserActiveChallenges.cs b/src/Gameboard.Api/Features/User/Requests/GetUserActiveChallenges.cs index db1d883c..ed45c4fc 100644 --- a/src/Gameboard.Api/Features/User/Requests/GetUserActiveChallenges.cs +++ b/src/Gameboard.Api/Features/User/Requests/GetUserActiveChallenges.cs @@ -43,7 +43,7 @@ await _validator .Auth ( a => a - .RequirePermissions(PermissionKey.Teams_Observe) + .Require(PermissionKey.Teams_Observe) .UnlessUserIdIn(request.UserId) ) .AddValidator(_userExists.UseProperty(m => m.UserId)) diff --git a/src/Gameboard.Api/Features/User/Requests/RequestNameChange/RequestNameChange.cs b/src/Gameboard.Api/Features/User/Requests/RequestNameChange/RequestNameChange.cs index 1654a625..a1602f2f 100644 --- a/src/Gameboard.Api/Features/User/Requests/RequestNameChange/RequestNameChange.cs +++ b/src/Gameboard.Api/Features/User/Requests/RequestNameChange/RequestNameChange.cs @@ -32,7 +32,7 @@ await _validatorService.Auth ( config => config - .RequirePermissions(PermissionKey.Teams_ApproveNameChanges) + .Require(PermissionKey.Teams_ApproveNameChanges) .Unless(() => Task.FromResult(request.UserId == _actingUserService.Get()?.Id && _coreOptions.NameChangeIsEnabled)) ) .AddValidator(request.Request.RequestedName.IsEmpty(), new MissingRequiredInput(nameof(request.Request.RequestedName), request.Request.RequestedName)) diff --git a/src/Gameboard.Api/Features/User/Requests/TryCreateUsers.cs b/src/Gameboard.Api/Features/User/Requests/TryCreateUsers.cs index ab815f60..63dc7b0e 100644 --- a/src/Gameboard.Api/Features/User/Requests/TryCreateUsers.cs +++ b/src/Gameboard.Api/Features/User/Requests/TryCreateUsers.cs @@ -39,7 +39,7 @@ IValidatorService validator public async Task Handle(TryCreateUsersCommand request, CancellationToken cancellationToken) { // validate/authorize - _validator.Auth(config => config.RequirePermissions(PermissionKey.Users_CreateEditDelete)); + _validator.Auth(config => config.Require(PermissionKey.Users_CreateEditDelete)); // optionally throw if the caller doesn't want to ignore the fact that some users exist already if (!request.Request.AllowSubsetCreation) diff --git a/src/Gameboard.Api/Features/User/Requests/UserRolePermissionsOverview/UserRolePermissionOverview.cs b/src/Gameboard.Api/Features/User/Requests/UserRolePermissionsOverview/UserRolePermissionOverview.cs index 154a38e3..c56e0544 100644 --- a/src/Gameboard.Api/Features/User/Requests/UserRolePermissionsOverview/UserRolePermissionOverview.cs +++ b/src/Gameboard.Api/Features/User/Requests/UserRolePermissionsOverview/UserRolePermissionOverview.cs @@ -24,7 +24,7 @@ IValidatorService validatorService public async Task Handle(UserRolePermissionsOverviewQuery request, CancellationToken cancellationToken) { await _validatorService - .Auth(a => a.RequirePermissions(PermissionKey.Admin_View)) + .Auth(a => a.Require(PermissionKey.Admin_View)) .Validate(cancellationToken); var permissions = await _permissionsService.List(); diff --git a/src/Gameboard.Api/Structure/MediatR/Validators/UserRolePermissionsValidator.cs b/src/Gameboard.Api/Structure/MediatR/Validators/UserRolePermissionsValidator.cs index bb6de034..b077415a 100644 --- a/src/Gameboard.Api/Structure/MediatR/Validators/UserRolePermissionsValidator.cs +++ b/src/Gameboard.Api/Structure/MediatR/Validators/UserRolePermissionsValidator.cs @@ -9,7 +9,8 @@ namespace Gameboard.Api.Structure.MediatR.Validators; public interface IUserRolePermissionsValidator { public IUserRolePermissionsValidator RequireAuthentication(); - public IUserRolePermissionsValidator RequirePermissions(params PermissionKey[] requiredPermissions); + public IUserRolePermissionsValidator Require(params PermissionKey[] requiredPermissions); + public IUserRolePermissionsValidator RequireOneOf(params PermissionKey[] permissions); public IUserRolePermissionsValidator Unless(Func> condition); public IUserRolePermissionsValidator Unless(Func> condition, GameboardValidationException validationException); public IUserRolePermissionsValidator UnlessUserIdIn(params string[] userIds); @@ -19,6 +20,7 @@ internal class UserRolePermissionsValidator(IUserRolePermissionsService userRole { private bool _requireAuthentication = false; private readonly IUserRolePermissionsService _userRolePermissionsService = userRolePermissionsService; + private PermissionKey[] _requireOneOf { get; set; } = []; private IEnumerable _requiredPermissions { get; set; } = []; private Func> _unless { get; set; } private GameboardValidationException _unlessException; @@ -27,31 +29,33 @@ internal class UserRolePermissionsValidator(IUserRolePermissionsService userRole internal async Task> GetAuthValidationExceptions(User user) { if (_requireAuthentication && user is null) + { throw new UnauthorizedAccessException($"This operation requires authentication."); + } - // if there are no required permissions, validation always passes - if (_requiredPermissions is not null && _requiredPermissions.Any()) + // if the user is on the whitelist, let em through + if (_unlessUserIdIn is not null && _unlessUserIdIn.Any(uId => uId == user.Id)) + return []; + + // if some other condition allows access independent of user ID + if (_unless is not null) { - // if the user is on the whitelist, let em through - if (_unlessUserIdIn is not null && _unlessUserIdIn.Any(uId => uId == user.Id)) - return []; + var result = await _unless(); - // if some other condition allows access independent of user ID - if (_unless is not null) + if (result) { - var result = await _unless(); - - if (result) - { - return []; - } + return []; } + } - // otherwise, check their role to see if it has the permissions needed - var permissions = await _userRolePermissionsService.GetPermissions(user.Role); - var missingPermissions = _requiredPermissions.Where(p => !permissions.Contains(p)); + // otherwise, check their role to see if it has the permissions needed + var permissions = await _userRolePermissionsService.GetPermissions(user.Role); + var retVal = new List(); - var retVal = new List(); + // if there are no required permissions, validation always passes + if (_requiredPermissions is not null && _requiredPermissions.Any()) + { + var missingPermissions = _requiredPermissions.Where(p => !permissions.Contains(p)); if (missingPermissions.Any()) { @@ -66,6 +70,11 @@ internal async Task> GetAuthValidation return retVal; } + if (_requireOneOf.IsNotEmpty() && !permissions.Intersect(_requireOneOf).Any()) + { + retVal.Add(new UserRoleOneOfPermissionsException(user.Role, _requireOneOf)); + } + return []; } @@ -75,12 +84,18 @@ public IUserRolePermissionsValidator RequireAuthentication() return this; } - public IUserRolePermissionsValidator RequirePermissions(params PermissionKey[] requiredPermissions) + public IUserRolePermissionsValidator Require(params PermissionKey[] requiredPermissions) { _requiredPermissions = requiredPermissions; return this; } + public IUserRolePermissionsValidator RequireOneOf(params PermissionKey[] permissions) + { + _requireOneOf = [.. _requireOneOf.Concat(permissions).Distinct()]; + return this; + } + public IUserRolePermissionsValidator Unless(Func> condition) { _unless = condition; From 396fc6b8b458cb8e863d5daa8ea1e8a3aa460268 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 14 Jan 2025 15:48:49 -0500 Subject: [PATCH 15/26] Remove version endpoint since we no longer support it anyway. --- .../Features/Version/VersionController.cs | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 src/Gameboard.Api/Features/Version/VersionController.cs diff --git a/src/Gameboard.Api/Features/Version/VersionController.cs b/src/Gameboard.Api/Features/Version/VersionController.cs deleted file mode 100644 index 55e0f0cf..00000000 --- a/src/Gameboard.Api/Features/Version/VersionController.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Microsoft.AspNetCore.Mvc; - -namespace Gameboard.Api.Features.Version; - -public class VersionController : Controller -{ - /// - /// check version - /// - /// The commit SHA of the current application version. - [HttpGet("/api/version")] - public IActionResult Version() - { - return Ok(new - { - Commit = Environment.GetEnvironmentVariable("COMMIT") ?? "no version info" - }); - } -} From 0064bd62c6b7ce4231ae157bec573f788d83027e Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 14 Jan 2025 15:56:19 -0500 Subject: [PATCH 16/26] Resolve #572 --- .../Features/ApiStatus/ApiStatusController.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/Gameboard.Api/Features/ApiStatus/ApiStatusController.cs diff --git a/src/Gameboard.Api/Features/ApiStatus/ApiStatusController.cs b/src/Gameboard.Api/Features/ApiStatus/ApiStatusController.cs new file mode 100644 index 00000000..25aead3f --- /dev/null +++ b/src/Gameboard.Api/Features/ApiStatus/ApiStatusController.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Gameboard.Api.Features.Status; + +[ApiController] +[Route("/api/status")] +public class ApiStatusController : ControllerBase +{ + [HttpGet] + [AllowAnonymous] + public ActionResult Get() + => Ok(); +} From 3ceba4e2fa49a61e609485adbb2df2cdc9a5f669 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 15 Jan 2025 09:43:23 -0500 Subject: [PATCH 17/26] Fixed autotagging bug and minor cleanup --- src/Gameboard.Api/Data/Entities/Ticket.cs | 2 +- .../GetGameCenterTeams/GetGameCenterTeams.cs | 1 - .../Challenge/Services/ChallengeService.cs | 2 +- .../Features/Support/AutoTagService.cs | 79 ++++++++++--------- .../Features/Ticket/TicketService.cs | 11 +-- 5 files changed, 50 insertions(+), 45 deletions(-) diff --git a/src/Gameboard.Api/Data/Entities/Ticket.cs b/src/Gameboard.Api/Data/Entities/Ticket.cs index fef9d910..6b576fe3 100644 --- a/src/Gameboard.Api/Data/Entities/Ticket.cs +++ b/src/Gameboard.Api/Data/Entities/Ticket.cs @@ -33,8 +33,8 @@ public class Ticket : IEntity public User Creator { get; set; } public Challenge Challenge { get; set; } public Player Player { get; set; } + // Activity is a thread of comments and activity like status or assignee changes public ICollection Activity { get; set; } = []; } - } diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterTeams/GetGameCenterTeams.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterTeams/GetGameCenterTeams.cs index d2cc7d71..69304cdc 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterTeams/GetGameCenterTeams.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterTeams/GetGameCenterTeams.cs @@ -10,7 +10,6 @@ using Gameboard.Api.Services; using MediatR; using Microsoft.EntityFrameworkCore; -using ServiceStack; namespace Gameboard.Api.Features.Admin; diff --git a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs index 4e050656..091e814f 100644 --- a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs +++ b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs @@ -176,7 +176,7 @@ public async Task Create(NewChallenge model, string actorId, string g } finally { - entry.Specs = entry.Specs.Where(s => s.SpecId != model.SpecId).ToList(); + entry.Specs = [.. entry.Specs.Where(s => s.SpecId != model.SpecId)]; } } diff --git a/src/Gameboard.Api/Features/Support/AutoTagService.cs b/src/Gameboard.Api/Features/Support/AutoTagService.cs index 909f1488..ff96a8e9 100644 --- a/src/Gameboard.Api/Features/Support/AutoTagService.cs +++ b/src/Gameboard.Api/Features/Support/AutoTagService.cs @@ -4,7 +4,6 @@ using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Data; -using Gameboard.Api.Features.Teams; using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Features.Support; @@ -20,12 +19,10 @@ internal sealed class AutoTagService(IStore store) : IAutoTagService public async Task> GetAutoTags(Data.Ticket ticket, CancellationToken cancellationToken) { - if (ticket.ChallengeId.IsEmpty() && ticket.TeamId.IsEmpty()) - return []; + var retVal = new List(); var teamSponsorIds = Array.Empty(); - - if (ticket.TeamId.IsEmpty()) + if (!ticket.TeamId.IsNotEmpty()) { teamSponsorIds = await _store .WithNoTracking() @@ -33,43 +30,51 @@ public async Task> GetAutoTags(Data.Ticket ticket, Cancellat .Select(p => p.SponsorId) .Distinct() .ToArrayAsync(cancellationToken); + + retVal.AddRange + ( + await _store + .WithNoTracking() + .Where(t => t.ConditionType == SupportSettingsAutoTagConditionType.SponsorId && teamSponsorIds.Contains(t.ConditionValue)) + .Select(t => t.Tag) + .ToArrayAsync(cancellationToken) + ); } - var challengeData = await _store - .WithNoTracking() - .Where(c => c.Id == ticket.ChallengeId) - .Select(c => new - { - c.PlayerMode, - c.SpecId, - c.GameId - }) - .SingleAsync(cancellationToken); + if (ticket.ChallengeId.IsNotEmpty()) + { + var challengeData = await _store + .WithNoTracking() + .Where(c => c.Id == ticket.ChallengeId) + .Select(c => new + { + c.GameId + c.PlayerMode, + c.SpecId, + }) + .SingleOrDefaultAsync(cancellationToken); - var playerModeValue = (int)challengeData.PlayerMode; + var playerModeValue = (int)challengeData.PlayerMode; - var autoTagConfig = await _store - .WithNoTracking() - .Where(t => t.IsEnabled) - .Where - ( - t => - (t.ConditionType == SupportSettingsAutoTagConditionType.ChallengeSpecId && t.ConditionValue == challengeData.SpecId) || - (t.ConditionType == SupportSettingsAutoTagConditionType.GameId && t.ConditionValue == challengeData.GameId) || - (t.ConditionType == SupportSettingsAutoTagConditionType.PlayerMode && t.ConditionValue == playerModeValue.ToString()) || - (t.ConditionType == SupportSettingsAutoTagConditionType.SponsorId && teamSponsorIds.Contains(t.ConditionValue)) - ) - .Select(t => new + if (challengeData is not null) { - t.ConditionType, - t.ConditionValue, - t.Tag - }) - .ToArrayAsync(cancellationToken); - - var autoTags = await _store.WithNoTracking().ToArrayAsync(); - - return [.. autoTagConfig.Select(c => c.Tag).OrderBy(t => t)]; + retVal.AddRange + ( + await _store + .WithNoTracking() + .Where + ( + c => + (c.ConditionType == SupportSettingsAutoTagConditionType.GameId && c.ConditionValue == challengeData.GameId) || + (c.ConditionType == SupportSettingsAutoTagConditionType.ChallengeSpecId && c.ConditionValue == challengeData.SpecId) || + (c.ConditionType == SupportSettingsAutoTagConditionType.PlayerMode && c.ConditionValue == challengeData.PlayerMode.ToString()) + ) + .Select(c => c.Tag) + .ToArrayAsync(cancellationToken) + ); + } + } + return [.. retVal.Distinct().OrderBy(t => t)]; } } diff --git a/src/Gameboard.Api/Features/Ticket/TicketService.cs b/src/Gameboard.Api/Features/Ticket/TicketService.cs index dd86f86a..0caa41ea 100644 --- a/src/Gameboard.Api/Features/Ticket/TicketService.cs +++ b/src/Gameboard.Api/Features/Ticket/TicketService.cs @@ -48,7 +48,7 @@ ITeamService teamService private readonly ISupportHubBus _supportHubBus = supportHubBus; private readonly ITeamService _teamService = teamService; - internal static char LABELS_DELIMITER = ' '; + internal static char TAGS_DELIMITER = ' '; public string GetFullKey(int key) => $"{(Options.KeyPrefix.IsEmpty() ? "GB" : Options.KeyPrefix)}-{key}"; @@ -191,14 +191,15 @@ public async Task Update(ChangedTicket model, string actorId, bool sudo) } if (statusChanged && entity.Status == "Closed") + { updateClosesTicket = true; + } updatedBySupport = true; } else // regular participant can only edit a few fields { Mapper.Map(Mapper.Map(model), entity); - updatedByUser = true; } @@ -452,7 +453,7 @@ internal IEnumerable TransformTicketLabels(string labels) if (labels.IsEmpty()) return []; - return labels.Split(LABELS_DELIMITER, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return labels.Split(TAGS_DELIMITER, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } private async Task UpdatedSessionContext(Data.Ticket entity) @@ -486,7 +487,7 @@ private async Task UpdatedSessionContext(Data.Ticket entity) // add conditional auto-tags var autoTags = await _autoTagService.GetAutoTags(entity, CancellationToken.None); - var finalTags = new List(entity.Label?.Split(LABELS_DELIMITER, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []); + var finalTags = new List(entity.Label?.Split(TAGS_DELIMITER, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []); foreach (var autoTag in autoTags) { @@ -496,7 +497,7 @@ private async Task UpdatedSessionContext(Data.Ticket entity) } } - entity.Label = string.Join(LABELS_DELIMITER, finalTags); + entity.Label = string.Join(TAGS_DELIMITER, finalTags); if (entity.TeamId.IsEmpty()) { From 0b5625a341244089df98d1ee72c4cad51151cd43 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 15 Jan 2025 13:45:32 -0500 Subject: [PATCH 18/26] Fix autotagging bugs, add observe to practice tab, fix import/export stuff --- .../GetGameCenterPractice.cs | 139 +++++++++--------- .../GetGameCenterPracticeModels.cs | 1 + .../Features/Game/GameController.cs | 12 -- .../Features/Game/GameService.cs | 53 +------ .../ImportExport/GameImportExportModels.cs | 7 - .../ImportExport/GameImportExportService.cs | 3 +- .../Features/Support/AutoTagService.cs | 24 ++- .../Features/Ticket/TicketController.cs | 3 +- 8 files changed, 95 insertions(+), 147 deletions(-) diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPractice.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPractice.cs index c5532794..1c5ff8bc 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPractice.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPractice.cs @@ -17,29 +17,20 @@ namespace Gameboard.Api.Features.Admin; public record GetGameCenterPracticeContextQuery(string GameId, string SearchTerm, GameCenterPracticeSessionStatus? SessionStatus, GameCenterPracticeSort? Sort) : IRequest; -internal class GetGameCenterPracticeQueryHandler : IRequestHandler +internal class GetGameCenterPracticeQueryHandler +( + EntityExistsValidator gameExists, + INowService now, + IScoringService scoringService, + IStore store, + IValidatorService validatorService +) : IRequestHandler { - private readonly EntityExistsValidator _gameExists; - private readonly INowService _now; - private readonly IScoringService _scoringService; - private readonly IStore _store; - private readonly IValidatorService _validatorService; - - public GetGameCenterPracticeQueryHandler - ( - EntityExistsValidator gameExists, - INowService now, - IScoringService scoringService, - IStore store, - IValidatorService validatorService - ) - { - _gameExists = gameExists; - _now = now; - _scoringService = scoringService; - _store = store; - _validatorService = validatorService; - } + private readonly EntityExistsValidator _gameExists = gameExists; + private readonly INowService _now = now; + private readonly IScoringService _scoringService = scoringService; + private readonly IStore _store = store; + private readonly IValidatorService _validatorService = validatorService; public async Task Handle(GetGameCenterPracticeContextQuery request, CancellationToken cancellationToken) { @@ -75,6 +66,7 @@ public async Task Handle(GetGameCenterPracticeContext c.SpecId, c.Points, c.Score, + c.TeamId, User = new PlayerWithSponsor { Id = c.Player.UserId, @@ -108,63 +100,64 @@ public async Task Handle(GetGameCenterPracticeContext .SingleOrDefaultAsync(g => g.Id == request.GameId, cancellationToken); var responseUsers = users.Select(u => - { - var challengeSpecs = new List(); - var totalAttempts = 0; - var activeChallenge = default(SimpleEntity); - var activeChallengeEndTimestamp = default(long?); + { + var challengeSpecs = new List(); + var totalAttempts = 0; + var activeChallenge = default(SimpleEntity); + var activeChallengeEndTimestamp = default(long?); + var activeTeamId = default(string); - foreach (var specId in u.Value.Keys) - { - var attemptCount = u.Value[specId].Count(); - totalAttempts += attemptCount; - var lastAttempt = u.Value[specId].OrderByDescending(c => c.StartTime).FirstOrDefault(); - var bestAttempt = u.Value[specId].OrderByDescending(c => c.Score).FirstOrDefault(); + foreach (var specId in u.Value.Keys) + { + var attemptCount = u.Value[specId].Count(); + totalAttempts += attemptCount; + var lastAttempt = u.Value[specId].OrderByDescending(c => c.StartTime).FirstOrDefault(); + var bestAttempt = u.Value[specId].OrderByDescending(c => c.Score).FirstOrDefault(); - if (activeChallenge is null) - { - var potentialActiveChallenge = u.Value[specId] - .Where(c => c.StartTime > DateTimeOffset.MinValue && c.StartTime <= nowish) - .Where(c => c.EndTime == DateTimeOffset.MinValue || c.EndTime > nowish) - .FirstOrDefault(); - - if (potentialActiveChallenge is not null) - { - activeChallenge = new SimpleEntity { Id = potentialActiveChallenge.Id, Name = potentialActiveChallenge.Name }; - activeChallengeEndTimestamp = potentialActiveChallenge.EndTime.IsEmpty() ? null : potentialActiveChallenge.EndTime.ToUnixTimeMilliseconds(); - } - } - specs.TryGetValue(specId, out var spec); + var potentialActiveChallenge = u.Value[specId] + .Where(c => c.StartTime > DateTimeOffset.MinValue && c.StartTime <= nowish) + .Where(c => c.EndTime == DateTimeOffset.MinValue || c.EndTime > nowish) + .FirstOrDefault(); - challengeSpecs.Add(new() - { - Id = specId, - Name = bestAttempt.Name, - Tag = spec?.Tag, - AttemptCount = attemptCount, - BestAttempt = bestAttempt is null ? null : new GameCenterPracticeContextChallengeAttempt - { - AttemptTimestamp = bestAttempt.StartTime.ToUnixTimeMilliseconds(), - Result = _scoringService.GetChallengeResult(bestAttempt.Score, bestAttempt.Points), - Score = bestAttempt.Score - }, - LastAttemptDate = lastAttempt.StartTime.IsEmpty() ? null : lastAttempt.StartTime.ToUnixTimeMilliseconds(), - }); + if (potentialActiveChallenge is not null) + { + activeChallenge = new SimpleEntity { Id = potentialActiveChallenge.Id, Name = potentialActiveChallenge.Name }; + activeChallengeEndTimestamp = potentialActiveChallenge.EndTime.IsEmpty() ? null : potentialActiveChallenge.EndTime.ToUnixTimeMilliseconds(); + activeTeamId = potentialActiveChallenge.TeamId; } - return new GameCenterPracticeContextUser + specs.TryGetValue(specId, out var spec); + + challengeSpecs.Add(new() { - Id = u.Key.Id, - Name = u.Key.Name, - Sponsor = u.Key.Sponsor, - ActiveChallenge = activeChallenge, - ActiveChallengeEndTimestamp = activeChallengeEndTimestamp, - TotalAttempts = totalAttempts, - UniqueChallengeSpecs = u.Value.Keys.Count, - ChallengeSpecs = challengeSpecs - }; - }) - .Where(u => request.SessionStatus is null || (request.SessionStatus == GameCenterPracticeSessionStatus.Playing == u.ActiveChallenge is not null)); + Id = specId, + Name = bestAttempt.Name, + Tag = spec?.Tag, + AttemptCount = attemptCount, + BestAttempt = bestAttempt is null ? null : new GameCenterPracticeContextChallengeAttempt + { + AttemptTimestamp = bestAttempt.StartTime.ToUnixTimeMilliseconds(), + Result = _scoringService.GetChallengeResult(bestAttempt.Score, bestAttempt.Points), + Score = bestAttempt.Score + }, + LastAttemptDate = lastAttempt.StartTime.IsEmpty() ? null : lastAttempt.StartTime.ToUnixTimeMilliseconds(), + }); + } + + return new GameCenterPracticeContextUser + { + Id = u.Key.Id, + Name = u.Key.Name, + Sponsor = u.Key.Sponsor, + ActiveChallenge = activeChallenge, + ActiveChallengeEndTimestamp = activeChallengeEndTimestamp, + ActiveTeamId = activeTeamId, + TotalAttempts = totalAttempts, + UniqueChallengeSpecs = u.Value.Keys.Count, + ChallengeSpecs = challengeSpecs + }; + }) + .Where(u => request.SessionStatus is null || (request.SessionStatus == GameCenterPracticeSessionStatus.Playing == u.ActiveChallenge is not null)); responseUsers = request.Sort switch { diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPracticeModels.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPracticeModels.cs index 19b0b6b5..1d465ca3 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPracticeModels.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPracticeModels.cs @@ -15,6 +15,7 @@ public sealed class GameCenterPracticeContextUser public required SimpleSponsor Sponsor { get; set; } public required SimpleEntity ActiveChallenge { get; set; } public required long? ActiveChallengeEndTimestamp { get; set; } + public required string ActiveTeamId { get; set; } public required int TotalAttempts { get; set; } public required int UniqueChallengeSpecs { get; set; } public required IEnumerable ChallengeSpecs { get; set; } diff --git a/src/Gameboard.Api/Features/Game/GameController.cs b/src/Gameboard.Api/Features/Game/GameController.cs index 269a59b1..4c154aca 100644 --- a/src/Gameboard.Api/Features/Game/GameController.cs +++ b/src/Gameboard.Api/Features/Game/GameController.cs @@ -150,18 +150,6 @@ public Task GetSyncStartState(string gameId) public Task GetGamePlayState(string gameId) => _mediator.Send(new GetGamePlayStateQuery(gameId, Actor.Id)); - [HttpPost("/api/game/import")] - [Authorize] - public Task ImportGameSpec([FromBody] GameSpecImport model) - => GameService.Import(model); - - [HttpPost("/api/game/export")] - public async Task ExportGameSpec([FromBody] GameSpecExport model) - { - await Authorize(_permissionsService.Can(PermissionKey.Games_CreateEditDelete)); - return await GameService.Export(model); - } - [HttpPost("/api/games/export")] public Task ExportGames([FromBody] ExportGameCommand request, CancellationToken cancellationToken) => _mediator.Send(request, cancellationToken); diff --git a/src/Gameboard.Api/Features/Game/GameService.cs b/src/Gameboard.Api/Features/Game/GameService.cs index 5beb9eec..88979b5a 100644 --- a/src/Gameboard.Api/Features/Game/GameService.cs +++ b/src/Gameboard.Api/Features/Game/GameService.cs @@ -5,26 +5,20 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using AutoMapper; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; using Gameboard.Api.Common.Services; using Microsoft.AspNetCore.Http; using Gameboard.Api.Data; -using Gameboard.Api.Features.Users; namespace Gameboard.Api.Services; public interface IGameService { Task Create(NewGame model); - Task Export(GameSpecExport model); - Task Import(GameSpecImport model); IQueryable GetTeamsWithActiveSession(string GameId); Task IsUserPlaying(string gameId, string userId); Task> List(GameSearchFilter model = null, bool sudo = false); @@ -45,14 +39,12 @@ public class GameService( CoreOptions options, Defaults defaults, INowService nowService, - IUserRolePermissionsService permissionsService, IStore store ) : _Service(logger, mapper, options), IGameService { private readonly Defaults _defaults = defaults; private readonly IGuidService _guids = guids; private readonly INowService _now = nowService; - private readonly IUserRolePermissionsService _permissionsService = permissionsService; private readonly IStore _store = store; public async Task Create(NewGame model) @@ -153,7 +145,7 @@ public async Task ListGrouped(GameSearchFilter model, bool sudo) else b = b.OrderBy(g => g.Year).ThenBy(g => g.Month); - return b.ToArray(); + return [.. b]; } public async Task RetrieveChallengeSpecs(string id) @@ -205,49 +197,6 @@ public async Task SessionForecast(string id) return [.. result]; } - public async Task Export(GameSpecExport model) - { - var yaml = new SerializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .Build(); - - var entity = await _store - .WithNoTracking() - .Include(g => g.Specs) - .SingleOrDefaultAsync(g => g.Id == model.Id); - - if (entity is not null) - return yaml.Serialize(entity); - - entity = new Data.Game { Id = _guids.Generate() }; - - for (int i = 0; i < model.GenerateSpecCount; i++) - entity.Specs.Add(new Data.ChallengeSpec - { - Id = _guids.Generate(), - GameId = entity.Id - }); - - return model.Format == ExportFormat.Yaml - ? yaml.Serialize(entity) - : JsonSerializer.Serialize(entity, JsonOptions); - } - - public async Task Import(GameSpecImport model) - { - if (!await _permissionsService.Can(PermissionKey.Games_CreateEditDelete)) - throw new ActionForbidden(); - - var yaml = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); - - var entity = yaml.Deserialize(model.Data); - await _store.Create(entity); - return Mapper.Map(entity); - } - public async Task UpdateImage(string id, string type, string filename) { var entity = await _store diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs index 0433a058..5a86b747 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Microsoft.EntityFrameworkCore.Storage; namespace Gameboard.Api.Features.Games; @@ -111,12 +110,6 @@ public sealed class GameImportExportCertificateTemplate public string Content { get; set; } } -public sealed class GameImportExportImages -{ - public required string CardFileName { get; set; } - public required string MapFileName { get; set; } -} - public sealed class GameImportExportSponsor { public required string Id { get; set; } diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs index 895bccb2..08550f11 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs @@ -271,7 +271,7 @@ public async Task ExportPackage(string[] gameIds, bool in RegistrationOpen = game.RegistrationOpen, RegistrationMarkdown = game.RegistrationMarkdown, RegistrationType = game.RegistrationType, - RequireSynchronizedStart = game.RequireSession, + RequireSynchronizedStart = game.RequireSynchronizedStart, RequireSponsoredTeam = game.RequireSponsoredTeam, SessionAvailabilityWarningThreshold = game.SessionAvailabilityWarningThreshold, SessionLimit = game.SessionLimit, @@ -342,6 +342,7 @@ public async Task ImportPackage(byte[] package, CancellationToke var actingUser = _actingUser.Get(); Directory.CreateDirectory(GetImportBatchRoot(importBatchId)); Directory.CreateDirectory(GetImportPackageRoot()); + Directory.CreateDirectory(GetImportBatchImageRoot(importBatchId)); // extract the data var tempArchivePath = Path.Combine(GetImportPackageRoot(), importBatchId) + ".zip"; diff --git a/src/Gameboard.Api/Features/Support/AutoTagService.cs b/src/Gameboard.Api/Features/Support/AutoTagService.cs index ff96a8e9..e849eb1b 100644 --- a/src/Gameboard.Api/Features/Support/AutoTagService.cs +++ b/src/Gameboard.Api/Features/Support/AutoTagService.cs @@ -48,7 +48,7 @@ await _store .Where(c => c.Id == ticket.ChallengeId) .Select(c => new { - c.GameId + c.GameId, c.PlayerMode, c.SpecId, }) @@ -73,6 +73,28 @@ await _store .ToArrayAsync(cancellationToken) ); } + + if (ticket.TeamId.IsNotEmpty()) + { + var teamGameIds = await _store + .WithNoTracking() + .Where(p => p.Id == ticket.TeamId) + .Select(p => p.GameId) + .Distinct() + .ToArrayAsync(cancellationToken); + + if (teamGameIds.Length > 0) + { + retVal.AddRange + ( + await _store + .WithNoTracking() + .Where(c => c.ConditionType == SupportSettingsAutoTagConditionType.GameId && teamGameIds.Contains(c.ConditionValue)) + .Select(c => c.Tag) + .ToArrayAsync(cancellationToken) + ); + } + } } return [.. retVal.Distinct().OrderBy(t => t)]; diff --git a/src/Gameboard.Api/Features/Ticket/TicketController.cs b/src/Gameboard.Api/Features/Ticket/TicketController.cs index 73d2365e..947ce0a2 100644 --- a/src/Gameboard.Api/Features/Ticket/TicketController.cs +++ b/src/Gameboard.Api/Features/Ticket/TicketController.cs @@ -90,12 +90,13 @@ public async Task Update([FromBody] ChangedTicket model) var prevTicket = await TicketService.Retrieve(model.Id); var result = await TicketService.Update(model, Actor.Id, isTicketAdmin); - // Ignore labels being different if (result.Label != prevTicket.Label) prevTicket.LastUpdated = result.LastUpdated; // If the ticket hasn't been meaningfully updated, don't send a notification if (prevTicket.LastUpdated != result.LastUpdated) + { await Notify(Mapper.Map(result), EventAction.Updated); + } return result; } From 0f3ea4f3e21fff21b04194ecd0aae35931a4e164 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 15 Jan 2025 13:56:29 -0500 Subject: [PATCH 19/26] Fix bug that prevented feedback from loading for support personnel --- .../Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs | 4 +++- .../Features/Reports/Queries/FeedbackReport/FeedbackReport.cs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs b/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs index c65f5b62..6956a8e7 100644 --- a/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs +++ b/src/Gameboard.Api/Features/Feedback/Requests/ListFeedbackTemplates/ListFeedbackTemplates.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using AutoMapper; using Gameboard.Api.Data; +using Gameboard.Api.Features.Users; using Gameboard.Api.Structure.MediatR; using MediatR; using Microsoft.EntityFrameworkCore; @@ -19,7 +20,8 @@ internal sealed class ListFeedbackTemplatesHandler(IMapper mapper, IStore store, public async Task Handle(ListFeedbackTemplatesQuery request, CancellationToken cancellationToken) { await _validatorService - .Auth(c => c.Require(Users.PermissionKey.Games_CreateEditDelete)) + // reports users are allowed here because of the filters + .Auth(c => c.RequireOneOf(PermissionKey.Games_CreateEditDelete, PermissionKey.Reports_View)) .Validate(cancellationToken); var templates = await _mapper diff --git a/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReport.cs b/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReport.cs index 5a2dcc00..a4ed03e9 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReport.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReport.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Common.Services; +using Gameboard.Api.Features.Users; using Gameboard.Api.Structure.MediatR; using MediatR; @@ -27,7 +28,7 @@ IValidatorService validator public async Task> Handle(FeedbackReportQuery request, CancellationToken cancellationToken) { await _validator - .Auth(c => c.Require(Users.PermissionKey.Reports_View)) + .Auth(c => c.Require(PermissionKey.Reports_View)) .Validate(cancellationToken); var results = await _feedbackReportService.GetBaseQuery(request.Parameters, cancellationToken); From 9848b05c0205fa90091eefac4d593a565c2acbe3 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 15 Jan 2025 15:47:43 -0500 Subject: [PATCH 20/26] Fix enrollment report unstarted teams calc. Fix challenge launch failure after an attempt that is canceled --- .../Challenge/Services/ChallengeService.cs | 43 ++++++++++--------- .../EnrollmentReportService.cs | 9 ++-- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs index 091e814f..7594c50e 100644 --- a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs +++ b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs @@ -136,32 +136,33 @@ public async Task Create(NewChallenge model, string actorId, string g TeamId = player.TeamId, Specs = [] }); - _launchCache.TryGetValue(player.TeamId, out var entry); - if (entry.Specs.Any(s => s.SpecId == model.SpecId)) - { - throw new ChallengeStartPending(); - } - else + try { - entry.Specs.Add(new ChallengeLaunchCacheEntrySpec { GameId = player.GameId, SpecId = model.SpecId }); - } + cancellationToken.ThrowIfCancellationRequested(); + + if (entry.Specs.Any(s => s.SpecId == model.SpecId)) + { + throw new ChallengeStartPending(); + } + else + { + entry.Specs.Add(new ChallengeLaunchCacheEntrySpec { GameId = player.GameId, SpecId = model.SpecId }); + } - var spec = await _store - .WithNoTracking() - .SingleAsync(s => s.Id == model.SpecId, cancellationToken); + var spec = await _store + .WithNoTracking() + .SingleAsync(s => s.Id == model.SpecId, cancellationToken); - var playerCount = 1; - if (player.Game.AllowTeam) - { - playerCount = await _store - .WithNoTracking() - .CountAsync(p => p.TeamId == player.TeamId, cancellationToken); - } + var playerCount = 1; + if (player.Game.AllowTeam) + { + playerCount = await _store + .WithNoTracking() + .CountAsync(p => p.TeamId == player.TeamId, cancellationToken); + } - try - { var challenge = await BuildAndRegisterChallenge(model, spec, player.Game, player, actorId, graderUrl, playerCount, model.Variant); await _store.Create(challenge, cancellationToken); @@ -171,7 +172,7 @@ public async Task Create(NewChallenge model, string actorId, string g } catch (Exception ex) { - Logger.LogWarning(message: "Challenge registration failure: {exName} -- {exMessage}", ex.GetType().Name, ex.Message); + Logger.LogWarning("Challenge registration failure: {exName} -- {exMessage}", ex.GetType().Name, ex.Message); throw; } finally diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs index 7b9553ec..380b9619 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs @@ -229,20 +229,23 @@ public async Task GetSummaryStats(EnrollmentReportP } } + var distinctTeamIds = rawResults.Select(r => r.Player.TeamId).Distinct().ToArray(); var noChallengeStartedPlayers = rawResults.Where(r => r.ChallengesStarted == 0); var noSessionPlayers = rawResults.Where(r => r.Player.SessionBegin.IsEmpty()); + var teamsWithSession = rawResults.Where(r => r.Player.SessionBegin.IsNotEmpty()).Select(p => p.Player.TeamId).Distinct().Count(); + var startedChallengeTeamsCount = distinctTeamIds.Length - rawResults.Where(p => p.ChallengesStarted > 0).Select(r => r.Player.TeamId).Distinct().Count(); return new EnrollmentReportStatSummary { DistinctGameCount = rawResults.Select(r => r.Player.GameId).Distinct().Count(), DistinctPlayerCount = rawResults.Select(r => r.Player.UserId).Distinct().Count(), DistinctSponsorCount = rawResults.Select(r => r.Player.SponsorId).Distinct().Count(), - DistinctTeamCount = rawResults.Select(r => r.Player.TeamId).Distinct().Count(), + DistinctTeamCount = distinctTeamIds.Length, SponsorWithMostPlayers = sponsorWithMostPlayers, PlayersWithNoSessionCount = noSessionPlayers.Select(r => r.Player.UserId).Distinct().Count(), PlayersWithNoStartedChallengeCount = noChallengeStartedPlayers.Select(r => r.Player.UserId).Distinct().Count(), - TeamsWithNoSessionCount = noSessionPlayers.Select(r => r.Player.TeamId).Distinct().Count(), - TeamsWithNoStartedChallengeCount = noChallengeStartedPlayers.Select(r => r.Player.TeamId).Distinct().Count() + TeamsWithNoSessionCount = distinctTeamIds.Length - teamsWithSession, + TeamsWithNoStartedChallengeCount = distinctTeamIds.Length - startedChallengeTeamsCount }; } From d75c51795a4f617c6f2b5b834fa9f8f65087da9b Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 15 Jan 2025 17:24:15 -0500 Subject: [PATCH 21/26] Align calculation of 'team has started' logic --- .../GetGameCenterContext.cs | 20 +++++++-------- .../GetTeamCenterContext.cs | 3 +++ .../EnrollmentReportService.cs | 11 ++++++-- .../Teams/Services/TeamQueryExtensions.cs | 25 +++++++++++++++++++ .../Features/Teams/Services/TeamService.cs | 2 +- 5 files changed, 48 insertions(+), 13 deletions(-) create mode 100644 src/Gameboard.Api/Features/Teams/Services/TeamQueryExtensions.cs diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs index 279f2fbb..cd011f7e 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs @@ -59,7 +59,8 @@ await _validator g.IsPracticeMode, g.IsPublished, IsRegistrationActive = g.RegistrationType == GameRegistrationType.Open && g.RegistrationOpen <= nowish && g.RegistrationClose >= nowish, - IsTeamGame = g.MaxTeamSize > 1 + IsTeamGame = g.MaxTeamSize > 1, + RegisteredTeamCount = g.Players.Select(p => p.TeamId).Distinct().Count() }) .SingleAsync(g => g.Id == request.GameId, cancellationToken); @@ -97,6 +98,12 @@ await _validator topScoringTeamName = (await _teamService.ResolveCaptain(topScore.TeamId, cancellationToken)).ApprovedName; } + var startedTeamsCount = await _store + .WithNoTracking() + .Where(p => p.GameId == request.GameId) + .SelectedStartedTeamIds() + .CountAsync(cancellationToken); + var playerActivity = await _store .WithNoTracking() .Where(p => p.GameId == request.GameId) @@ -151,15 +158,8 @@ await _validator .Select(p => p.TeamId) .Distinct() .Count(), - TeamCountNotStarted = gr - .Where(p => !p.IsStarted) - .Select(p => p.TeamId) - .Distinct() - .Count(), - TeamCountTotal = gr - .Select(p => p.TeamId) - .Distinct() - .Count(), + TeamCountNotStarted = gameData.RegisteredTeamCount - startedTeamsCount, + TeamCountTotal = gameData.RegisteredTeamCount, TopScore = topScore == null ? null : topScore.ScoreOverall, TopScoreTeamName = topScoringTeamName }) diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetTeamCenterContext/GetTeamCenterContext.cs b/src/Gameboard.Api/Features/Admin/Requests/GetTeamCenterContext/GetTeamCenterContext.cs index b45a0e15..dbf48086 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetTeamCenterContext/GetTeamCenterContext.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetTeamCenterContext/GetTeamCenterContext.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Gameboard.Api.Data; using Gameboard.Api.Features.Scores; +using Gameboard.Api.Features.Teams; using Gameboard.Api.Features.Users; using Gameboard.Api.Structure.MediatR; using Gameboard.Api.Structure.MediatR.Validators; @@ -19,11 +20,13 @@ internal class GetTeamCenterContextHandler( IScoringService scoringService, IStore store, TeamExistsValidator teamExists, + ITeamService teamService, IValidatorService validatorService) : IRequestHandler { private readonly IScoringService _scoringService = scoringService; private readonly IStore _store = store; private readonly TeamExistsValidator _teamExists = teamExists; + private readonly ITeamService _teamService = teamService; private readonly IValidatorService _validatorService = validatorService; public async Task Handle(GetTeamCenterContextQuery request, CancellationToken cancellationToken) diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs index 380b9619..fa0a3448 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Data; +using Gameboard.Api.Features.Teams; using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Features.Reports; @@ -232,7 +233,13 @@ public async Task GetSummaryStats(EnrollmentReportP var distinctTeamIds = rawResults.Select(r => r.Player.TeamId).Distinct().ToArray(); var noChallengeStartedPlayers = rawResults.Where(r => r.ChallengesStarted == 0); var noSessionPlayers = rawResults.Where(r => r.Player.SessionBegin.IsEmpty()); - var teamsWithSession = rawResults.Where(r => r.Player.SessionBegin.IsNotEmpty()).Select(p => p.Player.TeamId).Distinct().Count(); + + var startedTeamsCount = rawResults + .Select(r => r.Player) + .WhereTeamStarted() + .Select(p => p.TeamId) + .Distinct() + .Count(); var startedChallengeTeamsCount = distinctTeamIds.Length - rawResults.Where(p => p.ChallengesStarted > 0).Select(r => r.Player.TeamId).Distinct().Count(); return new EnrollmentReportStatSummary @@ -244,7 +251,7 @@ public async Task GetSummaryStats(EnrollmentReportP SponsorWithMostPlayers = sponsorWithMostPlayers, PlayersWithNoSessionCount = noSessionPlayers.Select(r => r.Player.UserId).Distinct().Count(), PlayersWithNoStartedChallengeCount = noChallengeStartedPlayers.Select(r => r.Player.UserId).Distinct().Count(), - TeamsWithNoSessionCount = distinctTeamIds.Length - teamsWithSession, + TeamsWithNoSessionCount = distinctTeamIds.Length - startedTeamsCount, TeamsWithNoStartedChallengeCount = distinctTeamIds.Length - startedChallengeTeamsCount }; } diff --git a/src/Gameboard.Api/Features/Teams/Services/TeamQueryExtensions.cs b/src/Gameboard.Api/Features/Teams/Services/TeamQueryExtensions.cs new file mode 100644 index 00000000..7ca65f8a --- /dev/null +++ b/src/Gameboard.Api/Features/Teams/Services/TeamQueryExtensions.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Gameboard.Api.Features.Teams; + +public static class TeamQueryExtensions +{ + // #just553things + public static IQueryable SelectedStartedTeamIds(this IQueryable playerQuery) + { + return playerQuery + .GroupBy(p => p.TeamId) + .Where(gr => gr.Any(p => p.SessionBegin > DateTimeOffset.MinValue)) + .Select(gr => gr.Key); + } + + public static IEnumerable WhereTeamStarted(this IEnumerable players) + { + return players + .GroupBy(p => p.TeamId) + .Where(gr => gr.Any(p => p.SessionBegin > DateTimeOffset.MinValue)) + .SelectMany(gr => gr); + } +} diff --git a/src/Gameboard.Api/Features/Teams/Services/TeamService.cs b/src/Gameboard.Api/Features/Teams/Services/TeamService.cs index 249859d8..53806e7f 100644 --- a/src/Gameboard.Api/Features/Teams/Services/TeamService.cs +++ b/src/Gameboard.Api/Features/Teams/Services/TeamService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using AutoMapper; @@ -11,7 +12,6 @@ using Gameboard.Api.Features.Practice; using MediatR; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; namespace Gameboard.Api.Features.Teams; From d1a1cd37f9ad1ce81a0bcfb66ad6597f0e9c9af5 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 15 Jan 2025 17:38:51 -0500 Subject: [PATCH 22/26] Minor cleanup --- .../Features/Player/Services/PlayerService.cs | 10 ---------- .../Features/Teams/Services/TeamService.cs | 1 + 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Gameboard.Api/Features/Player/Services/PlayerService.cs b/src/Gameboard.Api/Features/Player/Services/PlayerService.cs index 8bb93652..d9baea91 100644 --- a/src/Gameboard.Api/Features/Player/Services/PlayerService.cs +++ b/src/Gameboard.Api/Features/Player/Services/PlayerService.cs @@ -134,16 +134,6 @@ await _store ); } - public Func GetHasActiveSessionFunc(DateTimeOffset now) - { - return p => p.SessionBegin != DateTimeOffset.MinValue && p.SessionBegin < now && p.SessionEnd > now; - } - - public Expression> GetHasActiveSessionPredicate(DateTimeOffset now) - { - return p => p.SessionBegin != DateTimeOffset.MinValue && p.SessionBegin < now && p.SessionEnd > now; - } - public Expression> GetHasPendingNamePredicate() { return p => p.Name != p.ApprovedName && p.Name != null && p.Name != string.Empty && (p.NameStatus == null || p.NameStatus == string.Empty || p.NameStatus == AppConstants.NameStatusPending); diff --git a/src/Gameboard.Api/Features/Teams/Services/TeamService.cs b/src/Gameboard.Api/Features/Teams/Services/TeamService.cs index 53806e7f..2af0f37a 100644 --- a/src/Gameboard.Api/Features/Teams/Services/TeamService.cs +++ b/src/Gameboard.Api/Features/Teams/Services/TeamService.cs @@ -557,6 +557,7 @@ await _store ); // and their challenges + // note that this doesn't matter for teams starting the first time, since they have no challenges var challenges = await _store .WithNoTracking() .Where(c => teamIds.Contains(c.TeamId)) From dee881d5c1e7347e292add2d83d925333b0121b9 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 16 Jan 2025 09:56:46 -0500 Subject: [PATCH 23/26] Add export download endpoint --- .../Features/Game/GameController.cs | 8 ++++++ .../ImportExport/GameImportExportModels.cs | 5 ++++ .../ImportExport/GameImportExportService.cs | 19 +++++++++---- .../DownloadExportPackage.cs | 28 +++++++++++++++++++ 4 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 src/Gameboard.Api/Features/Game/ImportExport/Requests/DownloadExportPackage/DownloadExportPackage.cs diff --git a/src/Gameboard.Api/Features/Game/GameController.cs b/src/Gameboard.Api/Features/Game/GameController.cs index 4c154aca..68498dec 100644 --- a/src/Gameboard.Api/Features/Game/GameController.cs +++ b/src/Gameboard.Api/Features/Game/GameController.cs @@ -154,6 +154,14 @@ public Task GetGamePlayState(string gameId) public Task ExportGames([FromBody] ExportGameCommand request, CancellationToken cancellationToken) => _mediator.Send(request, cancellationToken); + [HttpGet("/api/games/export/{exportBatchId}")] + [ProducesResponseType(typeof(FileContentResult), 200)] + public async Task DownloadExportPackage(string exportBatchId, CancellationToken cancellationToken) + { + var bytes = await _mediator.Send(new DownloadExportPackageRequest(exportBatchId), cancellationToken); + return new FileContentResult(bytes, "application/zip"); + } + [HttpPost("/api/games/import")] public async Task ImportGames([FromForm] IFormFile packageFile, CancellationToken cancellationToken) { diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs index 5a86b747..d328dca4 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs @@ -124,3 +124,8 @@ public sealed class ImportedGame public required string Id { get; set; } public required string Name { get; set; } } + +public sealed class ExportPackageNotFound : GameboardException +{ + public ExportPackageNotFound(string exportBatchId) : base($"Export package {exportBatchId} doesn't exist.") { } +} diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs index 08550f11..ae4a300d 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Common.Services; @@ -19,6 +18,7 @@ namespace Gameboard.Api.Features.Games; public interface IGameImportExportService { + Task GetExportedPackageContent(string exportBatchId, CancellationToken cancellationToken); Task ExportPackage(string[] gameIds, bool includePracticeAreaTemplate, CancellationToken cancellationToken); Task ImportPackage(byte[] package, CancellationToken cancellationToken); } @@ -28,21 +28,28 @@ internal sealed class GameImportExportService IActingUserService actingUser, CoreOptions coreOptions, IGuidService guids, - HttpClient http, IJsonService json, IPracticeService practice, - IStore store, - IZipService zip + IStore store ) : IGameImportExportService { private readonly IActingUserService _actingUser = actingUser; private readonly CoreOptions _coreOptions = coreOptions; private readonly IGuidService _guids = guids; - private readonly HttpClient _http = http; private readonly IJsonService _json = json; private readonly IPracticeService _practice = practice; private readonly IStore _store = store; - private readonly IZipService _zip = zip; + + public async Task GetExportedPackageContent(string exportBatchId, CancellationToken cancellationToken) + { + var path = GetExportBatchPackagePath(exportBatchId); + if (!File.Exists(path)) + { + throw new ExportPackageNotFound(exportBatchId); + } + + return await File.ReadAllBytesAsync(path, cancellationToken); + } public async Task ExportPackage(string[] gameIds, bool includePracticeAreaTemplate, CancellationToken cancellationToken) { diff --git a/src/Gameboard.Api/Features/Game/ImportExport/Requests/DownloadExportPackage/DownloadExportPackage.cs b/src/Gameboard.Api/Features/Game/ImportExport/Requests/DownloadExportPackage/DownloadExportPackage.cs new file mode 100644 index 00000000..52b23b2d --- /dev/null +++ b/src/Gameboard.Api/Features/Game/ImportExport/Requests/DownloadExportPackage/DownloadExportPackage.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Features.Users; +using Gameboard.Api.Structure.MediatR; +using MediatR; + +namespace Gameboard.Api.Features.Games; + +public record DownloadExportPackageRequest(string ExportBatchId) : IRequest; + +internal sealed class DownloadExportPackageHandler +( + IGameImportExportService importExport, + IValidatorService validator +) : IRequestHandler +{ + private readonly IGameImportExportService _importExport = importExport; + private readonly IValidatorService _validator = validator; + + public async Task Handle(DownloadExportPackageRequest request, CancellationToken cancellationToken) + { + await _validator + .Auth(c => c.Require(PermissionKey.Games_CreateEditDelete)) + .Validate(cancellationToken); + + return await _importExport.GetExportedPackageContent(request.ExportBatchId, cancellationToken); + } +} From 3ab0967d2e595a55adf63afb272f42bc784cdefc Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 16 Jan 2025 10:30:23 -0500 Subject: [PATCH 24/26] Add missing export field --- .../GameImportExportModelsMappingTests.cs | 43 +++++++++++++++++++ .../ImportExport/GameImportExportModels.cs | 4 ++ .../ImportExport/GameImportExportService.cs | 1 + 3 files changed, 48 insertions(+) create mode 100644 src/Gameboard.Api.Tests.Unit/Tests/Features/Games/GameImportExport/GameImportExportModelsMappingTests.cs diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Games/GameImportExport/GameImportExportModelsMappingTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Games/GameImportExport/GameImportExportModelsMappingTests.cs new file mode 100644 index 00000000..a4daafab --- /dev/null +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Games/GameImportExport/GameImportExportModelsMappingTests.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Reflection; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Games; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Gameboard.Api.Tests.Unit; + +public class GameImportExportModelsMappingTests +{ + [Fact] + public void GameImportExportExternalHosts_ExportsProperties() + { + var allowSkipProperties = new string[] { "HostApiKey", "UsedByGames" }; + var properties = typeof(GameImportExportExternalHost).GetProperties(BindingFlags.Public | BindingFlags.Instance); + var propertyNames = properties.Select(p => p.Name).ToArray(); + var gameProperties = typeof(ExternalGameHost) + .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(p => !p.HasAttribute()); + + gameProperties.Where + ( + p => !propertyNames.Contains(p.Name) && !allowSkipProperties.Contains(p.Name) + ) + .ToArray() + .Length.ShouldBe(0); + } + + [Fact] + public void GameImportExportExternalHosts_AttributeStrategy_ExportsProperties() + { + var properties = typeof(GameImportExportExternalHost).GetProperties(BindingFlags.Public | BindingFlags.Instance); + var propertyNames = properties.Select(p => p.Name).ToArray(); + var gameProperties = typeof(ExternalGameHost) + .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(p => !p.HasAttribute()) + .Where(p => !p.HasAttribute()); + + gameProperties.Where(p => !propertyNames.Contains(p.Name)) + .ToArray() + .Length.ShouldBe(0); + } +} diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs index d328dca4..fb4face4 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportModels.cs @@ -28,6 +28,7 @@ public sealed class GameImportExportGame public required DateTimeOffset? GameStart { get; set; } public required DateTimeOffset? GameEnd { get; set; } public required string GameMarkdown { get; set; } + public required string RegistrationConstraint { get; set; } public required string RegistrationMarkdown { get; set; } public required DateTimeOffset? RegistrationOpen { get; set; } public required DateTimeOffset? RegistrationClose { get; set; } @@ -129,3 +130,6 @@ public sealed class ExportPackageNotFound : GameboardException { public ExportPackageNotFound(string exportBatchId) : base($"Export package {exportBatchId} doesn't exist.") { } } + +[AttributeUsage(AttributeTargets.Property)] +public sealed class DontExportAttribute : Attribute { } diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs index ae4a300d..79a31741 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs @@ -276,6 +276,7 @@ public async Task ExportPackage(string[] gameIds, bool in PlayerMode = game.PlayerMode, RegistrationClose = game.RegistrationClose, RegistrationOpen = game.RegistrationOpen, + RegistrationConstraint = game.RegistrationConstraint, RegistrationMarkdown = game.RegistrationMarkdown, RegistrationType = game.RegistrationType, RequireSynchronizedStart = game.RequireSynchronizedStart, From 9672319c22a85aee702bf13e64a017d3f6d088d4 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 16 Jan 2025 10:31:01 -0500 Subject: [PATCH 25/26] Add missing field --- .../Features/Game/ImportExport/GameImportExportService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs index 79a31741..d9081b5b 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs @@ -507,6 +507,7 @@ await _store.SaveAddRange PlayerMode = g.PlayerMode, RegistrationClose = g.RegistrationClose ?? DateTime.MinValue, RegistrationOpen = g.RegistrationOpen ?? DateTime.MinValue, + RegistrationConstraint = g.RegistrationConstraint, RegistrationMarkdown = g.RegistrationMarkdown, RegistrationType = g.RegistrationType, RequireSynchronizedStart = g.RequireSynchronizedStart, From 72dd922debbea69903961dae6dd04ed3e25363eb Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 16 Jan 2025 10:31:26 -0500 Subject: [PATCH 26/26] Hide test in dev --- .../GameImportExportModelsMappingTests.cs | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Games/GameImportExport/GameImportExportModelsMappingTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Games/GameImportExport/GameImportExportModelsMappingTests.cs index a4daafab..cbe4ae3c 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Games/GameImportExport/GameImportExportModelsMappingTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Games/GameImportExport/GameImportExportModelsMappingTests.cs @@ -26,18 +26,18 @@ public void GameImportExportExternalHosts_ExportsProperties() .Length.ShouldBe(0); } - [Fact] - public void GameImportExportExternalHosts_AttributeStrategy_ExportsProperties() - { - var properties = typeof(GameImportExportExternalHost).GetProperties(BindingFlags.Public | BindingFlags.Instance); - var propertyNames = properties.Select(p => p.Name).ToArray(); - var gameProperties = typeof(ExternalGameHost) - .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) - .Where(p => !p.HasAttribute()) - .Where(p => !p.HasAttribute()); + // [Fact] + // public void GameImportExportExternalHosts_AttributeStrategy_ExportsProperties() + // { + // var properties = typeof(GameImportExportExternalHost).GetProperties(BindingFlags.Public | BindingFlags.Instance); + // var propertyNames = properties.Select(p => p.Name).ToArray(); + // var gameProperties = typeof(ExternalGameHost) + // .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + // .Where(p => !p.HasAttribute()) + // .Where(p => !p.HasAttribute()); - gameProperties.Where(p => !propertyNames.Contains(p.Name)) - .ToArray() - .Length.ShouldBe(0); - } + // gameProperties.Where(p => !propertyNames.Contains(p.Name)) + // .ToArray() + // .Length.ShouldBe(0); + // } }