From 0c95d198dac18bf063855ac29d455d9ee0a02f57 Mon Sep 17 00:00:00 2001 From: Ben Stein <115497763+sei-bstein@users.noreply.github.com> Date: Thu, 7 Sep 2023 13:48:36 -0400 Subject: [PATCH] v3.10.1-beta0 (#245) * Initial groundwork for reports rewrite. * Add reports service and fix service registration tech debt. * More iteration on reports revamp * Checkpoint for reports revamp * More reports backend * Use challenge specs instead of challenges for reporting. * More reports plumbing * More report plumbing * Add support report to db * Work on support report * Polishing support report * Add reports and initial unit tests * More work on reports * Refactor - move SimpleEntity into Gameboard.Api.Features.Common. * Don't bootstrap JobsService in integration test env * Address an issue where Gameboard was incorrectly evaluating whether a challenge has a deployed gamespace after destroy. Resolves #182. * Add registration date range to challenges report * Fix player report bugs * Server-side hardening for illegal mime type upload. Removed bare repositories. Updated recommended extensions. * Add improved file upload validation. Add editor config to suppress private member warnings. * Update unit tests * Remove server-side html escaping of user input (handled client side). * Resolved an issue that prevented assignees from being display in the support ticket list. * Remove unnecessary dependency injection from the app hub. * Fix build error in ticket service * Fix merge issue from main * Resolve changes from the merge * Initial version of enrollment report and refactor * Add minimal unit tests for enrollment report * Additional refinements to enrollment report * Remove gamebrain schema generator (no longer needed) * Add WhenCreated to player table. * Added export for enrollment report. * Remove unnecessary pragma warning for new C# tools, unbreak player service tests. * More work on enrollment report * Clarify comments on Enrollment report query * Fix line chart for enrollment, update validation strategies, add new query extension to deal with nullish dates. * Page to default values when illegal paging is requested. Add Challenges Attempted to enrollment csv export. * Refactor of practice backend * Add new user fields for created on, last login date, and login count. Pruned unused code and store implementations that weren't really doing much. Resolves #196. * Rename reports folder * Add tests for query extensions * Started work on practice report * Code cleanup * Fix failing integration tests * Fix a bug with date processing for the enrollment report * Work on practice mode report * Add metadata query * Improvements to practice report * Update practice report models * Add player performance tab to practice mode report * Wrapping up a draft of practice mode * Fix integration test issues * Direct dotnet restore command in Github Action to src * Update GH actions config for correct solutions file config. * Delete extra sln file * Fix stackoverflow in captain resolution * Improvements to support report and solution tooling. * Improvements to support report filters * Code cleanup in engine service * Automatically sort string filters for reports * fixed an issue with the sponsor filter on the enrollment report * Guard against orphaned specIds in the practice mode report * Add games filter to enrollment service. * Guard against unexpected nulls in practice mode report * Guard against unexpected sponsor nulls in practice mode report * Fix criteria processing for practice mode report * PlayerMode to challenges - Add "PlayerMode" to challenges. Resolves #218 - Sort support service by created date by default. * Query practice mode report based on the challenge-level PlayerMode flag rather than the game-level one. * Code cleanup in game engine service * Code cleanup * Fix PlayerService constructor signature to remove unused injections * Code cleanup * Align parameter names * Add stat summary for enrollment report * Code cleanup * Fix build * Guard against null sponsors in enrollment report. Hide practice games on home screen. * Present percentile for Practice Mode report as a 0-100 value (up from 0-1). * Add challenge result to practice mode report * Optimize sponsor count stat for the enrollment report * Code cleanup * Remove unused property of game model * Improvements to practice mode workflow (allow detection of active practice session * Change model shape for user active practice challenge. * Enhance information available for an active practice challenge to support new launch path. * Improvement to practice mode/playcomponent * More support for auto-deploy of practice challenges/ new 'play' component. * More work on autolaunch/play component * Finish autolaunch. Resolves #227 * Fix a bug in practice report where player count per sponsor was not computed correctly. * Fix test game engine service interface sig * Fix failing test * Corrected resolution of json options for integration tests. * Code cleanup * Allow API key authentication to resolve grader keys as well as standard user api keys. - Moved GetUserFromApiKey to service level - Added tests to verify resolution of user and grader keys - Resolved an issue where integration tests were inadvertently depending on our internal test Gamebrain instance * set default solution path * Additional test for grader/apikey authentication. * Revert "Additional test for grader/apikey authentication." This reverts commit b75727d2500b081af642188ebfe0692292a2883a. * Additional test for grader api key resolution. * Added revised grader key authentication and modified _Controller to represent an authenticated grader key separately from an authenticated user. * Remove incorrect tests * Update game engine mapper to reflect existing mapping in ChallengeMaps between Vm count and IsActive * Add practice admin stuff * Improvements to practice admin * Set default topo timeout to 300 sec (up from 100). * Finished up practice certificates * Fixed broken constructor sig intests * Fixed hours/minutes display for practice certificates and added supporting unit tests. * Always archive team challenges on reset * Removed test (the rule is no longer true) * Playing with various pdf rendering options? * Various code cleanup * Do server-side rendering of certificates in PNG format. * Update tests * Finish server side generation of html for both practice and competitive. * Fix test signatures * Make published certificate collections on User default to empty lists * Bug fixes for various practice mode session end things. * Test fixes * Bug fixes to practice mode. * Fix test types * Update dockerfile for chromium install. * Update Dockerfile - move chromium install to production multistage. * Accommodate docker execution of chromium with --no-sandbox * Disable use of dev shared memory for chromium image generation. * Request full challenge doc from Topomojo during spec sync. * Fix for challenge doc text in practice mode * Try wkhtmltopdf as alternate image generation tool. * Use correct version of wkhtmltopdf binary * Remove stray pdf * Decrease quality of images rendered by wkhtmltoimage * Set wkhtmltoimage quality to 30 * Rename player-facing 'Practice Mode' to 'Practice Area' * Fix practice area report routes * Fix count issue on the player prac/competitive report * Fix a count issue on the 'by challenge' grouping of the practice area report. * allow authenticated users to retrieve practice mode settings * Fix grouping issue with per-mode practice area report * Code cleanup * Fix typo in Enrollment report desc * Correct calculation of users per sponsor in Enrollment report. * Change roles who can access reporting. * Improve summary info on report cards to better reflect the corresponding reports. * - corrected an issue that caused practice challenges to appear as though they were created a million years ago in challenge admin. - The search box on Challenge Admin now automatically trims whitespace. (Resolves #247) * Resolved an issue which caused the enrollment report to fail to report challenges assigned to games which are in practice mode (even if the challenge was played competitively. * Fix failing unit test and add a new one for prac/comp challenge/game fiasco on Enrollment report. * Resolved an issue that prevented non-registrars from extending/resetting their practice sessions. --- .../ChallengeBonusControllerManualTests.cs | 4 +- .../Features/Player/PlayerServiceTests.cs | 9 ++-- .../Tests/Features/Player/TeamServiceTests.cs | 10 +++- .../Reports/EnrollmentReportServiceTests.cs | 48 ++++++++++++++++++- .../Data/Store/Store[TEntity].cs | 5 +- .../Features/Challenge/ChallengeService.cs | 5 +- .../GameEngine/Services/GameEngineService.cs | 3 +- .../Features/Player/PlayerController.cs | 3 +- .../Features/Player/PlayerService.cs | 6 +-- .../Features/Practice/PracticeController.cs | 2 - .../ChallengesReportExportQuery.cs | 14 ++++-- .../ChallengesReport/ChallengesReportQuery.cs | 14 ++++-- .../EnrollmentReport/EnrollmentReport.cs | 11 ++++- .../EnrollmentReportExport.cs | 9 +++- .../EnrollmentReportLineChart.cs | 11 ++--- .../EnrollmentReportService.cs | 9 ++-- .../Queries/GetMetaData/GetMetaData.cs | 11 ++--- .../PlayersReport/PlayerReportQuery.cs | 8 +++- .../PlayersReport/PlayersReportExportQuery.cs | 14 ++++-- .../PracticeMode/PracticeModeReport.cs | 9 +++- .../PracticeModeReportCsvExport.cs | 4 +- .../Queries/SupportReport/SupportReport.cs | 4 +- .../Features/Reports/ReportsController.cs | 10 ++-- .../Reports/ReportsExportController.cs | 6 +-- ...sValidator.cs => ReportsQueryValidator.cs} | 4 +- .../Features/Reports/ReportsService.cs | 32 ++++++------- .../Features/Teams/TeamService.cs | 24 ++++++++++ 27 files changed, 198 insertions(+), 91 deletions(-) rename src/Gameboard.Api/Features/Reports/{ReportsValidator.cs => ReportsQueryValidator.cs} (80%) diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/ChallengeBonuses/ChallengeBonusControllerManualTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/ChallengeBonuses/ChallengeBonusControllerManualTests.cs index 245da311..df84c6a6 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/ChallengeBonuses/ChallengeBonusControllerManualTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/ChallengeBonuses/ChallengeBonusControllerManualTests.cs @@ -23,7 +23,7 @@ await _testContext.WithDataState(state => state.AddUser(u => { u.Id = userId; - u.Role = Api.UserRole.Support; + u.Role = UserRole.Support; }); state.AddChallenge(c => @@ -41,7 +41,7 @@ await _testContext.WithDataState(state => var httpClient = _testContext.CreateHttpClientWithActingUser(u => { u.Id = userId; - u.Role = Api.UserRole.Support; + u.Role = UserRole.Support; }); // when diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs index 561eda86..332e95f8 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs @@ -26,8 +26,7 @@ private PlayerServiceTestable( INowService now, ITeamService teamService, IMapper mapper, - IMemoryCache localCache, - GameEngineService gameEngine) : base + IMemoryCache localCache) : base ( challengeService, coreOptions, @@ -41,8 +40,7 @@ private PlayerServiceTestable( practiceService, teamService, mapper, - localCache, - gameEngine + localCache ) { } @@ -78,8 +76,7 @@ internal static PlayerService GetTestable( practiceService ?? A.Fake(), teamService ?? A.Fake(), mapper ?? A.Fake(), - localCache ?? A.Fake(), - gameEngine ?? A.Fake() + localCache ?? A.Fake() ); } } diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/TeamServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/TeamServiceTests.cs index ef1909ef..a859061b 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/TeamServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/TeamServiceTests.cs @@ -3,6 +3,7 @@ using Gameboard.Api.Features.Teams; using Gameboard.Api.Hubs; using Gameboard.Api.Services; +using Microsoft.Extensions.Caching.Memory; namespace Gameboard.Api.Tests.Unit; @@ -14,7 +15,14 @@ public async Task Standings_WhenGameIdIsEmpty_ReturnsEmptyArray() // arrange var playerStore = A.Fake(); var mapper = A.Fake(); - var sut = new TeamService(A.Fake(), A.Fake(), A.Fake(), playerStore); + var sut = new TeamService + ( + A.Fake(), + A.Fake(), + A.Fake(), + A.Fake(), + playerStore + ); var players = new Data.Player[] { diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Reports/EnrollmentReportServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Reports/EnrollmentReportServiceTests.cs index 67272aed..6f81b6f7 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Reports/EnrollmentReportServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Reports/EnrollmentReportServiceTests.cs @@ -1,6 +1,5 @@ using Gameboard.Api.Data; using Gameboard.Api.Features.Reports; -using Gameboard.Api.Services; namespace Gameboard.Api.Tests.Unit; @@ -23,6 +22,7 @@ public async Task GetResults_WithOnePlayerAndChallenge_ReportsCompleteSolve(IFix var challenge = fixture.Create(); challenge.Points = 50; challenge.Score = 50; + challenge.PlayerMode = PlayerMode.Competition; var player = fixture.Create(); player.Challenges = new Data.Challenge[] { challenge }; @@ -75,6 +75,7 @@ public async Task GetResults_WithOneTeamRecord_ReportsExpectedValues(IFixture fi var challenge = fixture.Create(); challenge.Points = 50; challenge.Score = 50; + challenge.PlayerMode = PlayerMode.Competition; var player1 = fixture.Create(); player1.Challenges = new Data.Challenge[] { challenge }; @@ -83,6 +84,7 @@ public async Task GetResults_WithOneTeamRecord_ReportsExpectedValues(IFixture fi player1.Role = PlayerRole.Manager; var player2 = fixture.Create(); + player2.Challenges = new Data.Challenge[] { challenge }; player2.Game = player1.Game; player2.GameId = player1.GameId; player2.TeamId = player1.TeamId; @@ -109,4 +111,48 @@ public async Task GetResults_WithOneTeamRecord_ReportsExpectedValues(IFixture fi results.Records.First().Team.Sponsors.Count().ShouldBe(2); results.Records.SelectMany(r => r.Challenges).DistinctBy(c => c.SpecId).Count().ShouldBe(1); } + + [Theory, GameboardAutoData] + public async Task GetResults_WithGameInPracAndChallengeInComp_ReportsOneResult(IFixture fixture) + { + // given + var sponsors = new List + { + new Data.Sponsor + { + Id = "good-people", + Name = "The Good People", + Logo = "good-people.jpg" + } + }.BuildMock(); + + var challenge = fixture.Create(); + challenge.Points = 50; + challenge.Score = 50; + challenge.PlayerMode = PlayerMode.Competition; + + var player = fixture.Create(); + player.Challenges = new Data.Challenge[] { challenge }; + player.Game.PlayerMode = PlayerMode.Practice; + player.Sponsor = sponsors.First().Logo; + + var players = new List { player }.BuildMock(); + + var reportsService = A.Fake(); + A.CallTo(() => reportsService.ParseMultiSelectCriteria(string.Empty)) + .WithAnyArguments() + .Returns(Array.Empty()); + + var store = A.Fake(); + A.CallTo(() => store.List(false)).Returns(sponsors); + A.CallTo(() => store.List(false)).Returns(players); + + var sut = new EnrollmentReportService(reportsService, store); + + // when + var results = await sut.GetRawResults(new EnrollmentReportParameters(), CancellationToken.None); + + // then + results.Records.Count().ShouldBe(1); + } } diff --git a/src/Gameboard.Api/Data/Store/Store[TEntity].cs b/src/Gameboard.Api/Data/Store/Store[TEntity].cs index 5ce4416c..d11f9eda 100644 --- a/src/Gameboard.Api/Data/Store/Store[TEntity].cs +++ b/src/Gameboard.Api/Data/Store/Store[TEntity].cs @@ -38,12 +38,11 @@ public virtual IQueryable List(string term = null) } public IQueryable ListWithNoTracking() - => DbContext. - Set() + => DbContext + .Set() .AsNoTracking() .AsQueryable(); - public virtual async Task Create(TEntity entity) { if (string.IsNullOrWhiteSpace(entity.Id)) diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeService.cs b/src/Gameboard.Api/Features/Challenge/ChallengeService.cs index 11becc96..845e49cf 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeService.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeService.cs @@ -178,7 +178,7 @@ public async Task UserIsTeamPlayer(string id, string subjectId) public async Task List(SearchFilter model = null) { - var q = Store.List(model?.Term ?? null); + var q = Store.List(model?.Term?.Trim() ?? null); // filter out challenge records with no state used to give starting score to player q = q.Where(p => p.Name != "_initialscore_" && p.State != null); @@ -638,8 +638,9 @@ int variant challenge.State = _jsonService.Serialize(state); challenge.StartTime = state.StartTime; challenge.EndTime = state.EndTime; + challenge.LastSyncTime = _now.Get(); - challenge.Events.Add(new Data.ChallengeEvent + challenge.Events.Add(new ChallengeEvent { Id = _guids.GetGuid(), UserId = actorUserId, diff --git a/src/Gameboard.Api/Features/GameEngine/Services/GameEngineService.cs b/src/Gameboard.Api/Features/GameEngine/Services/GameEngineService.cs index a2ad9c26..6d3b73b1 100644 --- a/src/Gameboard.Api/Features/GameEngine/Services/GameEngineService.cs +++ b/src/Gameboard.Api/Features/GameEngine/Services/GameEngineService.cs @@ -66,8 +66,7 @@ public async Task RegisterGamespace(GameEngineChallengeRegi { Players = new RegistrationPlayer[] { - new RegistrationPlayer - { + new() { SubjectId = registration.Player.TeamId, SubjectName = registration.Player.ApprovedName } diff --git a/src/Gameboard.Api/Features/Player/PlayerController.cs b/src/Gameboard.Api/Features/Player/PlayerController.cs index 0e946a33..68d0b389 100644 --- a/src/Gameboard.Api/Features/Player/PlayerController.cs +++ b/src/Gameboard.Api/Features/Player/PlayerController.cs @@ -3,7 +3,6 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using AutoMapper; @@ -143,7 +142,7 @@ public async Task UpdateSession([FromBody] SessionChangeRequest model, Cancellat AuthorizeAny( () => Actor.IsRegistrar, - () => IsSelf(model.TeamId).Result + () => TeamService.IsOnTeam(model.TeamId, Actor.Id).Result ); await PlayerService.AdjustSessionEnd(model, Actor, cancellationToken); diff --git a/src/Gameboard.Api/Features/Player/PlayerService.cs b/src/Gameboard.Api/Features/Player/PlayerService.cs index 2b366a59..956ac24f 100644 --- a/src/Gameboard.Api/Features/Player/PlayerService.cs +++ b/src/Gameboard.Api/Features/Player/PlayerService.cs @@ -9,7 +9,6 @@ using AutoMapper; using Gameboard.Api.Data; using Gameboard.Api.Data.Abstractions; -using Gameboard.Api.Features.GameEngine; using Gameboard.Api.Features.Games; using Gameboard.Api.Features.Player; using Gameboard.Api.Features.Practice; @@ -37,7 +36,6 @@ public class PlayerService ITeamService TeamService { get; } IMapper Mapper { get; } IMemoryCache LocalCache { get; } - IGameEngineService GameEngine { get; } public PlayerService( ChallengeService challengeService, @@ -52,8 +50,7 @@ public PlayerService( IPracticeService practiceService, ITeamService teamService, IMapper mapper, - IMemoryCache localCache, - IGameEngineService gameEngine + IMemoryCache localCache ) { ChallengeService = challengeService; @@ -69,7 +66,6 @@ IGameEngineService gameEngine TeamService = teamService; Mapper = mapper; LocalCache = localCache; - GameEngine = gameEngine; } public async Task Enroll(NewPlayer model, User actor, CancellationToken cancellationToken) diff --git a/src/Gameboard.Api/Features/Practice/PracticeController.cs b/src/Gameboard.Api/Features/Practice/PracticeController.cs index 48984e3c..504097c1 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeController.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeController.cs @@ -1,13 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Common; using Gameboard.Api.Common.Services; using Gameboard.Api.Data; using Gameboard.Api.Services; -using Gameboard.Api.Structure; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportExportQuery.cs b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportExportQuery.cs index 060acdfa..6520a780 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportExportQuery.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportExportQuery.cs @@ -6,21 +6,29 @@ namespace Gameboard.Api.Features.Reports; -public record ChallengesReportExportQuery(GetChallengesReportQueryArgs Parameters) : IRequest>; +public record ChallengesReportExportQuery(GetChallengesReportQueryArgs Parameters, User ActingUser) : IRequest>, IReportQuery; -public class ChallengesReportExportQueryHandler : IRequestHandler> +internal class ChallengesReportExportQueryHandler : IRequestHandler> { private readonly IMapper _mapper; private readonly IReportsService _reportsService; + private readonly ReportsQueryValidator _reportsQueryValidator; - public ChallengesReportExportQueryHandler(IMapper mapper, IReportsService reportsService) + public ChallengesReportExportQueryHandler + ( + IMapper mapper, + IReportsService reportsService, + ReportsQueryValidator reportsQueryValidator + ) { _mapper = mapper; _reportsService = reportsService; + _reportsQueryValidator = reportsQueryValidator; } public async Task> Handle(ChallengesReportExportQuery request, CancellationToken cancellationToken) { + await _reportsQueryValidator.Validate(request); var results = await _reportsService.GetChallengesReportRecords(request.Parameters); return _mapper.Map>(results); } diff --git a/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportQuery.cs b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportQuery.cs index 50d024dd..694ec6bd 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportQuery.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportQuery.cs @@ -5,21 +5,29 @@ namespace Gameboard.Api.Features.Reports; -public record ChallengesReportQuery(GetChallengesReportQueryArgs Args) : IRequest>; +public record ChallengesReportQuery(GetChallengesReportQueryArgs Args, User ActingUser) : IRequest>, IReportQuery; -public class ChallengeReportQueryHandler : IRequestHandler> +internal class ChallengeReportQueryHandler : IRequestHandler> { private readonly INowService _now; private readonly IReportsService _reportsService; + private readonly ReportsQueryValidator _reportsQueryValidator; - public ChallengeReportQueryHandler(INowService now, IReportsService reportsService) + public ChallengeReportQueryHandler + ( + INowService now, + IReportsService reportsService, + ReportsQueryValidator reportsQueryValidator + ) { _now = now; _reportsService = reportsService; + _reportsQueryValidator = reportsQueryValidator; } public async Task> Handle(ChallengesReportQuery request, CancellationToken cancellationToken) { + await _reportsQueryValidator.Validate(request); var results = await _reportsService.GetChallengesReportRecords(request.Args); return new ReportResults diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReport.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReport.cs index 3e4ce9f4..0823a5bf 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReport.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReport.cs @@ -6,28 +6,35 @@ namespace Gameboard.Api.Features.Reports; -public record EnrollmentReportQuery(EnrollmentReportParameters Parameters, PagingArgs PagingArgs) : IRequest>; +public record EnrollmentReportQuery(EnrollmentReportParameters Parameters, PagingArgs PagingArgs, User ActingUser) : IRequest>, IReportQuery; internal class EnrollmentReportQueryHandler : IRequestHandler> { private readonly IEnrollmentReportService _enrollmentReportService; private readonly INowService _now; private readonly IPagingService _pagingService; + private readonly ReportsQueryValidator _reportsQueryValidator; public EnrollmentReportQueryHandler ( IEnrollmentReportService enrollmentReportService, INowService now, - IPagingService pagingService + IPagingService pagingService, + ReportsQueryValidator reportsQueryValidator ) { _enrollmentReportService = enrollmentReportService; _now = now; _pagingService = pagingService; + _reportsQueryValidator = reportsQueryValidator; } public async Task> Handle(EnrollmentReportQuery request, CancellationToken cancellationToken) { + // validate + await _reportsQueryValidator.Validate(request); + + // pull and page results var rawResults = await _enrollmentReportService.GetRawResults(request.Parameters, cancellationToken); var paged = _pagingService.Page(rawResults.Records, request.PagingArgs); diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportExport.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportExport.cs index 4ff57caa..b0b660f8 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportExport.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportExport.cs @@ -7,19 +7,24 @@ namespace Gameboard.Api.Features.Reports; -public record EnrollmentReportExportQuery(EnrollmentReportParameters Parameters) : IRequest>; +public record EnrollmentReportExportQuery(EnrollmentReportParameters Parameters, User ActingUser) : IRequest>, IReportQuery; internal class EnrollmentReportExportHandler : IRequestHandler> { private readonly IEnrollmentReportService _enrollmentReportService; + private readonly ReportsQueryValidator _reportsQueryValidator; - public EnrollmentReportExportHandler(IEnrollmentReportService enrollmentReportService) + public EnrollmentReportExportHandler(IEnrollmentReportService enrollmentReportService, ReportsQueryValidator reportsQueryValidator) { _enrollmentReportService = enrollmentReportService; + _reportsQueryValidator = reportsQueryValidator; } public async Task> Handle(EnrollmentReportExportQuery request, CancellationToken cancellationToken) { + // validate + await _reportsQueryValidator.Validate(request); + // ignore paging parameters - for file export, we don't page var results = await _enrollmentReportService.GetRawResults(request.Parameters, cancellationToken); diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportLineChart.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportLineChart.cs index bc0f8f99..4d872475 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportLineChart.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportLineChart.cs @@ -11,31 +11,30 @@ namespace Gameboard.Api.Features.Reports; -public record EnrollmentReportLineChartQuery(EnrollmentReportParameters Parameters) : IRequest>; +public record EnrollmentReportLineChartQuery(EnrollmentReportParameters Parameters, User ActingUser) : IRequest>, IReportQuery; internal class EnrollmentReportLineChartHandler : IRequestHandler> { - private readonly UserRoleAuthorizer _authorizer; private readonly IEnrollmentReportService _reportService; + private readonly ReportsQueryValidator _reportsQueryValidator; private readonly EnrollmentReportValidator _validator; public EnrollmentReportLineChartHandler ( - UserRoleAuthorizer authorizer, IEnrollmentReportService reportService, + ReportsQueryValidator reportsQueryValidator, EnrollmentReportValidator validator ) { - _authorizer = authorizer; _reportService = reportService; + _reportsQueryValidator = reportsQueryValidator; _validator = validator; } public async Task> Handle(EnrollmentReportLineChartQuery request, CancellationToken cancellationToken) { // authorize/validate - _authorizer.AllowedRoles = new UserRole[] { UserRole.Admin, UserRole.Director, UserRole.Support }; - _authorizer.Authorize(); + await _reportsQueryValidator.Validate(request); await _validator.Validate(request.Parameters); // pull base query but select only what we need diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs index 836bf23e..a0cad784 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs @@ -42,14 +42,13 @@ IStore store DateTimeOffset? enrollDateEnd = parameters.EnrollDateEnd.HasValue ? parameters.EnrollDateEnd.Value.ToUniversalTime() : null; // the fundamental unit of reporting here is really the player record (an "enrollment"), so resolve enrollments that - // meet the filter criteria + // meet the filter criteria (and have at least one challenge completed in competitive mode) var query = _store .List() .Include(p => p.Game) + .Include(p => p.Challenges.Where(c => c.PlayerMode == PlayerMode.Competition)) .Include(p => p.User) - .Include(p => p.Challenges) - .ThenInclude(c => c.AwardedManualBonuses) - .Where(p => p.Game.PlayerMode == PlayerMode.Competition); + .Where(p => p.Challenges.Any(c => c.PlayerMode == PlayerMode.Competition)); if (enrollDateStart != null) query = query @@ -129,7 +128,7 @@ public async Task GetRawResults(EnrollmentReportPara var teamAndChallengeData = await _store .List() - .Include(p => p.Challenges) + .Include(p => p.Challenges.Where(c => c.PlayerMode == PlayerMode.Competition)) .Include(p => p.Game) .Include(p => p.User) .Where(p => teamIds.Contains(p.TeamId)) diff --git a/src/Gameboard.Api/Features/Reports/Queries/GetMetaData/GetMetaData.cs b/src/Gameboard.Api/Features/Reports/Queries/GetMetaData/GetMetaData.cs index d803ce80..41431057 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/GetMetaData/GetMetaData.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/GetMetaData/GetMetaData.cs @@ -7,26 +7,25 @@ namespace Gameboard.Api.Features.Reports; -public record GetMetaDataQuery(string ReportKey) : IRequest; +public record GetMetaDataQuery(string ReportKey, User ActingUser) : IRequest, IReportQuery; internal class GetMetaDataHandler : IRequestHandler { private readonly INowService _now; private readonly IReportsService _reportsService; - private readonly UserRoleAuthorizer _roleAuthorizer; + private readonly ReportsQueryValidator _reportsQueryValidator; public GetMetaDataHandler ( INowService now, IReportsService reportsService, - UserRoleAuthorizer roleAuthorizer + ReportsQueryValidator reportsQueryValidator ) - => (_now, _reportsService, _roleAuthorizer) = (now, reportsService, roleAuthorizer); + => (_now, _reportsService, _reportsQueryValidator) = (now, reportsService, reportsQueryValidator); public async Task Handle(GetMetaDataQuery request, CancellationToken cancellationToken) { - _roleAuthorizer.AllowedRoles = new UserRole[] { UserRole.Admin }; - _roleAuthorizer.Authorize(); + await _reportsQueryValidator.Validate(request); var reports = await _reportsService.List(); var report = reports.FirstOrDefault(r => r.Key == request.ReportKey) ?? throw new ResourceNotFound(request.ReportKey); diff --git a/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayerReportQuery.cs b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayerReportQuery.cs index 2dfad930..18fc9fd0 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayerReportQuery.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayerReportQuery.cs @@ -11,13 +11,14 @@ namespace Gameboard.Api.Features.Reports; -public record PlayersReportQuery(PlayersReportQueryParameters Parameters) : IRequest>; +public record PlayersReportQuery(PlayersReportQueryParameters Parameters, User ActingUser) : IRequest>, IReportQuery; internal class GetPlayersReportQueryHandler : IRequestHandler> { private readonly IMapper _mapper; private readonly INowService _nowService; private readonly IPlayersReportService _reportService; + private readonly ReportsQueryValidator _reportsQueryValidator; private readonly IStore _sponsorStore; public GetPlayersReportQueryHandler @@ -25,17 +26,22 @@ public GetPlayersReportQueryHandler IMapper mapper, INowService now, IPlayersReportService reportService, + ReportsQueryValidator reportsQueryValidator, IStore sponsorStore ) { _mapper = mapper; _nowService = now; + _reportsQueryValidator = reportsQueryValidator; _reportService = reportService; _sponsorStore = sponsorStore; } public async Task> Handle(PlayersReportQuery request, CancellationToken cancellationToken) { + // validate/authorize + await _reportsQueryValidator.Validate(request); + var query = _reportService.GetPlayersReportBaseQuery(request.Parameters); return await TransformQueryToResults(query); } diff --git a/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportExportQuery.cs b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportExportQuery.cs index 0d45a03e..e2931f7c 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportExportQuery.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportExportQuery.cs @@ -7,19 +7,25 @@ namespace Gameboard.Api.Features.Reports; -public record PlayersReportExportQuery(PlayersReportQueryParameters Parameters) : IRequest>; +public record PlayersReportExportQuery(PlayersReportQueryParameters Parameters, User ActingUser) : IRequest>, IReportQuery; -public class PlayersReportExportHandler : IRequestHandler> +internal class PlayersReportExportHandler : IRequestHandler> { + private readonly ReportsQueryValidator _reportsQueryValidator; private readonly IPlayersReportService _reportsService; - public PlayersReportExportHandler(IPlayersReportService reportsService) + public PlayersReportExportHandler(ReportsQueryValidator reportQueryValidator, IPlayersReportService reportsService) { + _reportsQueryValidator = reportQueryValidator; _reportsService = reportsService; } public async Task> Handle(PlayersReportExportQuery request, CancellationToken cancellationToken) { + // validate/authorize + await _reportsQueryValidator.Validate(request); + + // base data var query = _reportsService.GetPlayersReportBaseQuery(request.Parameters); return await query.Select(p => new PlayersReportExportRecord @@ -36,6 +42,6 @@ public async Task> Handle(PlayersReportEx PlayerName = p.ApprovedName, MaxPossibleScore = p.Game.Specs.Sum(s => s.Points), Score = p.Score - }).ToArrayAsync(); + }).ToArrayAsync(cancellationToken); } } diff --git a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReport.cs b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReport.cs index d64db580..089aae77 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReport.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReport.cs @@ -6,22 +6,27 @@ namespace Gameboard.Api.Features.Reports; -public record PracticeModeReportQuery(PracticeModeReportParameters Parameters, User ActingUser, PagingArgs PagingArgs) : IRequest>; +public record PracticeModeReportQuery(PracticeModeReportParameters Parameters, User ActingUser, PagingArgs PagingArgs) : IRequest>, IReportQuery; internal class PracticeModeReportHandler : IRequestHandler> { private readonly IPracticeModeReportService _practiceModeReportService; + private readonly ReportsQueryValidator _reportsQueryValidator; private readonly IReportsService _reportsService; public PracticeModeReportHandler ( IPracticeModeReportService practiceModeReportService, + ReportsQueryValidator reportsQueryValidator, IReportsService reportsService ) - => (_practiceModeReportService, _reportsService) = (practiceModeReportService, reportsService); + => (_practiceModeReportService, _reportsQueryValidator, _reportsService) = (practiceModeReportService, reportsQueryValidator, reportsService); public async Task> Handle(PracticeModeReportQuery request, CancellationToken cancellationToken) { + // validate access for all reports + await _reportsQueryValidator.Validate(request); + if (request.Parameters.Grouping == PracticeModeReportGrouping.Challenge) { var results = await _practiceModeReportService.GetResultsByChallenge(request.Parameters, cancellationToken); diff --git a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportCsvExport.cs b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportCsvExport.cs index c0724b0a..184a2a23 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportCsvExport.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportCsvExport.cs @@ -10,9 +10,9 @@ public record PracticeModeReportCsvExportQuery(PracticeModeReportParameters Para internal class PracticeModeReportCsvExportHandler : IRequestHandler> { private readonly IPracticeModeReportService _practiceModeReportService; - private readonly ReportsQueryValidator _validator; + private readonly ReportsQueryValidator _validator; - public PracticeModeReportCsvExportHandler(IPracticeModeReportService practiceModeReportService, ReportsQueryValidator validator) + public PracticeModeReportCsvExportHandler(IPracticeModeReportService practiceModeReportService, ReportsQueryValidator validator) { _practiceModeReportService = practiceModeReportService; _validator = validator; diff --git a/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReport.cs b/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReport.cs index 4c051d5e..ee09d43e 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReport.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReport.cs @@ -11,13 +11,13 @@ internal class SupportReportQueryHandler : IRequestHandler _validator; + private readonly ReportsQueryValidator _validator; public SupportReportQueryHandler ( IReportsService reportsService, ISupportReportService service, - ReportsQueryValidator validator + ReportsQueryValidator validator ) { _reportsService = reportsService; diff --git a/src/Gameboard.Api/Features/Reports/ReportsController.cs b/src/Gameboard.Api/Features/Reports/ReportsController.cs index dda391f6..749e24d8 100644 --- a/src/Gameboard.Api/Features/Reports/ReportsController.cs +++ b/src/Gameboard.Api/Features/Reports/ReportsController.cs @@ -35,15 +35,15 @@ public async Task> List() [HttpGet("challenges-report")] public Task> GetChallengeReport([FromQuery] GetChallengesReportQueryArgs args) - => _mediator.Send(new ChallengesReportQuery(args)); + => _mediator.Send(new ChallengesReportQuery(args, _actingUser)); [HttpGet("enrollment")] public Task> GetEnrollmentReport([FromQuery] EnrollmentReportParameters parameters, [FromQuery] PagingArgs paging) - => _mediator.Send(new EnrollmentReportQuery(parameters, paging)); + => _mediator.Send(new EnrollmentReportQuery(parameters, paging, _actingUser)); [HttpGet("enrollment/trend")] public Task> GetEnrollmentReportLineChart([FromQuery] EnrollmentReportParameters parameters) - => _mediator.Send(new EnrollmentReportLineChartQuery(parameters)); + => _mediator.Send(new EnrollmentReportLineChartQuery(parameters, _actingUser)); [HttpGet("practice-area")] public async Task> GetPracticeModeReport([FromQuery] PracticeModeReportParameters parameters, [FromQuery] PagingArgs paging) @@ -55,7 +55,7 @@ public async Task GetPracticeModeReportPlay [HttpGet("players-report")] public async Task> GetPlayersReport([FromQuery] PlayersReportQueryParameters reportParams) - => await _mediator.Send(new PlayersReportQuery(reportParams)); + => await _mediator.Send(new PlayersReportQuery(reportParams, _actingUser)); [HttpGet("support")] public async Task> GetSupportReport([FromQuery] SupportReportParameters reportParams, [FromQuery] PagingArgs pagingArgs) @@ -63,7 +63,7 @@ public async Task> GetSupportReport([FromQuer [HttpGet("metaData")] public async Task GetReportMetaData([FromQuery] string reportKey) - => await _mediator.Send(new GetMetaDataQuery(reportKey)); + => await _mediator.Send(new GetMetaDataQuery(reportKey, _actingUser)); [HttpGet("parameter/challenge-specs/{gameId?}")] public Task> GetChallengeSpecs(string gameId = null) diff --git a/src/Gameboard.Api/Features/Reports/ReportsExportController.cs b/src/Gameboard.Api/Features/Reports/ReportsExportController.cs index da252945..37f5fcb3 100644 --- a/src/Gameboard.Api/Features/Reports/ReportsExportController.cs +++ b/src/Gameboard.Api/Features/Reports/ReportsExportController.cs @@ -27,7 +27,7 @@ public ReportsExportController(IActingUserService actingUserService, IMediator m [ProducesResponseType(typeof(FileContentResult), 200)] public async Task GetChallengesReport(GetChallengesReportQueryArgs parameters) { - var results = await _mediator.Send(new ChallengesReportExportQuery(parameters)); + var results = await _mediator.Send(new ChallengesReportExportQuery(parameters, _actingUser)); return new FileContentResult(GetReportExport(results), MimeTypes.TextCsv); } @@ -35,7 +35,7 @@ public async Task GetChallengesReport(GetChallengesReportQueryArg [ProducesResponseType(typeof(FileContentResult), 200)] public async Task GetEnrollmentReportExport(EnrollmentReportParameters parameters) { - var results = await _mediator.Send(new EnrollmentReportExportQuery(parameters)); + var results = await _mediator.Send(new EnrollmentReportExportQuery(parameters, _actingUser)); return new FileContentResult(GetReportExport(results), MimeTypes.TextCsv); } @@ -43,7 +43,7 @@ public async Task GetEnrollmentReportExport(EnrollmentReportParam [ProducesResponseType(typeof(FileContentResult), 200)] public async Task GetPlayersReport(PlayersReportQueryParameters parameters) { - var results = await _mediator.Send(new PlayersReportExportQuery(parameters)); + var results = await _mediator.Send(new PlayersReportExportQuery(parameters, _actingUser)); return new FileContentResult(GetReportExport(results), MimeTypes.TextCsv); } diff --git a/src/Gameboard.Api/Features/Reports/ReportsValidator.cs b/src/Gameboard.Api/Features/Reports/ReportsQueryValidator.cs similarity index 80% rename from src/Gameboard.Api/Features/Reports/ReportsValidator.cs rename to src/Gameboard.Api/Features/Reports/ReportsQueryValidator.cs index 617c8283..cc21290e 100644 --- a/src/Gameboard.Api/Features/Reports/ReportsValidator.cs +++ b/src/Gameboard.Api/Features/Reports/ReportsQueryValidator.cs @@ -4,7 +4,7 @@ namespace Gameboard.Api.Features.Reports; -internal class ReportsQueryValidator : IGameboardRequestValidator +internal class ReportsQueryValidator : IGameboardRequestValidator { private readonly UserRoleAuthorizer _roleAuthorizer; @@ -15,7 +15,7 @@ public ReportsQueryValidator(UserRoleAuthorizer roleAuthorizer) public Task Validate(IReportQuery request) { - _roleAuthorizer.AllowedRoles = new UserRole[] { UserRole.Director, UserRole.Admin, UserRole.Support }; + _roleAuthorizer.AllowedRoles = new UserRole[] { UserRole.Admin, UserRole.Registrar, UserRole.Support }; _roleAuthorizer.Authorize(); return Task.CompletedTask; diff --git a/src/Gameboard.Api/Features/Reports/ReportsService.cs b/src/Gameboard.Api/Features/Reports/ReportsService.cs index 3dcdfde6..63b7ab5a 100644 --- a/src/Gameboard.Api/Features/Reports/ReportsService.cs +++ b/src/Gameboard.Api/Features/Reports/ReportsService.cs @@ -65,40 +65,37 @@ public Task> List() { var reports = new ReportViewModel[] { - new ReportViewModel - { + new() { Name = "Enrollment", Key = ReportKey.Enrollment, Description = "View a summary of player enrollment - who enrolled when, which sponsors do they represent, and how many of them actually played challenges.", ExampleFields = new string[] { - "Player Info", + "Player & Sponsor", "Games Enrolled", - "Sessions Launched", "Challenge Performance", - "Sponsor" }, ExampleParameters = new string[] { - "Enrollment Date Range", "Season", "Series", "Sponsor", "Track", - "Game & Challenge" + "Game", + "Enrollment Date Range", } }, - new ReportViewModel - { + new() { Name = "Practice Area", Key = ReportKey.PracticeArea, Description = "Check in on players who are spending free time honing their skills on Gameboard. See which challenges are practiced most, success rates, and which players are logging in to practice.", ExampleFields = new string[] { "Challenge Performance", - "Player Engagement", + "Player Performance", "Scoring", - "Trends" + "Trends", + "Practice vs. Competitive" }, ExampleParameters = new string[] { @@ -110,24 +107,25 @@ public Task> List() "Sponsor" } }, - new ReportViewModel - { + new() { Name = "Support", Key = ReportKey.Support, Description = "View a summary of the support tickets that have been created in Gameboard, including closer looks at submission times, ticket categories, and associated challenges.", ExampleFields = new string[] { - "Ticket Category", + "Summary Info", + "Status", + "Label", "Challenge", "Time Windows", "Assignment Info" }, ExampleParameters = new string[] { - "Challenge", + "Status & Label", + "Game & Challenge", "Creation Date", - "Ticket Category", - "Time Window", + "Time Since Opened / Updated", } }, }; diff --git a/src/Gameboard.Api/Features/Teams/TeamService.cs b/src/Gameboard.Api/Features/Teams/TeamService.cs index 0941bac6..2be9c999 100644 --- a/src/Gameboard.Api/Features/Teams/TeamService.cs +++ b/src/Gameboard.Api/Features/Teams/TeamService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -7,6 +8,7 @@ using Gameboard.Api.Hubs; using Gameboard.Api.Services; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; namespace Gameboard.Api.Features.Teams; @@ -15,6 +17,7 @@ public interface ITeamService Task GetExists(string teamId); Task GetSessionCount(string teamId, string gameId); Task GetTeam(string id); + Task IsOnTeam(string teamId, string userId); Task ResolveCaptain(string teamId); Task ResolveCaptain(IEnumerable players); Task PromoteCaptain(string teamId, string newCaptainPlayerId, User actingUser); @@ -24,6 +27,7 @@ public interface ITeamService internal class TeamService : ITeamService { private readonly IMapper _mapper; + private readonly IMemoryCache _memCache; private readonly INowService _now; private readonly IInternalHubBus _teamHubService; private readonly IPlayerStore _store; @@ -31,12 +35,14 @@ internal class TeamService : ITeamService public TeamService ( IMapper mapper, + IMemoryCache memCache, INowService now, IInternalHubBus teamHubService, IPlayerStore store ) { _mapper = mapper; + _memCache = memCache; _now = now; _store = store; _teamHubService = teamHubService; @@ -74,6 +80,24 @@ public async Task GetTeam(string id) return team; } + public async Task IsOnTeam(string teamId, string userId) + { + // simple serialize to indicate whether this user and team are a match + var cacheKey = $"{teamId}|{userId}"; + + if (_memCache.TryGetValue(cacheKey, out bool cachedIsOnTeam)) + return cachedIsOnTeam; + + var teamUserIds = await _store + .ListTeam(teamId) + .Select(p => p.UserId).ToArrayAsync(); + + var isOnTeam = teamUserIds.Contains(userId); + _memCache.Set(cacheKey, isOnTeam, TimeSpan.FromMinutes(30)); + + return isOnTeam; + } + public async Task PromoteCaptain(string teamId, string newCaptainPlayerId, User actingUser) { var teamPlayers = await _store