From cc3dec4cf6c976e4409f178af9ebd8f70e8ee0ce Mon Sep 17 00:00:00 2001 From: Ben Stein <115497763+sei-bstein@users.noreply.github.com> Date: Fri, 1 Sep 2023 14:20:22 -0400 Subject: [PATCH] v3.10.0 (#241) - Practice Area feature added - New Reporting Interface - Revamp of certificates to support publishing certificates - Additional tracking of user actions (e.g., time enrolled in a game) to support more granular reporting - Code Cleanup - Bug Fixes --- .github/workflows/main.yml | 6 +- .gitignore | 10 +- .vscode/settings.json | 2 +- .vscode/tasks.json | 19 + Dockerfile | 11 +- Gameboard.sln | 84 -- .../Fixtures/Builders/DataStateBuilder.cs | 9 +- .../Extensions/DefaultEntityExtensions.cs | 17 +- .../GameboardTestContextExtensions.cs | 18 +- .../Extensions/SerializationExtensions.cs | 11 +- .../Extensions/ServiceCollectionExtensions.cs | 8 +- .../Fixtures/GameboardTestContext.cs | 82 +- .../GbIntegrationAutoDataAttribute.cs | 17 +- .../Fixtures/GbIntegrationCustomization.cs | 84 -- .../Services/TestGameEngineService.cs | 5 + .../Fixtures/Services/TestGamebrainService.cs | 26 + .../Fixtures/TestCollectionNames.cs | 6 + .../Fixtures/TestIds.cs | 7 - .../Gameboard.Api.Tests.Integration.csproj | 25 +- .../ChallengeBonusControllerManualTests.cs | 8 +- .../ChallengeBonusListTests.cs | 8 +- .../ChallengeControllerCreateTests.cs | 11 +- .../GameEngineControllerGetStateTests.cs | 13 +- .../GameControllerGetSyncStartStateTests.cs | 7 +- .../Features/Games/GameControllerTests.cs | 7 +- .../PlayerControllerSessionResetTests.cs | 44 +- .../Features/Players/PlayerControllerTests.cs | 70 +- .../Players/PlayerControllerUnenrollTests.cs | 11 +- .../PlayerControllerUpdateReadyStateTests.cs | 16 +- ...ringControllerTeamChallengeSummaryTests.cs | 17 +- .../ScoringControllerTeamGameSummaryTests.cs | 8 +- .../UnityGames/UnityGameControllerTests.cs | 25 +- .../Features/Users/UserControllerTests.cs | 42 +- .../Fixtures/GameboardCustomization.cs | 91 +- .../Fixtures/SpecimenBuilders/IdBuilder.cs | 8 +- .../Gameboard.Api.Tests.Shared.csproj | 1 + .../Fixtures/Exceptions.cs | 6 + .../Fixtures/FakeBuilder.cs | 48 + .../Fixtures/GameboardAutoDataAttribute.cs | 4 +- .../Gameboard.Api.Tests.Unit.csproj | 11 +- .../Tests/Data/QueryExtensionsTests.cs | 20 + .../Challenges/ChallengeServiceTests.cs | 127 +- .../GameEngine/GameEngineMapsTests.cs | 2 +- .../Features/Player/PlayerServiceTests.cs | 44 +- .../Tests/Features/Player/TeamServiceTests.cs | 2 + .../GetPracticeModeCertificatePngTests.cs | 84 ++ .../Reports/EnrollmentReportServiceTests.cs | 112 ++ .../Reports/SupportReportServiceTests.cs | 58 + .../UnityGames/UnityGameServiceTests.cs | 1 - .../Tests/Features/User/UserServiceTests.cs | 4 +- .../Tests/Features/_ControllerTests.cs | 12 +- .../ApiKeyAuthenticationHandlerTests.cs | 16 +- .../Tests/Structure/ApiKeyServiceTests.cs | 56 +- src/Gameboard.Api.sln | 40 + src/Gameboard.Api/Common/CommonModels.cs | 33 + .../Common/ExpressionVisitors.cs | 132 ++ .../Common/Extensions/EnumerableExtensions.cs | 16 + .../Common/Services/PagingService.cs | 71 + .../Common/Services/TimeWindowService.cs | 73 + .../Common/Tools/StartProcessAsync.cs | 32 + .../Data/DataStartupExtensions.cs | 6 +- .../Data/Entities/ArchivedChallenge.cs | 1 + src/Gameboard.Api/Data/Entities/Challenge.cs | 3 +- .../Data/Entities/ChallengeSpec.cs | 1 + src/Gameboard.Api/Data/Entities/Game.cs | 136 +- src/Gameboard.Api/Data/Entities/Player.cs | 80 +- .../Data/Entities/PracticeModeSettings.cs | 17 + .../Data/Entities/PublishedCertificate.cs | 32 + src/Gameboard.Api/Data/Entities/User.cs | 40 +- .../Data/Extensions/EntityExtensions.cs | 24 + .../Data/Extensions/QueryExtensions.cs | 56 + src/Gameboard.Api/Data/GameboardDbContext.cs | 41 + ...622150410_AddPlayerWhenCreated.Designer.cs | 1077 ++++++++++++++ .../20230622150410_AddPlayerWhenCreated.cs | 30 + ...839_AddUserCreatedLoginColumns.Designer.cs | 1088 +++++++++++++++ ...230629155839_AddUserCreatedLoginColumns.cs | 53 + ...4155027_AddChallengePlayerMode.Designer.cs | 1094 +++++++++++++++ .../20230724155027_AddChallengePlayerMode.cs | 54 + ...194653_AddPracticeModeSettings.Designer.cs | 1141 +++++++++++++++ .../20230815194653_AddPracticeModeSettings.cs | 61 + ...65815_AddPublishedCertificates.Designer.cs | 1234 +++++++++++++++++ ...20230823165815_AddPublishedCertificates.cs | 68 + ...meboardDbContextPostgreSQLModelSnapshot.cs | 162 ++- .../20230426134535_AddReports.Designer.cs | 1104 +++++++++++++++ .../GameboardDb/20230426134535_AddReports.cs | 37 + ...622150416_AddPlayerWhenCreated.Designer.cs | 1076 ++++++++++++++ .../20230622150416_AddPlayerWhenCreated.cs | 49 + ...845_AddUserCreatedLoginColumns.Designer.cs | 1087 +++++++++++++++ ...230629155845_AddUserCreatedLoginColumns.cs | 53 + ...4155032_AddChallengePlayerMode.Designer.cs | 1093 +++++++++++++++ .../20230724155032_AddChallengePlayerMode.cs | 54 + ...194658_AddPracticeModeSettings.Designer.cs | 1141 +++++++++++++++ .../20230815194658_AddPracticeModeSettings.cs | 61 + ...65821_AddPublishedCertificates.Designer.cs | 1234 +++++++++++++++++ ...20230823165821_AddPublishedCertificates.cs | 68 + ...ameboardDbContextSqlServerModelSnapshot.cs | 163 ++- src/Gameboard.Api/Data/ProviderDbContext.cs | 37 +- .../Data/Store/IStore[TEntity].cs | 36 +- src/Gameboard.Api/Data/Store/Store.cs | 158 +++ .../Data/Store/Store[TEntity].cs | 30 +- .../Extensions/DatabaseStartupExtensions.cs | 26 +- .../Extensions/JsonExceptionMiddleware.cs | 2 +- .../WebApplicationBuilderExtensions.cs | 14 + .../Extensions/WebApplicationExtensions.cs | 2 - .../Features/ApiKeys/ApiKeysService.cs | 30 +- .../Features/ApiKeys/ApiKeysStore.cs | 11 - .../Features/ApiKeys/ApiKeysValidator.cs | 1 + .../Certificates/CertificatesController.cs | 85 ++ .../Certificates/CertificatesExceptions.cs | 10 + .../Certificates/CertificatesModels.cs | 42 + .../Certificates/CertificatesService.cs | 74 + .../GetCompetitiveModeCertificateHtml.cs | 62 + .../GetPracticeModeCertificateHtml.cs | 112 ++ .../Requests/GetPracticeModeCertificates.cs | 46 + .../SetCompetitiveCertificateIsPublished.cs | 101 ++ .../SetPracticeCertificateIsPublished.cs | 102 ++ .../Features/Challenge/Challenge.cs | 420 +++--- .../Features/Challenge/ChallengeController.cs | 20 +- .../Features/Challenge/ChallengeExtensions.cs | 2 + .../Features/Challenge/ChallengeMapper.cs | 4 +- .../Features/Challenge/ChallengeService.cs | 1066 +++++++------- .../Features/Challenge/ChallengeStore.cs | 23 +- .../Features/Challenge/ChallengeValidator.cs | 47 +- .../Features/Challenge/ConsoleActorMap.cs | 8 +- .../Features/Challenge/IChallengeStore.cs | 17 - .../AddManualBonus/AddManualBonusCommand.cs | 5 - .../AddManualBonus/AddManualBonusHandler.cs | 2 + .../AddManualBonus/AddManualBonusValidator.cs | 6 +- .../ChallengeBonusController.cs | 1 + .../ChallengeBonuses/ChallengeBonusMaps.cs | 2 +- .../ChallengeBonuses/ChallengeBonusModels.cs | 4 +- .../ChallengeBonuses/ChallengeBonusStore.cs | 24 +- .../ChallengeGate/ChallengeGateService.cs | 4 +- .../ChallengeGate/ChallengeGateValidator.cs | 43 +- .../Features/ChallengeSpec/ChallengeSpec.cs | 112 +- .../ChallengeSpec/ChallengeSpecController.cs | 13 +- .../ChallengeSpec/ChallengeSpecMapper.cs | 2 + .../ChallengeSpec/ChallengeSpecService.cs | 40 +- .../ChallengeSpec/ChallengeSpecValidator.cs | 32 +- .../Features/Common/CommonModels.cs | 15 - .../CubespaceScoreboardController.cs | 17 +- .../CubespaceScoreboardService.cs | 3 +- .../Features/EntityExtensions.cs | 7 - .../ExternalGames/ExternalGamesModels.cs | 2 +- .../Features/FeatureStartupExtensions.cs | 80 +- .../Features/Feedback/FeedbackService.cs | 3 +- src/Gameboard.Api/Features/Game/Game.cs | 23 +- .../Features/Game/GameController.cs | 18 +- .../Features/Game/GameExtensions.cs | 13 + src/Gameboard.Api/Features/Game/GameMapper.cs | 25 +- .../Features/Game/GameService.cs | 20 +- .../Features/Game/GameValidator.cs | 2 +- .../StartGameCommandValidator.cs | 7 - .../Features/Game/SyncStartModels.cs | 2 +- .../Extensions/ChallengeExtensions.cs | 17 + .../GameEngine/GameEngineController.cs | 10 +- .../Features/GameEngine/GameEngineMaps.cs | 10 +- .../GetGameState/GetGameStateHandler.cs | 6 +- .../GetGameState/GetGameStateValidator.cs | 1 + .../GetSubmissionsRequestHandler.cs | 2 +- .../GameEngine/Models/GameEngineModels.cs | 13 +- .../GameEngine/Services/CrucibleService.cs | 12 +- .../GameEngine/Services/GameEngineService.cs | 129 +- .../GameEngine/Services/IGameEngineService.cs | 22 - src/Gameboard.Api/Features/Hubs/AppHub.cs | 1 + src/Gameboard.Api/Features/Hubs/IAppHub.cs | 1 + .../Features/Hubs/InternalHubBus.cs | 6 +- .../Features/Player/IPlayerStore.cs | 20 +- src/Gameboard.Api/Features/Player/Player.cs | 456 +++--- .../Features/Player/PlayerController.cs | 30 +- .../Features/Player/PlayerMapper.cs | 5 +- .../Features/Player/PlayerService.cs | 187 +-- .../Features/Player/PlayerStore.cs | 2 +- .../Features/Player/PlayerValidator.cs | 3 +- .../Practice/GetPracticeModeSettings.cs | 24 + .../PracticeChallengeScoringListener.cs | 117 ++ .../Features/Practice/PracticeController.cs | 61 + .../Features/Practice/PracticeMaps.cs | 13 + .../Features/Practice/PracticeModels.cs | 17 + .../Features/Practice/PracticeService.cs | 65 + .../Practice/SearchPracticeChallenges.cs | 64 + .../Practice/StartPracticeChallenge.cs | 17 + .../Practice/UpdatePracticeModeSettings.cs | 104 ++ .../Features/Report/IReportStore.cs | 7 - .../Features/Report/ReportModels.cs | 227 --- .../Features/Report/ReportStore.cs | 4 - .../ChallengesReportExportQuery.cs | 27 + .../ChallengesReportModels.cs | 105 ++ .../ChallengesReport/ChallengesReportQuery.cs | 37 + .../EnrollmentReport/EnrollmentReport.cs | 47 + .../EnrollmentReportExport.cs | 78 ++ .../EnrollmentReportLineChart.cs | 70 + .../EnrollmentReportModels.cs | 171 +++ .../EnrollmentReportService.cs | 270 ++++ .../EnrollmentReportValidator.cs | 28 + .../Queries/GetMetaData/GetMetaData.cs | 42 + .../PlayersReport/PlayerReportQuery.cs | 116 ++ .../PlayersReport/PlayersReportExportQuery.cs | 41 + .../PlayersReport/PlayersReportModels.cs | 70 + .../PlayersReport/PlayersReportService.cs | 63 + .../PracticeMode/PracticeModeReport.cs | 67 + .../PracticeModeReportCsvExport.cs | 26 + .../PracticeMode/PracticeModeReportModels.cs | 193 +++ .../PracticeModeReportPlayerModeSummary.cs | 38 + .../PracticeMode/PracticeModeReportService.cs | 511 +++++++ .../Queries/ReportsCommonViewModels.cs | 30 + .../Queries/SupportReport/SupportReport.cs | 41 + .../SupportReport/SupportReportExport.cs | 24 + .../SupportReport/SupportReportModels.cs | 67 + .../SupportReport/SupportReportService.cs | 142 ++ .../Reports/ReportConditionExtensions.cs | 32 + .../Features/Reports/ReportModels.cs | 227 +++ .../{Report => Reports}/ReportService.cs | 7 +- .../Features/Reports/ReportsController.cs | 95 ++ .../Reports/ReportsExportController.cs | 71 + .../Features/Reports/ReportsMaps.cs | 22 + .../Features/Reports/ReportsModels.cs | 111 ++ .../Features/Reports/ReportsService.cs | 449 ++++++ .../ReportsV1Controller.cs} | 143 +- .../Features/Reports/ReportsValidator.cs | 23 + .../Features/Scores/ScoringModels.cs | 5 +- .../Features/Scores/ScoringService.cs | 15 +- .../TeamGameScoreQuery/TeamGameScoreQuery.cs | 7 +- .../TeamGameScoreQueryValidator.cs | 10 +- src/Gameboard.Api/Features/SearchFilter.cs | 35 +- .../Features/Sponsor/SponserValidator.cs | 6 +- src/Gameboard.Api/Features/Sponsor/Sponsor.cs | 1 - .../Features/Sponsor/SponsorController.cs | 8 +- .../Features/Sponsor/SponsorService.cs | 29 +- .../Features/Teams/Requests/GetTeamQuery.cs | 14 +- .../Features/Teams/TeamController.cs | 13 +- .../Features/Teams/TeamService.cs | 45 +- .../Features/Teams/TeamsModels.cs | 86 ++ .../Features/Ticket/ITicketStore.cs | 1 - .../Features/Ticket/TicketController.cs | 4 +- .../Features/Ticket/TicketService.cs | 28 +- .../Features/Ticket/TicketValidator.cs | 178 +-- .../UnityGames/UnityGameController.cs | 1 + .../Features/UnityGames/UnityGameValidator.cs | 47 +- .../GetUserActiveChallenges.cs | 181 +++ src/Gameboard.Api/Features/User/IUserStore.cs | 7 - .../Features/User/UpdateUserLoginEvents.cs | 65 + src/Gameboard.Api/Features/User/User.cs | 168 +-- .../Features/User/UserClaimTransformation.cs | 26 +- .../Features/User/UserController.cs | 36 +- src/Gameboard.Api/Features/User/UserMapper.cs | 3 + .../Features/User/UserService.cs | 123 +- src/Gameboard.Api/Features/User/UserStore.cs | 26 - .../Features/User/UserValidator.cs | 17 +- src/Gameboard.Api/Features/_Controller.cs | 5 +- src/Gameboard.Api/Gameboard.Api.csproj | 29 +- src/Gameboard.Api/Program.cs | 4 +- .../Services/ActingUserService.cs | 4 +- src/Gameboard.Api/Services/FlattenService.cs | 62 + src/Gameboard.Api/Services/GuidService.cs | 8 +- src/Gameboard.Api/Services/HashService.cs | 19 - .../Services/HtmlToImageService.cs | 141 ++ src/Gameboard.Api/Services/JsonService.cs | 24 +- src/Gameboard.Api/Services/NowService.cs | 2 + src/Gameboard.Api/Services/StartupLogger.cs | 2 +- .../Services/TimeWindowService.cs | 65 - src/Gameboard.Api/Structure/AppSettings.cs | 6 +- .../ApiKeyAuthenticationChallengeException.cs | 2 +- .../ApiKeys/ApiKeyAuthenticationExtensions.cs | 2 +- .../ApiKeys/ApiKeyAuthenticationHandler.cs | 17 +- .../ApiKeys/ApiKeyAuthenticationOptions.cs | 6 +- .../Auth/AuthenticationStartupExtensions.cs | 9 +- .../Auth/AuthorizationStartupExtensions.cs | 3 +- .../Auth/ClaimsPrincipalExtensions.cs | 9 + .../GraderKeyAuthenticationHandler.cs | 89 ++ .../Auth/TicketAuthenticationHandler.cs | 3 +- .../Structure/AuthenticatingHandler.cs | 3 +- .../Structure/AuthenticationService.cs | 3 - src/Gameboard.Api/Structure/Exceptions.cs | 18 +- .../Structure/Extensions/IListExtensions.cs | 1 - src/Gameboard.Api/Structure/JobService.cs | 24 +- .../Structure/JsonDateTimeConverter.cs | 10 +- .../MediatR/Authorizers/UserRoleAuthorizer.cs | 9 +- .../MediatR/GameboardValidationException.cs | 3 +- .../Validators/EntityExistsValidator.cs | 11 +- .../MediatR/Validators/Exceptions.cs | 3 + .../Validators/StartEndDateValidator.cs | 51 + .../MediatR/Validators/TeamExistsValidator.cs | 11 +- .../Validators/TeamHasChallengeValidator.cs | 2 +- .../Validators/UserIsPlayingGameValidator.cs | 2 +- .../MediatR/Validators/ValidatorService.cs | 2 +- src/Gameboard.Api/Structure/MimeTypes.cs | 8 + .../Structure/Models/StructureModelMaps.cs | 13 + .../ServiceRegistrationExtensions.cs | 50 +- .../Structure/StringExtensions.cs | 40 +- src/Gameboard.Api/wwwroot/temp/.gitkeep | 0 .../practice-certificate.template.html | 47 + test/oidc-dev.http | 40 - 293 files changed, 22552 insertions(+), 3304 deletions(-) delete mode 100644 Gameboard.sln delete mode 100644 src/Gameboard.Api.Tests.Integration/Fixtures/GbIntegrationCustomization.cs create mode 100644 src/Gameboard.Api.Tests.Integration/Fixtures/Services/TestGamebrainService.cs create mode 100644 src/Gameboard.Api.Tests.Integration/Fixtures/TestCollectionNames.cs delete mode 100644 src/Gameboard.Api.Tests.Integration/Fixtures/TestIds.cs rename src/{Gameboard.Api.Tests.Integration => Gameboard.Api.Tests.Shared}/Fixtures/SpecimenBuilders/IdBuilder.cs (80%) create mode 100644 src/Gameboard.Api.Tests.Unit/Fixtures/Exceptions.cs create mode 100644 src/Gameboard.Api.Tests.Unit/Fixtures/FakeBuilder.cs create mode 100644 src/Gameboard.Api.Tests.Unit/Tests/Data/QueryExtensionsTests.cs create mode 100644 src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/GetPracticeModeCertificatePngTests.cs create mode 100644 src/Gameboard.Api.Tests.Unit/Tests/Features/Reports/EnrollmentReportServiceTests.cs create mode 100644 src/Gameboard.Api.Tests.Unit/Tests/Features/Reports/SupportReportServiceTests.cs create mode 100644 src/Gameboard.Api.sln create mode 100644 src/Gameboard.Api/Common/CommonModels.cs create mode 100644 src/Gameboard.Api/Common/ExpressionVisitors.cs create mode 100644 src/Gameboard.Api/Common/Extensions/EnumerableExtensions.cs create mode 100644 src/Gameboard.Api/Common/Services/PagingService.cs create mode 100644 src/Gameboard.Api/Common/Services/TimeWindowService.cs create mode 100644 src/Gameboard.Api/Common/Tools/StartProcessAsync.cs create mode 100644 src/Gameboard.Api/Data/Entities/PracticeModeSettings.cs create mode 100644 src/Gameboard.Api/Data/Entities/PublishedCertificate.cs create mode 100644 src/Gameboard.Api/Data/Extensions/EntityExtensions.cs create mode 100644 src/Gameboard.Api/Data/Extensions/QueryExtensions.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230622150410_AddPlayerWhenCreated.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230622150410_AddPlayerWhenCreated.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230629155839_AddUserCreatedLoginColumns.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230629155839_AddUserCreatedLoginColumns.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230724155027_AddChallengePlayerMode.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230724155027_AddChallengePlayerMode.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230815194653_AddPracticeModeSettings.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230815194653_AddPracticeModeSettings.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230823165815_AddPublishedCertificates.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230823165815_AddPublishedCertificates.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230426134535_AddReports.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230426134535_AddReports.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230622150416_AddPlayerWhenCreated.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230622150416_AddPlayerWhenCreated.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230629155845_AddUserCreatedLoginColumns.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230629155845_AddUserCreatedLoginColumns.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230724155032_AddChallengePlayerMode.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230724155032_AddChallengePlayerMode.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230815194658_AddPracticeModeSettings.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230815194658_AddPracticeModeSettings.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230823165821_AddPublishedCertificates.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230823165821_AddPublishedCertificates.cs create mode 100644 src/Gameboard.Api/Data/Store/Store.cs create mode 100644 src/Gameboard.Api/Features/Certificates/CertificatesController.cs create mode 100644 src/Gameboard.Api/Features/Certificates/CertificatesExceptions.cs create mode 100644 src/Gameboard.Api/Features/Certificates/CertificatesModels.cs create mode 100644 src/Gameboard.Api/Features/Certificates/CertificatesService.cs create mode 100644 src/Gameboard.Api/Features/Certificates/Requests/GetCompetitiveModeCertificateHtml.cs create mode 100644 src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificateHtml.cs create mode 100644 src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificates.cs create mode 100644 src/Gameboard.Api/Features/Certificates/Requests/SetCompetitiveCertificateIsPublished.cs create mode 100644 src/Gameboard.Api/Features/Certificates/Requests/SetPracticeCertificateIsPublished.cs create mode 100644 src/Gameboard.Api/Features/Challenge/ChallengeExtensions.cs delete mode 100644 src/Gameboard.Api/Features/Challenge/IChallengeStore.cs delete mode 100644 src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusCommand.cs delete mode 100644 src/Gameboard.Api/Features/Common/CommonModels.cs delete mode 100644 src/Gameboard.Api/Features/EntityExtensions.cs create mode 100644 src/Gameboard.Api/Features/Game/GameExtensions.cs create mode 100644 src/Gameboard.Api/Features/GameEngine/Extensions/ChallengeExtensions.cs delete mode 100644 src/Gameboard.Api/Features/GameEngine/Services/IGameEngineService.cs create mode 100644 src/Gameboard.Api/Features/Practice/GetPracticeModeSettings.cs create mode 100644 src/Gameboard.Api/Features/Practice/PracticeChallengeScoringListener.cs create mode 100644 src/Gameboard.Api/Features/Practice/PracticeController.cs create mode 100644 src/Gameboard.Api/Features/Practice/PracticeMaps.cs create mode 100644 src/Gameboard.Api/Features/Practice/PracticeModels.cs create mode 100644 src/Gameboard.Api/Features/Practice/PracticeService.cs create mode 100644 src/Gameboard.Api/Features/Practice/SearchPracticeChallenges.cs create mode 100644 src/Gameboard.Api/Features/Practice/StartPracticeChallenge.cs create mode 100644 src/Gameboard.Api/Features/Practice/UpdatePracticeModeSettings.cs delete mode 100644 src/Gameboard.Api/Features/Report/IReportStore.cs delete mode 100644 src/Gameboard.Api/Features/Report/ReportModels.cs delete mode 100644 src/Gameboard.Api/Features/Report/ReportStore.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportExportQuery.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportModels.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportQuery.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReport.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportExport.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportLineChart.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportModels.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportValidator.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/GetMetaData/GetMetaData.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayerReportQuery.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportExportQuery.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportModels.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportService.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReport.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportCsvExport.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportModels.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportPlayerModeSummary.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportService.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/ReportsCommonViewModels.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReport.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReportExport.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReportModels.cs create mode 100644 src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReportService.cs create mode 100644 src/Gameboard.Api/Features/Reports/ReportConditionExtensions.cs create mode 100644 src/Gameboard.Api/Features/Reports/ReportModels.cs rename src/Gameboard.Api/Features/{Report => Reports}/ReportService.cs (99%) create mode 100644 src/Gameboard.Api/Features/Reports/ReportsController.cs create mode 100644 src/Gameboard.Api/Features/Reports/ReportsExportController.cs create mode 100644 src/Gameboard.Api/Features/Reports/ReportsMaps.cs create mode 100644 src/Gameboard.Api/Features/Reports/ReportsModels.cs create mode 100644 src/Gameboard.Api/Features/Reports/ReportsService.cs rename src/Gameboard.Api/Features/{Report/ReportController.cs => Reports/ReportsV1Controller.cs} (93%) create mode 100644 src/Gameboard.Api/Features/Reports/ReportsValidator.cs create mode 100644 src/Gameboard.Api/Features/Teams/TeamsModels.cs create mode 100644 src/Gameboard.Api/Features/User/GetUserActiveChallenges/GetUserActiveChallenges.cs delete mode 100644 src/Gameboard.Api/Features/User/IUserStore.cs create mode 100644 src/Gameboard.Api/Features/User/UpdateUserLoginEvents.cs delete mode 100644 src/Gameboard.Api/Features/User/UserStore.cs create mode 100644 src/Gameboard.Api/Services/FlattenService.cs delete mode 100644 src/Gameboard.Api/Services/HashService.cs create mode 100644 src/Gameboard.Api/Services/HtmlToImageService.cs delete mode 100644 src/Gameboard.Api/Services/TimeWindowService.cs create mode 100644 src/Gameboard.Api/Structure/Auth/GraderKey/GraderKeyAuthenticationHandler.cs create mode 100644 src/Gameboard.Api/Structure/MediatR/Validators/StartEndDateValidator.cs create mode 100644 src/Gameboard.Api/Structure/MimeTypes.cs create mode 100644 src/Gameboard.Api/Structure/Models/StructureModelMaps.cs create mode 100644 src/Gameboard.Api/wwwroot/temp/.gitkeep create mode 100644 src/Gameboard.Api/wwwroot/templates/practice-certificate.template.html delete mode 100644 test/oidc-dev.http diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2037902d..05d4a90c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: release: - types: [ "published" ] + types: ["published"] push: branches: - dev @@ -21,9 +21,9 @@ jobs: with: dotnet-version: 7.0 - name: Install dependencies - run: dotnet restore + run: dotnet restore src - name: Build - run: dotnet build -c Release --no-restore + run: dotnet build src/Gameboard.Api.sln -c Release --no-restore - name: Run unit tests run: dotnet test src/Gameboard.Api.Tests.Unit --no-restore - name: Run integration tests diff --git a/.gitignore b/.gitignore index c0c2a308..ef9cfb8b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,13 @@ appsettings.Development.* *.csproj.user # compiled output +*/**/bin +*/**/obj +/bin +/obj /dist /tmp /out-tsc -# Only exists if Bazel was run -/bazel-out # dependencies /node_modules @@ -41,3 +43,7 @@ Thumbs.db # vs code launch (allow for end dev customization) .vscode/launch.json + +# git-kept +src/Gameboard.Api/wwwroot/temp/* +!.gitkeep diff --git a/.vscode/settings.json b/.vscode/settings.json index f34c487d..25f29a07 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "dotnet.defaultSolution": "Gameboard.sln" + "dotnet.defaultSolution": "src/Gameboard.Api.sln" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 55e618fc..c3fd3417 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -152,6 +152,25 @@ "EF: Undo Migration (MS)" ], "problemMatcher": [] + }, + { + "label": "EF: Update Database (PG)", + "dependsOrder": "sequence", + "dependsOn": [ + "build" + ], + "type": "shell", + "command": "dotnet", + "args": [ + "ef", + "database", + "update", + "--project", + "${workspaceFolder}/src/Gameboard.Api", + "--context", + "GameboardDbContextPostgreSql" + ], + "problemMatcher": [] } ], "inputs": [ diff --git a/Dockerfile b/Dockerfile index 54475957..4c9e581e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # -#multi-stage target: dev +# multi-stage target: dev # FROM mcr.microsoft.com/dotnet/sdk:7.0 AS dev @@ -13,11 +13,18 @@ RUN dotnet publish -c Release -o /app/dist CMD ["dotnet", "run"] # -#multi-stage target: prod +# multi-stage target: prod # FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS prod ARG commit ENV COMMIT=$commit + +# install tools for PNG generation on the server +RUN apt-get update && apt-get install -y wget && apt-get clean +RUN wget -O ~/wkhtmltopdf.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.bullseye_amd64.deb +RUN apt-get install -y ~/wkhtmltopdf.deb +RUN rm ~/wkhtmltopdf.deb + COPY --from=dev /app/dist /app COPY --from=dev /app/LICENSE.md /app/LICENSE.md WORKDIR /app diff --git a/Gameboard.sln b/Gameboard.sln deleted file mode 100644 index df0801e1..00000000 --- a/Gameboard.sln +++ /dev/null @@ -1,84 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30114.105 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C148F187-E600-4044-8944-2E9E52575B07}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard", "src\Gameboard.Api\Gameboard.Api.csproj", "{A2715FCD-D078-4E96-81C3-B833640921C5}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard.Api.Tests.Shared", "src\Gameboard.Api.Tests.Shared\Gameboard.Api.Tests.Shared.csproj", "{79462104-DCF9-4F9C-AC60-EA6E9F961EBF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard.Api.Tests.Unit", "src\Gameboard.Api.Tests.Unit\Gameboard.Api.Tests.Unit.csproj", "{6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard.Api.Tests.Integration", "src\Gameboard.Api.Tests.Integration\Gameboard.Api.Tests.Integration.csproj", "{C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {A2715FCD-D078-4E96-81C3-B833640921C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A2715FCD-D078-4E96-81C3-B833640921C5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A2715FCD-D078-4E96-81C3-B833640921C5}.Debug|x64.ActiveCfg = Debug|Any CPU - {A2715FCD-D078-4E96-81C3-B833640921C5}.Debug|x64.Build.0 = Debug|Any CPU - {A2715FCD-D078-4E96-81C3-B833640921C5}.Debug|x86.ActiveCfg = Debug|Any CPU - {A2715FCD-D078-4E96-81C3-B833640921C5}.Debug|x86.Build.0 = Debug|Any CPU - {A2715FCD-D078-4E96-81C3-B833640921C5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A2715FCD-D078-4E96-81C3-B833640921C5}.Release|Any CPU.Build.0 = Release|Any CPU - {A2715FCD-D078-4E96-81C3-B833640921C5}.Release|x64.ActiveCfg = Release|Any CPU - {A2715FCD-D078-4E96-81C3-B833640921C5}.Release|x64.Build.0 = Release|Any CPU - {A2715FCD-D078-4E96-81C3-B833640921C5}.Release|x86.ActiveCfg = Release|Any CPU - {A2715FCD-D078-4E96-81C3-B833640921C5}.Release|x86.Build.0 = Release|Any CPU - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF}.Debug|x64.ActiveCfg = Debug|Any CPU - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF}.Debug|x64.Build.0 = Debug|Any CPU - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF}.Debug|x86.ActiveCfg = Debug|Any CPU - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF}.Debug|x86.Build.0 = Debug|Any CPU - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF}.Release|Any CPU.Build.0 = Release|Any CPU - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF}.Release|x64.ActiveCfg = Release|Any CPU - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF}.Release|x64.Build.0 = Release|Any CPU - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF}.Release|x86.ActiveCfg = Release|Any CPU - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF}.Release|x86.Build.0 = Release|Any CPU - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}.Debug|x64.ActiveCfg = Debug|Any CPU - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}.Debug|x64.Build.0 = Debug|Any CPU - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}.Debug|x86.ActiveCfg = Debug|Any CPU - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}.Debug|x86.Build.0 = Debug|Any CPU - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}.Release|Any CPU.Build.0 = Release|Any CPU - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}.Release|x64.ActiveCfg = Release|Any CPU - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}.Release|x64.Build.0 = Release|Any CPU - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}.Release|x86.ActiveCfg = Release|Any CPU - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC}.Release|x86.Build.0 = Release|Any CPU - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}.Debug|x64.ActiveCfg = Debug|Any CPU - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}.Debug|x64.Build.0 = Debug|Any CPU - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}.Debug|x86.ActiveCfg = Debug|Any CPU - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}.Debug|x86.Build.0 = Debug|Any CPU - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}.Release|Any CPU.Build.0 = Release|Any CPU - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}.Release|x64.ActiveCfg = Release|Any CPU - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}.Release|x64.Build.0 = Release|Any CPU - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}.Release|x86.ActiveCfg = Release|Any CPU - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {A2715FCD-D078-4E96-81C3-B833640921C5} = {C148F187-E600-4044-8944-2E9E52575B07} - {79462104-DCF9-4F9C-AC60-EA6E9F961EBF} = {C148F187-E600-4044-8944-2E9E52575B07} - {6F5F2BB6-AFD0-4935-8A1E-1E68A53E26CC} = {C148F187-E600-4044-8944-2E9E52575B07} - {C4A91F95-C5E2-4ACA-BC70-5168E2C565F4} = {C148F187-E600-4044-8944-2E9E52575B07} - EndGlobalSection -EndGlobal diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/Builders/DataStateBuilder.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/Builders/DataStateBuilder.cs index b588e200..8dcc9700 100644 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/Builders/DataStateBuilder.cs +++ b/src/Gameboard.Api.Tests.Integration/Fixtures/Builders/DataStateBuilder.cs @@ -2,14 +2,11 @@ namespace Gameboard.Api.Tests.Integration.Fixtures; -internal class DataStateBuilder : IDataStateBuilder where TDbContext : GameboardDbContext +internal class DataStateBuilder : IDataStateBuilder { - private readonly TDbContext _DbContext; + private readonly GameboardDbContext _DbContext; - public DataStateBuilder(TDbContext dbContext) - { - _DbContext = dbContext; - } + public DataStateBuilder(GameboardDbContext dbContext) => _DbContext = dbContext; public IDataStateBuilder Add(TEntity entity, Action? entityBuilder = null) where TEntity : class, IEntity { diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/DefaultEntityExtensions.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/DefaultEntityExtensions.cs index 3753c6b9..642ef82c 100644 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/DefaultEntityExtensions.cs +++ b/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/DefaultEntityExtensions.cs @@ -10,6 +10,9 @@ private static T BuildEntity(T entity, Action? builder = null) where T : c return entity; } + // eventually will replace these with registrations in the customization (like the integration test project does) + private static string GenerateTestGuid() => Guid.NewGuid().ToString("n"); + public static void AddChallengeSpec(this IDataStateBuilder dataStateBuilder, Action? specBuilder = null) => dataStateBuilder.Add(BuildChallengeSpec(dataStateBuilder, specBuilder)); @@ -18,7 +21,7 @@ public static Data.ChallengeSpec BuildChallengeSpec(this IDataStateBuilder dataS ( new Data.ChallengeSpec { - Id = TestIds.Generate(), + Id = GenerateTestGuid(), Game = BuildGame(dataStateBuilder), Name = "Integration Test Challenge Spec", AverageDeploySeconds = 1, @@ -51,7 +54,7 @@ public static Data.Game BuildGame(this IDataStateBuilder dataStateBuilder, Actio ( new Data.Game { - Id = TestIds.Generate(), + Id = GenerateTestGuid(), Name = "Test game", Competition = "Test competition", Season = "1", @@ -72,14 +75,14 @@ public static void AddPlayer(this IDataStateBuilder dataStateBuilder, Action? playerBuilder = null) { // TODO: this is potentially urky if the testing dev sets userid but not user's id - var userId = TestIds.Generate(); + var userId = GenerateTestGuid(); return BuildEntity ( new Data.Player { - Id = TestIds.Generate(), - TeamId = TestIds.Generate(), + Id = GenerateTestGuid(), + TeamId = GenerateTestGuid(), ApprovedName = "Integration Test Player", Sponsor = "Integration Test Sponsor", Role = Gameboard.Api.PlayerRole.Manager, @@ -184,7 +187,7 @@ public static Data.User BuildUser(this IDataStateBuilder dataStateBuilder, Actio ( new Data.User { - Id = TestIds.Generate(), + Id = GenerateTestGuid(), Username = "integrationtester", Email = "integration@test.com", Name = "integrationtester", @@ -198,7 +201,7 @@ public static Data.User BuildUser(this IDataStateBuilder dataStateBuilder, Actio public static IEnumerable BuildTeam(this IDataStateBuilder builder, int teamSize = 5, Action? playerBuilder = null) { var team = new List(); - var teamId = TestIds.Generate(); + var teamId = GenerateTestGuid(); for (var i = 0; i < teamSize; i++) { diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/GameboardTestContextExtensions.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/GameboardTestContextExtensions.cs index 1f246bdd..ea569f01 100644 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/GameboardTestContextExtensions.cs +++ b/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/GameboardTestContextExtensions.cs @@ -1,5 +1,4 @@ using System.Net.Http.Headers; -using Gameboard.Api; using Gameboard.Api.Data; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.Testing; @@ -9,7 +8,7 @@ namespace Gameboard.Api.Tests.Integration.Fixtures; internal static class GameboardTestContextExtensions { - private static WebApplicationFactory BuildAuthentication(this GameboardTestContext testContext, TestAuthenticationUser? actingUser = null) + private static WebApplicationFactory BuildAuthentication(this GameboardTestContext testContext, TestAuthenticationUser? actingUser = null) { return testContext .WithWebHostBuilder(builder => @@ -39,19 +38,16 @@ private static WebApplicationFactory BuildAuthentication(this Gameboard }); } - public static HttpClient CreateHttpClientWithActingUser(this GameboardTestContext testContext, Action? userBuilder = null) + public static HttpClient CreateHttpClientWithActingUser(this GameboardTestContext testContext, Action? userBuilder = null) { var user = new TestAuthenticationUser(); userBuilder?.Invoke(user); return BuildAuthentication(testContext, user) - .CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false - }); + .CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); } - public static HttpClient CreateHttpClientWithActingUser(this GameboardTestContext testContext, Api.Data.User user) + public static HttpClient CreateHttpClientWithActingUser(this GameboardTestContext testContext, Data.User user) { var client = testContext .CreateHttpClientWithActingUser(u => @@ -65,14 +61,14 @@ public static HttpClient CreateHttpClientWithActingUser(this GameboardTestContex return client; } - public static HttpClient CreateHttpClientWithAuthRole(this GameboardTestContext testContext, UserRole role) + public static HttpClient CreateHttpClientWithAuthRole(this GameboardTestContext testContext, UserRole role) => CreateHttpClientWithActingUser(testContext, u => u.Role = role); - public static async Task WithDataState(this GameboardTestContext context, Action builderAction) + public static async Task WithDataState(this GameboardTestContext context, Action builderAction) { var dbContext = context.GetDbContext(); - var builderInstance = new DataStateBuilder(dbContext); + var builderInstance = new DataStateBuilder(dbContext); builderAction.Invoke(builderInstance); await dbContext.SaveChangesAsync(); diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/SerializationExtensions.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/SerializationExtensions.cs index 835f87e6..dcb4c91f 100644 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/SerializationExtensions.cs +++ b/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/SerializationExtensions.cs @@ -12,9 +12,7 @@ internal static class SerializationExtensions public static StringContent ToJsonBody(this T obj) where T : class { // build gameboard-like serializer options - var opts = new JsonSerializerOptions(); - var builder = JsonService.BuildJsonSerializerOptions(); - builder(opts); + var opts = JsonService.GetJsonSerializerOptions(); // serialize and go return new StringContent(JsonSerializer.Serialize(obj, opts), Encoding.UTF8, MediaTypeNames.Application.Json); @@ -44,14 +42,13 @@ public static string ToQueryString(this object obj) response.EnsureSuccessStatusCode(); // we do this to ensure that we're deserializing with the same rules as gameboard is - var serializerOptions = new JsonSerializerOptions(); - JsonService.BuildJsonSerializerOptions()(serializerOptions); + var opts = JsonService.GetJsonSerializerOptions(); var rawResponse = await response.Content.ReadAsStringAsync(); try { - var deserialized = JsonSerializer.Deserialize(rawResponse, serializerOptions); + var deserialized = JsonSerializer.Deserialize(rawResponse, opts); if (deserialized != null) { @@ -63,6 +60,6 @@ public static string ToQueryString(this object obj) throw new ResponseContentDeserializationTypeFailure(rawResponse, ex); } - return default(T); + return default; } } diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/ServiceCollectionExtensions.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/ServiceCollectionExtensions.cs index 1e95da83..cf09b271 100644 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/ServiceCollectionExtensions.cs +++ b/src/Gameboard.Api.Tests.Integration/Fixtures/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,6 @@ namespace Gameboard.Api.Tests.Integration.Fixtures; -internal static class ServiceCollectionExtensions +public static class ServiceCollectionExtensions { public static void RemoveService(this IServiceCollection services) where I : class { @@ -42,17 +42,17 @@ public static void ReplaceService(this IServiceCollection services, C repl services.AddSingleton(replacement); } - private static ServiceDescriptor? FindService(IServiceCollection services) where T : class + public static ServiceDescriptor? FindService(this IServiceCollection services) where T : class { return services.SingleOrDefault(d => d.ServiceType == typeof(T)); } - private static ServiceDescriptor? FindService(IServiceCollection services) where I : class where C : class + public static ServiceDescriptor? FindService(this IServiceCollection services) where I : class where C : class { return services.SingleOrDefault(d => d.ServiceType == typeof(I) && d.ImplementationType == typeof(C)); } - private static ServiceDescriptor[] FindServices(IServiceCollection services) where I : class + public static ServiceDescriptor[] FindServices(this IServiceCollection services) where I : class { return services.Where(d => d.ServiceType == typeof(I)).ToArray(); } diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/GameboardTestContext.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/GameboardTestContext.cs index bc36c0df..f194ae7a 100644 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/GameboardTestContext.cs +++ b/src/Gameboard.Api.Tests.Integration/Fixtures/GameboardTestContext.cs @@ -1,33 +1,22 @@ -using DotNet.Testcontainers.Builders; -using DotNet.Testcontainers.Configurations; -using DotNet.Testcontainers.Containers; using Gameboard.Api.Data; using Gameboard.Api.Features.GameEngine; +using Gameboard.Api.Features.UnityGames; +using Gameboard.Api.Tests.Shared; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; +using Testcontainers.PostgreSql; namespace Gameboard.Api.Tests.Integration.Fixtures; -public class GameboardTestContext : WebApplicationFactory, IAsyncLifetime where TDbContext : GameboardDbContext -{ - private readonly TestcontainerDatabase _dbContainer; +[CollectionDefinition(TestCollectionNames.DbFixtureTests)] +public class DbTestCollection : ICollectionFixture { } - public GameboardTestContext() - { - _dbContainer = new TestcontainersBuilder() - .WithDatabase(new PostgreSqlTestcontainerConfiguration - { - Database = "GameboardIntegrationTestDb", - Username = "gameboard", - Password = "gameboard", - }) - .WithImage("postgres:latest") - .WithCleanUp(true) - .Build(); - } +public class GameboardTestContext : WebApplicationFactory, IAsyncLifetime +{ + private PostgreSqlContainer? _container; protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -35,18 +24,35 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.ConfigureServices(services => { + if (_container is null) + throw new GbAutomatedTestSetupException("Couldn't initialize the test context - the database contianer hasn't been resolved."); + // Add DB context with connection to the container - services.RemoveService(); - services.AddDbContext(options => options.UseNpgsql(_dbContainer.ConnectionString)); + services.RemoveService(); + // services.AddDbContext(builder => builder.UseNpgsql(_container.GetConnectionString())); + services.AddDbContext(builder => + { + builder.UseNpgsql(_container.GetConnectionString(), opts => opts.MigrationsAssembly("Gameboard.Api")); + }); + + // migrate the database (forces a blocking call) + // get the dbcontext type and use it to migrate (stand up) the database + // var builder = new DbContextOptionsBuilder().UseNpgsql(_container.GetConnectionString()); + // var dbContext = new GameboardTestDbContext(builder.Options); + // dbContext.Database.Migrate(); // Some services (like the stores) in Gameboard inject with GameboardDbContext rather than DbContext, // so we need to add an additional binding for them - services.AddTransient(); + // services.AddTransient(); + services.AddScoped(); + // var testDbContext = services.FindService(); + services.AddScoped(); // add user claims transformation that lets them all through services.ReplaceService(allowMultipleReplace: true); - // add a stand-in for the game engine service for now, because we don't have an instance for integration tests + // add a stand-in for external services + services.ReplaceService(); services.ReplaceService(); // dummy authorization service that lets everything through @@ -54,29 +60,27 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); } - public TDbContext GetDbContext() - { - return Services.GetRequiredService(); - } + public GameboardDbContext GetDbContext() => Services.GetRequiredService(); public async Task InitializeAsync() { - // start up our testcontainer with the db - await _dbContainer.StartAsync(); - - // get the dbcontext type and use it to migrate (stand up) the database - var dbContext = Services.GetRequiredService(); - if (dbContext == null) - { - throw new MissingServiceException("Attempting to stand up the testcontainers database but hit a missing dbContext service."); - } + _container = new PostgreSqlBuilder() + .WithHostname("localhost") + .WithPortBinding(5433) + .WithUsername("foundry") + .WithPassword("foundry") + .WithImage("postgres:latest") + .WithAutoRemove(true) + .WithCleanUp(true) + .Build(); - // ensure database migration - await Services.GetService()!.Database.MigrateAsync(); + // start up our testcontainer with the db + await _container.StartAsync(); } public new async Task DisposeAsync() { - await _dbContainer.DisposeAsync(); + if (_container is not null) + await _container.DisposeAsync(); } } diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/GbIntegrationAutoDataAttribute.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/GbIntegrationAutoDataAttribute.cs index 93934f1b..e01ac06c 100644 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/GbIntegrationAutoDataAttribute.cs +++ b/src/Gameboard.Api.Tests.Integration/Fixtures/GbIntegrationAutoDataAttribute.cs @@ -1,9 +1,22 @@ +using Gameboard.Api.Tests.Shared.Fixtures; + namespace Gameboard.Api.Tests.Integration.Fixtures; public class GbIntegrationAutoDataAttribute : AutoDataAttribute { - private static IFixture FIXTURE = new Fixture() + private static readonly IFixture FIXTURE = new Fixture() .Customize(new GameboardCustomization()); - public GbIntegrationAutoDataAttribute() : base(() => FIXTURE) { } + public GbIntegrationAutoDataAttribute() : base(() => + { + FIXTURE.Customizations.Add(new IdBuilder()); + return FIXTURE; + }) + { } } + +// public class GbIntegrationInlineAutoDataAttribute : CompositeDataAttribute +// { +// public GbIntegrationInlineAutoDataAttribute(params object[] fixedValues) +// : base(new InlineAutoDataAttribute(fixedValues), new GbIntegrationAutoDataAttribute()) { } +// } diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/GbIntegrationCustomization.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/GbIntegrationCustomization.cs deleted file mode 100644 index 76df03cf..00000000 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/GbIntegrationCustomization.cs +++ /dev/null @@ -1,84 +0,0 @@ - -public class GameboardCustomization : ICustomization -{ - public void Customize(IFixture fixture) - { - fixture.Customizations.Add(new IdBuilder()); - - fixture.Register(() => - new TopoMojo.Api.Client.GameState - { - Id = "75da686f-4d37-48cb-8755-80121ebf3647", - Name = "BenState", - ManagerId = "7fb2c8d6-c83f-412e-8e18-0e87ed3befcc", - ManagerName = "Sergei", - Markdown = "Here is some markdown _stuff_.", - Audience = "gameboard", - LaunchpointUrl = "https://google.com", - Players = new TopoMojo.Api.Client.Player[] - { - new TopoMojo.Api.Client.Player - { - GamespaceId = "33b9cf31-8686-4d95-b5a8-9fb1b7f8ce71", - SubjectId = "f4390dff-420d-47da-90c8-4c982eeab822", - SubjectName = "A cool player", - Permission = TopoMojo.Api.Client.Permission.None, - IsManager = true - } - }, - WhenCreated = DateTimeOffset.Now, - StartTime = DateTimeOffset.Now, - EndTime = DateTimeOffset.Now.AddMinutes(60), - ExpirationTime = DateTime.Now.AddMinutes(60), - IsActive = true, - Vms = new TopoMojo.Api.Client.VmState[] - { - new TopoMojo.Api.Client.VmState - { - Id = "10fccb66-6916-45e2-9a39-188d3a692d4a", - Name = "VM 1", - IsolationId = "vm1", - IsRunning = true, - IsVisible = true - }, - new TopoMojo.Api.Client.VmState - { - Id = "8d771689-8b37-48e7-b706-9efe1c64bdca", - Name = "VM 2", - IsolationId = "vm2", - IsRunning = true, - IsVisible = false - }, - }, - Challenge = new TopoMojo.Api.Client.ChallengeView - { - Text = "A challenging challenge", - MaxPoints = 100, - MaxAttempts = 3, - Attempts = 1, - Score = 50, - SectionCount = 1, - SectionIndex = 0, - SectionScore = 50, - SectionText = "The best one", - LastScoreTime = DateTimeOffset.Now.AddMinutes(5), - Questions = new TopoMojo.Api.Client.QuestionView[] - { - new TopoMojo.Api.Client.QuestionView - { - Text = "What is your quest?", - Hint = "It's not about swallows or whatever", - Answer = "swallows", - Example = "To be the very best, like no one ever was", - Weight = 0.5f, - Penalty = 0.2f, - IsCorrect = false, - IsGraded = true - } - } - } - } - ); - - } -} diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/Services/TestGameEngineService.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/Services/TestGameEngineService.cs index e78fce1a..321c0d6a 100644 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/Services/TestGameEngineService.cs +++ b/src/Gameboard.Api.Tests.Integration/Fixtures/Services/TestGameEngineService.cs @@ -38,6 +38,11 @@ public Task ExtendSession(Api.Data.Challenge entity, DateTimeOffset sessionEnd) return Task.CompletedTask; } + public Task GetChallengeState(GameEngineType gameEngineType, string stateJson) + { + return Task.FromResult(new GameEngineGameState()); + } + public Task GetConsole(Api.Data.Challenge entity, ConsoleRequest model, bool observer) { return Task.FromResult(new ConsoleSummary { }); diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/Services/TestGamebrainService.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/Services/TestGamebrainService.cs new file mode 100644 index 00000000..2d50a30f --- /dev/null +++ b/src/Gameboard.Api.Tests.Integration/Fixtures/Services/TestGamebrainService.cs @@ -0,0 +1,26 @@ +using Gameboard.Api.Features.UnityGames; + +namespace Gameboard.Api.Tests.Integration.Fixtures; + +internal class TestGamebrainService : IGamebrainService +{ + public Task DeployUnitySpace(string gameId, string teamId) + { + return Task.FromResult("{}"); + } + + public Task GetGameState(string gameId, string teamId) + { + return Task.FromResult("{}"); + } + + public Task UndeployUnitySpace(string gameId, string teamId) + { + return Task.FromResult(string.Empty); + } + + public Task UpdateConsoleUrls(string gameId, string teamId, IEnumerable vms) + { + return Task.CompletedTask; + } +} diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/TestCollectionNames.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/TestCollectionNames.cs new file mode 100644 index 00000000..53bfc59f --- /dev/null +++ b/src/Gameboard.Api.Tests.Integration/Fixtures/TestCollectionNames.cs @@ -0,0 +1,6 @@ +namespace Gameboard.Api.Tests.Integration.Fixtures; + +public class TestCollectionNames +{ + public const string DbFixtureTests = "db"; +} diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/TestIds.cs b/src/Gameboard.Api.Tests.Integration/Fixtures/TestIds.cs deleted file mode 100644 index 01ed5b5c..00000000 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/TestIds.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Gameboard.Api.Tests.Integration.Fixtures; - -internal static class TestIds -{ - public static string Generate() - => Guid.NewGuid().ToString("n"); -} diff --git a/src/Gameboard.Api.Tests.Integration/Gameboard.Api.Tests.Integration.csproj b/src/Gameboard.Api.Tests.Integration/Gameboard.Api.Tests.Integration.csproj index d1dde347..4e461c1f 100644 --- a/src/Gameboard.Api.Tests.Integration/Gameboard.Api.Tests.Integration.csproj +++ b/src/Gameboard.Api.Tests.Integration/Gameboard.Api.Tests.Integration.csproj @@ -11,27 +11,28 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all 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 49771e1d..245da311 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/ChallengeBonuses/ChallengeBonusControllerManualTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/ChallengeBonuses/ChallengeBonusControllerManualTests.cs @@ -1,13 +1,15 @@ using Gameboard.Api.Data; +using Gameboard.Api.Features.ChallengeBonuses; using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Tests.Integration; -public class ChallengeBonusControllerManualTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class ChallengeBonusControllerManualTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public ChallengeBonusControllerManualTests(GameboardTestContext testContext) + public ChallengeBonusControllerManualTests(GameboardTestContext testContext) { _testContext = testContext; } diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/ChallengeBonuses/ChallengeBonusListTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/ChallengeBonuses/ChallengeBonusListTests.cs index ef4be1d2..cbd64346 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/ChallengeBonuses/ChallengeBonusListTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/ChallengeBonuses/ChallengeBonusListTests.cs @@ -1,13 +1,15 @@ using Gameboard.Api.Data; +using Gameboard.Api.Features.ChallengeBonuses; using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Tests.Integration; -public class ChallengeBonusListTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class ChallengeBonusListTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public ChallengeBonusListTests(GameboardTestContext testContext) + public ChallengeBonusListTests(GameboardTestContext testContext) { _testContext = testContext; } diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Challenges/ChallengeControllerCreateTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Challenges/ChallengeControllerCreateTests.cs index 0d067ef0..3a2c234d 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Challenges/ChallengeControllerCreateTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Challenges/ChallengeControllerCreateTests.cs @@ -3,11 +3,12 @@ namespace Gameboard.Api.Tests.Integration; -public class ChallengeControllerCreateTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class ChallengeControllerCreateTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public ChallengeControllerCreateTests(GameboardTestContext testContext) + public ChallengeControllerCreateTests(GameboardTestContext testContext) { _testContext = testContext; } @@ -27,7 +28,7 @@ await _testContext.WithDataState(state => { spec.Id = challengeSpecId; spec.Name = specName; - spec.Game = new Api.Data.Game + spec.Game = new Data.Game { Id = fixture.Create(), Players = new Api.Data.Player[] @@ -35,7 +36,7 @@ await _testContext.WithDataState(state => state.BuildPlayer(p => { p.Id = playerId; - p.User = new Api.Data.User { Id = userId }; + p.User = new Data.User { Id = userId }; p.SessionBegin = DateTimeOffset.UtcNow.AddDays(-1); p.SessionEnd = DateTimeOffset.UtcNow.AddDays(1); }) diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/GameEngines/GameEngineControllerGetStateTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/GameEngines/GameEngineControllerGetStateTests.cs index 8277378e..beadbbac 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/GameEngines/GameEngineControllerGetStateTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/GameEngines/GameEngineControllerGetStateTests.cs @@ -4,11 +4,12 @@ namespace Gameboard.Api.Tests.Integration; -public class GameEngineControllerGetStateTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class GameEngineControllerGetStateTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public GameEngineControllerGetStateTests(GameboardTestContext testContext) + public GameEngineControllerGetStateTests(GameboardTestContext testContext) { _testContext = testContext; } @@ -36,7 +37,7 @@ await _testContext.WithDataState(state => state.AddChallenge(c => { c.Id = challenge1Id; - c.GameEngineType = Api.GameEngineType.TopoMojo; + c.GameEngineType = GameEngineType.TopoMojo; c.PlayerId = playerId; // NOTE: this isn't random - it's handcrafted so we can verify the data "tree" // See Fixtures/SpecimenBuilders/GameStateBuilder.cs @@ -47,14 +48,14 @@ await _testContext.WithDataState(state => state.AddChallenge(c => { c.Id = challenge2Id; - c.GameEngineType = Api.GameEngineType.TopoMojo; + c.GameEngineType = GameEngineType.TopoMojo; c.PlayerId = playerId; c.State = JsonSerializer.Serialize(state2); c.TeamId = teamId; }); }); - var httpClient = _testContext.CreateHttpClientWithAuthRole(Api.UserRole.Admin); + var httpClient = _testContext.CreateHttpClientWithAuthRole(UserRole.Admin); // when var results = await httpClient diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Games/GameControllerGetSyncStartStateTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Games/GameControllerGetSyncStartStateTests.cs index 444e2e89..85046fff 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Games/GameControllerGetSyncStartStateTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Games/GameControllerGetSyncStartStateTests.cs @@ -4,11 +4,12 @@ namespace Gameboard.Api.Tests.Integration; -public class GameControllerGetSyncStartStateTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class GameControllerGetSyncStartStateTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public GameControllerGetSyncStartStateTests(GameboardTestContext testContext) + public GameControllerGetSyncStartStateTests(GameboardTestContext testContext) { _testContext = testContext; } diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Games/GameControllerTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Games/GameControllerTests.cs index 7436858b..2dbb9299 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Games/GameControllerTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Games/GameControllerTests.cs @@ -3,11 +3,12 @@ namespace Gameboard.Api.Tests.Integration; -public class GameControllerTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class GameControllerTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public GameControllerTests(GameboardTestContext testContext) + public GameControllerTests(GameboardTestContext testContext) { _testContext = testContext; } diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerSessionResetTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerSessionResetTests.cs index 14b45737..ed5cecf0 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerSessionResetTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerSessionResetTests.cs @@ -5,11 +5,12 @@ namespace Gameboard.Api.Tests.Integration; -public class PlayerControllerSessionResetTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class PlayerControllerSessionResetTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public PlayerControllerSessionResetTests(GameboardTestContext testContext) + public PlayerControllerSessionResetTests(GameboardTestContext testContext) { _testContext = testContext; } @@ -84,43 +85,6 @@ await _testContext.WithDataState(s => var hasArchivedChallenges = await _testContext.GetDbContext().ArchivedChallenges.AnyAsync(c => c.TeamId == teamId); } - [Theory, GbIntegrationAutoData] - public async Task ResetSession_WithAutoResetAndPreserveTeam_PreservesChallenges(IFixture fixture, string teamId) - { - // given - TeamBuilderResult? result = null; - await _testContext.WithDataState(s => - { - result = s.AddTeam(fixture, opts => - { - opts.NumPlayers = 1; - opts.TeamId = teamId; - }); - }); - - if (result == null) - throw new GbAutomatedTestSetupException("AddTeam failed to return a result."); - - var player = result.Players.First(); - var httpClient = _testContext.CreateHttpClientWithActingUser(u => u.Id = player.UserId); - - // when - var response = await httpClient.PostAsync($"api/player/{player.Id}/session", new SessionResetRequest - { - IsManualReset = false, - UnenrollTeam = false - }.ToJsonBody()); - - // then - response.StatusCode.ShouldBe(HttpStatusCode.OK); - - var hasChallenges = await _testContext.GetDbContext().Challenges.AnyAsync(c => c.TeamId == teamId); - var hasArchivedChallenges = await _testContext.GetDbContext().ArchivedChallenges.AnyAsync(c => c.TeamId == teamId); - - hasArchivedChallenges.ShouldBeFalse(); - hasChallenges.ShouldBeTrue(); - } - [Theory, GbIntegrationAutoData] public async Task ResetSession_WithAlreadyArchivedChallenges_DoesntChoke(IFixture fixture, string teamId) { 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 1d963355..aa5e70a9 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerTests.cs @@ -3,11 +3,12 @@ namespace Gameboard.Api.Tests.Integration; -public class PlayerControllerTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class PlayerControllerTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public PlayerControllerTests(GameboardTestContext testContext) + public PlayerControllerTests(GameboardTestContext testContext) { _testContext = testContext; } @@ -60,14 +61,18 @@ await _testContext updatedPlayer?.NameStatus.ShouldBe(AppConstants.NameStatusNotUnique); } - [Theory] - [InlineData(0)] - [InlineData(1)] - public async Task GetCertificates_WhenScoreConstrained_ReturnsExpectedCount(int score) + [Theory, GbIntegrationAutoData] + public async Task GetCertificates_WhenScoreConstrained_ReturnsExpectedCount + ( + int score, + string scoringUserId, + string scoringPlayerId, + string nonScoringUserId, + string nonScoringPlayerId + ) { // given var now = DateTimeOffset.UtcNow; - var userId = TestIds.Generate(); await _testContext.WithDataState(state => { @@ -75,13 +80,24 @@ await _testContext.WithDataState(state => { g.GameEnd = now - TimeSpan.FromDays(1); g.CertificateTemplate = "This is a template with a {{player_count}}."; - g.Players = new Api.Data.Player[] + g.Players = new Data.Player[] { + // i almost broke my brain trying to get GbIntegrationAutoData to work with + // inline autodata, so I'm just doing two checks here state.BuildPlayer(p => { - p.Id = TestIds.Generate(); - p.User = new Api.Data.User { Id = userId }; - p.UserId = userId; + p.Id = scoringPlayerId; + p.User = new Data.User { Id = scoringUserId }; + p.UserId = scoringUserId; + p.SessionEnd = now - TimeSpan.FromDays(-2); + p.TeamId = "teamId"; + p.Score = score; + }), + state.BuildPlayer(p => + { + p.Id = nonScoringPlayerId; + p.User = new Data.User { Id = nonScoringUserId }; + p.UserId = nonScoringUserId; p.SessionEnd = now - TimeSpan.FromDays(-2); p.TeamId = "teamId"; p.Score = score; @@ -90,7 +106,7 @@ await _testContext.WithDataState(state => }); }); - var httpClient = _testContext.CreateHttpClientWithActingUser(u => u.Id = userId); + var httpClient = _testContext.CreateHttpClientWithActingUser(u => u.Id = scoringUserId); // when var certs = await httpClient @@ -98,30 +114,30 @@ await _testContext.WithDataState(state => .WithContentDeserializedAs>(); // then - certs?.Count().ShouldBe(score == 0 ? 0 : 1); + certs?.Count().ShouldBe(1); + certs?.First().Player.Id.ShouldBe(scoringPlayerId); } - [Fact] - public async Task GetCertificates_WithTeamsAndNonScorers_ReturnsExpected() + [Theory, GbIntegrationAutoData] + public async Task GetCertificates_WithTeamsAndNonScorers_ReturnsExpected(string userId, string playerId) { // given var now = DateTimeOffset.UtcNow; - var userId = TestIds.Generate(); - var playerId = TestIds.Generate(); var recentDate = DateTime.UtcNow.AddDays(-1); await _testContext.WithDataState(state => { - var allPlayers = new List(); - - allPlayers.Add(state.BuildPlayer(p => + var allPlayers = new List { - p.Id = TestIds.Generate(); - p.User = new Api.Data.User { Id = userId }; - p.UserId = userId; - p.SessionEnd = recentDate; - p.Score = 20; - })); + state.BuildPlayer(p => + { + p.Id = playerId; + p.User = new Data.User { Id = userId }; + p.UserId = userId; + p.SessionEnd = recentDate; + p.Score = 20; + }) + }; allPlayers.AddRange(state.BuildTeam(playerBuilder: p => { diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerUnenrollTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerUnenrollTests.cs index 9f1398f9..e66083c9 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerUnenrollTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerUnenrollTests.cs @@ -5,11 +5,12 @@ namespace Gameboard.Api.Tests.Integration; -public class PlayerControllerUnenrollTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class PlayerControllerUnenrollTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public PlayerControllerUnenrollTests(GameboardTestContext testContext) + public PlayerControllerUnenrollTests(GameboardTestContext testContext) { _testContext = testContext; } @@ -23,7 +24,7 @@ await _testContext { state.AddGame(g => { - g.Players = new Api.Data.Player[] + g.Players = new Data.Player[] { state.BuildPlayer(p => { @@ -44,7 +45,7 @@ await _testContext u.Id = memberUserId; u.Role = UserRole.Member; }); - p.Challenges = new Api.Data.Challenge[] + p.Challenges = new Data.Challenge[] { state.BuildChallenge(c => { diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerUpdateReadyStateTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerUpdateReadyStateTests.cs index 989552c9..49c75ac5 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerUpdateReadyStateTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Players/PlayerControllerUpdateReadyStateTests.cs @@ -1,14 +1,16 @@ using System.Net; using Gameboard.Api.Data; using Gameboard.Api.Features.Games; +using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Tests.Integration.Players; -public class PlayerControllerUpdatePlayerReadyTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class PlayerControllerUpdatePlayerReadyTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public PlayerControllerUpdatePlayerReadyTests(GameboardTestContext testContext) + public PlayerControllerUpdatePlayerReadyTests(GameboardTestContext testContext) { _testContext = testContext; } @@ -61,9 +63,11 @@ await _testContext.WithDataState(state => // then // only way to validate is to check for an upcoming session for the game - var finalPlayer1 = await client - .GetAsync($"/api/player/{notReadyPlayer1Id}") - .WithContentDeserializedAs(); + + var finalPlayer1 = await _testContext + .GetDbContext() + .Players + .SingleOrDefaultAsync(p => p.Id == notReadyPlayer1Id); response.StatusCode.ShouldBe(HttpStatusCode.OK); finalPlayer1.ShouldNotBeNull(); diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Scores/ScoringControllerTeamChallengeSummaryTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Scores/ScoringControllerTeamChallengeSummaryTests.cs index 55d5c118..23873732 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Scores/ScoringControllerTeamChallengeSummaryTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Scores/ScoringControllerTeamChallengeSummaryTests.cs @@ -1,19 +1,28 @@ using Gameboard.Api.Data; +using Gameboard.Api.Features.Scores; using Gameboard.Api.Tests.Shared; namespace Gameboard.Api.Tests.Integration; -public class ScoringControllerTeamChallengeSummaryTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class ScoringControllerTeamChallengeSummaryTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public ScoringControllerTeamChallengeSummaryTests(GameboardTestContext testContext) + public ScoringControllerTeamChallengeSummaryTests(GameboardTestContext testContext) { _testContext = testContext; } [Theory, GbIntegrationAutoData] - public async Task GetChallengeScore_WithFixedTeam_CalculatesScore(IFixture fixture, string teamId, string challengeId, int basePoints, int bonus1Points, int bonus2Points) + public async Task GetChallengeScore_WithFixedTeam_CalculatesScore + ( + IFixture fixture, + string teamId, + string challengeId, + int basePoints, + int bonus1Points, + int bonus2Points) { // given await _testContext.WithDataState(state => diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Scores/ScoringControllerTeamGameSummaryTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Scores/ScoringControllerTeamGameSummaryTests.cs index cf0e02c7..5c288718 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Scores/ScoringControllerTeamGameSummaryTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Scores/ScoringControllerTeamGameSummaryTests.cs @@ -1,13 +1,15 @@ using Gameboard.Api.Data; +using Gameboard.Api.Features.Scores; using Gameboard.Api.Tests.Shared; namespace Gameboard.Api.Tests.Integration; -public class ScoringControllerTeamGameSummaryTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class ScoringControllerTeamGameSummaryTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public ScoringControllerTeamGameSummaryTests(GameboardTestContext testContext) + public ScoringControllerTeamGameSummaryTests(GameboardTestContext testContext) { _testContext = testContext; } diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/UnityGames/UnityGameControllerTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/UnityGames/UnityGameControllerTests.cs index d3887f5d..9771bad7 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/UnityGames/UnityGameControllerTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/UnityGames/UnityGameControllerTests.cs @@ -3,17 +3,18 @@ namespace Gameboard.Api.Tests.Integration; -public class UnityGameControllerTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class UnityGameControllerTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public UnityGameControllerTests(GameboardTestContext testContext) + public UnityGameControllerTests(GameboardTestContext testContext) { _testContext = testContext; } - [Fact] - public async Task UnityGameController_CreateChallenge_DoesntReturnGraderKey() + [Theory, GbIntegrationAutoData] + public async Task UnityGameController_CreateChallenge_DoesntReturnGraderKey(string playerId, string gameId, string teamId) { // arrange await _testContext @@ -21,21 +22,21 @@ await _testContext { state.Add(state.BuildPlayer(), p => { - p.Id = "playerId"; + p.Id = playerId; p.Game = state.BuildGame(g => { - g.Id = "gameId"; + g.Id = gameId; g.Specs = new List { state.BuildChallengeSpec() }; }); - p.TeamId = "teamId"; + p.TeamId = teamId; }); }); var newChallenge = new NewUnityChallenge() { - GameId = "gameId", - PlayerId = "playerId", - TeamId = "teamId", + GameId = gameId, + PlayerId = playerId, + TeamId = teamId, MaxPoints = 50, GamespaceId = "gamespace", Vms = new UnityGameVm[] @@ -49,7 +50,7 @@ await _testContext } }; - var httpClient = _testContext.CreateHttpClientWithAuthRole(Api.UserRole.Admin); + var httpClient = _testContext.CreateHttpClientWithAuthRole(UserRole.Admin); // act var challenge = await httpClient diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Users/UserControllerTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Users/UserControllerTests.cs index 66d7f7db..6bc8284d 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Users/UserControllerTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Users/UserControllerTests.cs @@ -1,35 +1,53 @@ -using Gameboard.Api; using Gameboard.Api.Data; namespace Gameboard.Api.Tests.Integration.Users; -public class UserControllerTests : IClassFixture> +[Collection(TestCollectionNames.DbFixtureTests)] +public class UserControllerTests { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public UserControllerTests(GameboardTestContext testContext) + public UserControllerTests(GameboardTestContext testContext) { _testContext = testContext; } - [Fact] - public async Task Create_WhenDoesntExist_IsCreatedWithId() + [Theory, InlineAutoData] + public async Task Create_WhenDoesntExist_IsCreatedWithIdAndIsNewUser(string id) { // given - var newUser = new Gameboard.Api.NewUser(); + var newUser = new NewUser { Id = id }; // when var client = _testContext.CreateHttpClientWithAuthRole(UserRole.Registrar); var result = await client .PostAsync("api/user", newUser.ToJsonBody()) - .WithContentDeserializedAs(); + .WithContentDeserializedAs(); // then - result?.Id.ShouldNotBeNullOrEmpty(); + result?.User.Id.ShouldBe(id); + result?.IsNewUser.ShouldBeTrue(); } + // [Theory, InlineAutoData] + // public async Task Create_WhenDoesntExist_IsCreatedWithIdAndIsNewUser(string id) + // { + // // given + // var newUser = new NewUser { Id = id }; + + // // when + // var client = _testContext.CreateHttpClientWithAuthRole(UserRole.Registrar); + // var result = await client + // .PostAsync("api/user", newUser.ToJsonBody()) + // .WithContentDeserializedAs(); + + // // then + // result?.User.Id.ShouldBe(id); + // result?.IsNewUser.ShouldBeTrue(); + // } + [Fact] - public async Task Create_WhenExists_Throws() + public async Task Create_WhenExists_IsNotNewUser() { // given await _testContext @@ -49,9 +67,9 @@ await _testContext var result = await client .PostAsync("api/user", newUser.ToJsonBody()) - .WithContentDeserializedAs(); + .WithContentDeserializedAs(); // then - // result + result?.IsNewUser.ShouldBeFalse(); } } diff --git a/src/Gameboard.Api.Tests.Shared/Fixtures/GameboardCustomization.cs b/src/Gameboard.Api.Tests.Shared/Fixtures/GameboardCustomization.cs index 8223175b..600ae75a 100644 --- a/src/Gameboard.Api.Tests.Shared/Fixtures/GameboardCustomization.cs +++ b/src/Gameboard.Api.Tests.Shared/Fixtures/GameboardCustomization.cs @@ -1,14 +1,16 @@ using AutoFixture; -namespace Gameboard.Api.Tests.Shared; +namespace Gameboard.Api.Tests.Shared.Fixtures; public class GameboardCustomization : ICustomization { public void Customize(IFixture fixture) { - var now = DateTimeOffset.UtcNow; + fixture.Customizations.Add(new IdBuilder()); + fixture.Register(() => fixture); - fixture.Register(() => new Data.User + var now = DateTimeOffset.UtcNow; + fixture.Register(() => new Data.User { Id = fixture.Create(), Username = "testuser", @@ -17,7 +19,7 @@ public void Customize(IFixture fixture) Role = UserRole.Member }); - fixture.Register(() => new Data.Game + fixture.Register(() => new Data.Game { Id = fixture.Create(), Name = "A test game", @@ -26,22 +28,22 @@ public void Customize(IFixture fixture) IsPublished = true }); - fixture.Register(() => new Data.Player + fixture.Register(() => new Data.Player { Id = fixture.Create(), - TeamId = fixture.Create(), User = fixture.Create(), Game = fixture.Create(), ApprovedName = "Test Player", Sponsor = "Test Sponsor", Role = PlayerRole.Manager, + Score = 0, SessionBegin = now, SessionEnd = now.AddDays(1), - Score = 0, + TeamId = fixture.Create(), Mode = PlayerMode.Competition }); - fixture.Register(() => new Data.Challenge + fixture.Register(() => new Data.Challenge { Id = fixture.Create(), Name = "A test challenge", @@ -56,5 +58,78 @@ public void Customize(IFixture fixture) HasDeployedGamespace = false, GameEngineType = GameEngineType.TopoMojo }); + + fixture.Register(() => new TopoMojo.Api.Client.GameState + { + Id = "75da686f-4d37-48cb-8755-80121ebf3647", + Name = "BenState", + ManagerId = "7fb2c8d6-c83f-412e-8e18-0e87ed3befcc", + ManagerName = "Sergei", + Markdown = "Here is some markdown _stuff_.", + Audience = "gameboard", + LaunchpointUrl = "https://google.com", + Players = new TopoMojo.Api.Client.Player[] + { + new TopoMojo.Api.Client.Player + { + GamespaceId = "33b9cf31-8686-4d95-b5a8-9fb1b7f8ce71", + SubjectId = "f4390dff-420d-47da-90c8-4c982eeab822", + SubjectName = "A cool player", + Permission = TopoMojo.Api.Client.Permission.None, + IsManager = true + } + }, + WhenCreated = DateTimeOffset.Now, + StartTime = DateTimeOffset.Now, + EndTime = DateTimeOffset.Now.AddMinutes(60), + ExpirationTime = DateTime.Now.AddMinutes(60), + IsActive = true, + Vms = new TopoMojo.Api.Client.VmState[] + { + new TopoMojo.Api.Client.VmState + { + Id = "10fccb66-6916-45e2-9a39-188d3a692d4a", + Name = "VM 1", + IsolationId = "vm1", + IsRunning = true, + IsVisible = true + }, + new TopoMojo.Api.Client.VmState + { + Id = "8d771689-8b37-48e7-b706-9efe1c64bdca", + Name = "VM 2", + IsolationId = "vm2", + IsRunning = true, + IsVisible = false + }, + }, + Challenge = new TopoMojo.Api.Client.ChallengeView + { + Text = "A challenging challenge", + MaxPoints = 100, + MaxAttempts = 3, + Attempts = 1, + Score = 50, + SectionCount = 1, + SectionIndex = 0, + SectionScore = 50, + SectionText = "The best one", + LastScoreTime = DateTimeOffset.Now.AddMinutes(5), + Questions = new TopoMojo.Api.Client.QuestionView[] + { + new TopoMojo.Api.Client.QuestionView + { + Text = "What is your quest?", + Hint = "It's not about swallows or whatever", + Answer = "swallows", + Example = "To be the very best, like no one ever was", + Weight = 0.5f, + Penalty = 0.2f, + IsCorrect = false, + IsGraded = true + } + } + } + }); } } diff --git a/src/Gameboard.Api.Tests.Integration/Fixtures/SpecimenBuilders/IdBuilder.cs b/src/Gameboard.Api.Tests.Shared/Fixtures/SpecimenBuilders/IdBuilder.cs similarity index 80% rename from src/Gameboard.Api.Tests.Integration/Fixtures/SpecimenBuilders/IdBuilder.cs rename to src/Gameboard.Api.Tests.Shared/Fixtures/SpecimenBuilders/IdBuilder.cs index 8d014175..a0c1a21a 100644 --- a/src/Gameboard.Api.Tests.Integration/Fixtures/SpecimenBuilders/IdBuilder.cs +++ b/src/Gameboard.Api.Tests.Shared/Fixtures/SpecimenBuilders/IdBuilder.cs @@ -1,7 +1,7 @@ using System.Reflection; using AutoFixture.Kernel; -namespace Gameboard.Api.Tests.Integration.Fixtures; +namespace Gameboard.Api.Tests.Shared.Fixtures; public class IdBuilder : ISpecimenBuilder { @@ -10,15 +10,13 @@ public object Create(object request, ISpecimenContext context) var name = string.Empty; Type argumentType = typeof(object); - var pi = request as PropertyInfo; - if (pi != null) + if (request is PropertyInfo pi) { name = pi.Name; argumentType = pi.PropertyType; } - var rpi = request as ParameterInfo; - if (rpi != null) + if (request is ParameterInfo rpi) { name = rpi.Name; argumentType = rpi.ParameterType; diff --git a/src/Gameboard.Api.Tests.Shared/Gameboard.Api.Tests.Shared.csproj b/src/Gameboard.Api.Tests.Shared/Gameboard.Api.Tests.Shared.csproj index 3e4583f0..165299e7 100644 --- a/src/Gameboard.Api.Tests.Shared/Gameboard.Api.Tests.Shared.csproj +++ b/src/Gameboard.Api.Tests.Shared/Gameboard.Api.Tests.Shared.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Gameboard.Api.Tests.Unit/Fixtures/Exceptions.cs b/src/Gameboard.Api.Tests.Unit/Fixtures/Exceptions.cs new file mode 100644 index 00000000..f1f0c482 --- /dev/null +++ b/src/Gameboard.Api.Tests.Unit/Fixtures/Exceptions.cs @@ -0,0 +1,6 @@ +namespace Gameboard.Api.Tests.Unit.Fixtures; + +public class GameboardApiTestsFakingException : Exception +{ + public GameboardApiTestsFakingException(string message) : base(message) { } +} diff --git a/src/Gameboard.Api.Tests.Unit/Fixtures/FakeBuilder.cs b/src/Gameboard.Api.Tests.Unit/Fixtures/FakeBuilder.cs new file mode 100644 index 00000000..d1266ac8 --- /dev/null +++ b/src/Gameboard.Api.Tests.Unit/Fixtures/FakeBuilder.cs @@ -0,0 +1,48 @@ +using FakeItEasy.Sdk; + +namespace Gameboard.Api.Tests.Unit; + +public static class FakeBuilder +{ + /// + /// Constructs and automatically hydrates a T with fakes for each of its constructor parameters. + /// + /// + /// A T constructed with A.Fake() for each of its constructor parameters + public static T BuildMeA(params object[] fixedParameterValues) where T : class + { + if (fixedParameterValues.GroupBy(v => v.GetType()).Count() < fixedParameterValues.Count()) + { + throw new GameboardApiTestsFakingException($"Can't FakeBuild a {typeof(T).Name} because two or more of the supplied parameter values are of the same type."); + } + + var constructors = typeof(T).GetConstructors(); + + if (constructors.Count() > 1) + throw new GameboardApiTestsFakingException($"Can't FakeBuild a {typeof(T).Name} because it has multiple constructors."); + + var constructor = constructors.First(); + var parameters = constructor.GetParameters(); + + if (parameters.GroupBy(v => v.ParameterType).Count() < parameters.Count()) + throw new GameboardApiTestsFakingException($"Can't FakeBuild a {typeof(T).Name} because its constructor has multiple parameters of the same type."); + + var parametersToConstructor = new List(); + foreach (var parameter in parameters) + { + var fixedParameterValue = fixedParameterValues.FirstOrDefault(v => parameter.ParameterType.IsAssignableFrom(v.GetType())); + + if (fixedParameterValue != null) + parametersToConstructor.Add(fixedParameterValue); + else + parametersToConstructor.Add(Create.Fake(parameter.ParameterType)); + } + + var retVal = constructor.Invoke(parametersToConstructor.ToArray()) as T; + + if (retVal == null) + throw new GameboardApiTestsFakingException($"Can't FakeBuild a {typeof(T).Name} - Constructor returned null"); + + return retVal; + } +} diff --git a/src/Gameboard.Api.Tests.Unit/Fixtures/GameboardAutoDataAttribute.cs b/src/Gameboard.Api.Tests.Unit/Fixtures/GameboardAutoDataAttribute.cs index bb897b28..31bbb1cc 100644 --- a/src/Gameboard.Api.Tests.Unit/Fixtures/GameboardAutoDataAttribute.cs +++ b/src/Gameboard.Api.Tests.Unit/Fixtures/GameboardAutoDataAttribute.cs @@ -1,10 +1,10 @@ -using Gameboard.Api.Tests.Shared; +using Gameboard.Api.Tests.Shared.Fixtures; namespace Gameboard.Api.Tests.Unit.Fixtures; public class GameboardAutoDataAttribute : AutoDataAttribute { - private static IFixture FIXTURE = new Fixture() + private static readonly IFixture FIXTURE = new Fixture() .Customize(new AutoFakeItEasyCustomization()) .Customize(new GameboardCustomization()); diff --git a/src/Gameboard.Api.Tests.Unit/Gameboard.Api.Tests.Unit.csproj b/src/Gameboard.Api.Tests.Unit/Gameboard.Api.Tests.Unit.csproj index b1527c41..55607e50 100644 --- a/src/Gameboard.Api.Tests.Unit/Gameboard.Api.Tests.Unit.csproj +++ b/src/Gameboard.Api.Tests.Unit/Gameboard.Api.Tests.Unit.csproj @@ -14,15 +14,16 @@ - + - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Data/QueryExtensionsTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Data/QueryExtensionsTests.cs new file mode 100644 index 00000000..20d67024 --- /dev/null +++ b/src/Gameboard.Api.Tests.Unit/Tests/Data/QueryExtensionsTests.cs @@ -0,0 +1,20 @@ +using Gameboard.Api.Data; + +namespace Gameboard.Api.Tests.Unit; + +public class QueryExtensionsTests +{ + [Fact] + public void WhereHasDateValue_WithMinDate_ExcludesFromResults() + { + // given + var player = new Player { SessionBegin = DateTimeOffset.MinValue }; + var dataSet = new Player[] { player }.BuildMock(); + + // when + var results = dataSet.WhereDateIsNotEmpty(p => p.SessionBegin); + + // then + results.ShouldBeEmpty(); + } +} diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs index 2bce51f0..3509fc5a 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs @@ -1,7 +1,8 @@ using AutoMapper; -using Gameboard.Api; +using Gameboard.Api.Data; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.GameEngine; +using Gameboard.Api.Features.Practice; using Gameboard.Api.Services; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -53,11 +54,11 @@ string userId { Id = gameId, MaxTeamSize = 1, - Prerequisites = new Api.Data.ChallengeGate[] { } + Prerequisites = Array.Empty() }; - var fakeGames = new Api.Data.Game[] { fakeGame }.BuildMock(); + var fakeGames = new Data.Game[] { fakeGame }.BuildMock(); - var fakeSpec = new Api.Data.ChallengeSpec + var fakeSpec = new Data.ChallengeSpec { Id = specId, ExternalId = specExternalId @@ -105,6 +106,7 @@ string userId A.Fake(), A.Fake(), A.Fake(), + A.Fake(), A.Fake() ); @@ -124,4 +126,121 @@ string userId // then result.Id.ShouldBe(gamespaceId); } + + /// + /// When a challenge is created, its ID should match the gamespaceId returned by the game engine. + /// + /// + /// + /// + /// + /// + /// + /// + /// + [Theory, GameboardAutoData] + public async Task BuildAndRegister_WithGamePlayerModePractice_ShouldProducePracticeModeChallenge + ( + string gameId, + string playerId, + string gamespaceId, + string graderKey, + string graderUrl, + string specId, + string specExternalId, + string teamId, + string userId + ) + { + // given + var newChallenge = new NewChallenge + { + PlayerId = playerId, + SpecId = specId + }; + + var fakePlayer = new Api.Data.Player + { + Id = playerId, + GameId = gameId, + TeamId = teamId + }; + + var fakeGame = new Data.Game + { + Id = gameId, + MaxTeamSize = 1, + PlayerMode = PlayerMode.Practice, + Prerequisites = Array.Empty() + }; + var fakeGames = new Data.Game[] { fakeGame }.BuildMock(); + + var fakeSpec = new Data.ChallengeSpec + { + Id = specId, + ExternalId = specExternalId + }; + + var fakeGameEngineService = A.Fake(); + A + .CallTo(() => fakeGameEngineService.RegisterGamespace + ( + new GameEngineChallengeRegistration + { + Challenge = new Api.Data.Challenge { }, + ChallengeSpec = fakeSpec, + Game = fakeGame, + Player = fakePlayer, + GraderKey = graderKey, + GraderUrl = graderUrl, + PlayerCount = 1, + Variant = 0 + } + )) + .WithAnyArguments() + .Returns + ( + new GameEngineGameState + { + Id = gamespaceId, + IsActive = true, + StartTime = DateTimeOffset.Now, + EndTime = DateTimeOffset.Now.AddDays(1), + } + ); + + var sut = new ChallengeService + ( + A.Fake>(), + A.Fake(), + A.Fake(), + A.Fake(), + A.Fake>(), + fakeGameEngineService, + A.Fake(), + A.Fake(), + A.Fake(), + A.Fake(), + A.Fake(), + A.Fake(), + A.Fake(), + A.Fake() + ); + + // when + var result = await sut.BuildAndRegisterChallenge + ( + newChallenge, + fakeSpec, + fakeGame, + fakePlayer, + userId, + graderUrl, + 1, + 0 + ); + + // then + result.PlayerMode.ShouldBe(PlayerMode.Practice); + } } diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/GameEngine/GameEngineMapsTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/GameEngine/GameEngineMapsTests.cs index b0f96e70..88868239 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/GameEngine/GameEngineMapsTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/GameEngine/GameEngineMapsTests.cs @@ -142,7 +142,7 @@ public void MapStateToChallenge_WithIsActiveTrueAndNoVms_YieldsHasActiveGamespac StartTime = fixture.Create(), EndTime = fixture.Create(), ExpirationTime = fixture.Create(), - Vms = new GameEngineVmState[] { } + Vms = Array.Empty() }; var mapperConfig = new MapperConfiguration(cfg => cfg.AddProfile(new GameEngineMaps())); 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 97e88f23..561eda86 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs @@ -1,10 +1,10 @@ using AutoMapper; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.GameEngine; -using Gameboard.Api.Features.Games; +using Gameboard.Api.Features.Practice; using Gameboard.Api.Features.Teams; +using Gameboard.Api.Hubs; using Gameboard.Api.Services; -using MediatR; using Microsoft.Extensions.Caching.Memory; namespace Gameboard.Api.Tests.Unit; @@ -14,31 +14,31 @@ public class PlayerServiceTests class PlayerServiceTestable : PlayerService { private PlayerServiceTestable( - CoreOptions coreOptions, ChallengeService challengeService, + CoreOptions coreOptions, IPlayerStore store, - IUserStore userStore, - IGameHubBus gameHubBus, IGameService gameService, IGameStore gameStore, IGuidService guidService, - IMediator mediator, IInternalHubBus hubBus, + IPracticeChallengeScoringListener practiceChallengeScoringListener, + IPracticeService practiceService, + INowService now, ITeamService teamService, IMapper mapper, IMemoryCache localCache, GameEngineService gameEngine) : base ( - coreOptions, challengeService, + coreOptions, guidService, - mediator, + now, store, - userStore, - gameHubBus, gameService, gameStore, hubBus, + practiceChallengeScoringListener, + practiceService, teamService, mapper, localCache, @@ -52,13 +52,13 @@ internal static PlayerService GetTestable( CoreOptions? coreOptions = null, ChallengeService? challengeService = null, IPlayerStore? store = null, - IUserStore? userStore = null, - IGameHubBus? gameHubBus = null, IGameService? gameService = null, IGameStore? gameStore = null, IGuidService? guidService = null, - IMediator? mediator = null, + INowService? now = null, IInternalHubBus? hubBus = null, + IPracticeChallengeScoringListener? practiceChallengeScoringListener = null, + IPracticeService? practiceService = null, ITeamService? teamService = null, IMapper? mapper = null, IMemoryCache? localCache = null, @@ -66,16 +66,16 @@ internal static PlayerService GetTestable( { return new PlayerService ( - coreOptions ?? A.Fake(), challengeService ?? A.Fake(), + coreOptions ?? A.Fake(), guidService ?? A.Fake(), - mediator ?? A.Fake(), + now ?? A.Fake(), store ?? A.Fake(), - userStore ?? A.Fake(), - gameHubBus ?? A.Fake(), gameService ?? A.Fake(), gameStore ?? A.Fake(), hubBus ?? A.Fake(), + practiceChallengeScoringListener ?? A.Fake(), + practiceService ?? A.Fake(), teamService ?? A.Fake(), mapper ?? A.Fake(), localCache ?? A.Fake(), @@ -106,7 +106,7 @@ public async Task MakeCertificates_WhenScoreZero_ReturnsEmptyArray(IFixture fixt var fakeStore = A.Fake(); var fakePlayers = new Api.Data.Player[] { - new Api.Data.Player + new Data.Player { PartialCount = 1, Game = new Api.Data.Game @@ -138,12 +138,12 @@ public async Task MakeCertificates_WhenScore1_ReturnsOneCertificate(IFixture fix // arrange var userId = fixture.Create(); var fakeStore = A.Fake(); - var fakePlayers = new Api.Data.Player[] + var fakePlayers = new Data.Player[] { - new Api.Data.Player + new Data.Player { PartialCount = 0, - Game = new Api.Data.Game + Game = new Data.Game { CertificateTemplate = fixture.Create(), GameEnd = DateTimeOffset.Now - TimeSpan.FromDays(1) @@ -151,7 +151,7 @@ public async Task MakeCertificates_WhenScore1_ReturnsOneCertificate(IFixture fix Score = 1, SessionEnd = DateTimeOffset.Now - TimeSpan.FromDays(2), UserId = userId, - User = new Api.Data.User { Id = userId } + User = new Data.User { Id = userId } } }.ToList().BuildMock(); 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 57dcceb9..ef1909ef 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/TeamServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Player/TeamServiceTests.cs @@ -1,6 +1,8 @@ using AutoMapper; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.Teams; +using Gameboard.Api.Hubs; +using Gameboard.Api.Services; namespace Gameboard.Api.Tests.Unit; diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/GetPracticeModeCertificatePngTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/GetPracticeModeCertificatePngTests.cs new file mode 100644 index 00000000..b394e5e9 --- /dev/null +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/GetPracticeModeCertificatePngTests.cs @@ -0,0 +1,84 @@ +using Gameboard.Api.Data; +using Gameboard.Api.Features.Certificates; +using Gameboard.Api.Features.Practice; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Validators; + +namespace Gameboard.Api.Tests.Unit; + +public class GetPracticeModeCertificateHtmlTests +{ + private readonly GetPracticeModeCertificateHtmlHandler _sut; + + public GetPracticeModeCertificateHtmlTests() + { + _sut = new GetPracticeModeCertificateHtmlHandler + ( + A.Fake>(options => + { + options.WithArgumentsForConstructor(() => new EntityExistsValidator(A.Fake())); + }), + A.Fake>(options => + { + options.WithArgumentsForConstructor(() => new EntityExistsValidator(A.Fake())); + }), + A.Fake(), + A.Fake(), + A.Fake(), + A.Fake>() + ); + } + + [Fact] + public void GetDurationDescription_WithHoursAndMinutes_ResolvesExpected() + { + // given + var duration = TimeSpan.FromMinutes(62); + + // when + var result = _sut.GetDurationDescription(duration); + + // then + result.ToLower().ShouldBe("1 hour and 2 minutes"); + } + + [Fact] + public void GetDurationDescription_WithHours_HidesMinutes() + { + // given + var duration = TimeSpan.FromHours(2); + + // when + var result = _sut.GetDurationDescription(duration); + + // then + result.ToLower().ShouldBe("2 hours"); + } + + [Fact] + public void GetDurationDescription_WithMinutes_HidesHours() + { + // given + var duration = TimeSpan.FromMinutes(39); + + // when + var result = _sut.GetDurationDescription(duration); + + // then + result.ToLower().ShouldBe("39 minutes"); + } + + [Fact] + public void GetDurationDescription_WithHms_HidesSeconds() + { + // given + // 1 hour, 3 minutes, 4 seconds + var duration = TimeSpan.FromSeconds(3787); + + // when + var result = _sut.GetDurationDescription(duration); + + // then + result.ToLower().ShouldBe("1 hour and 3 minutes"); + } +} diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Reports/EnrollmentReportServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Reports/EnrollmentReportServiceTests.cs new file mode 100644 index 00000000..67272aed --- /dev/null +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Reports/EnrollmentReportServiceTests.cs @@ -0,0 +1,112 @@ +using Gameboard.Api.Data; +using Gameboard.Api.Features.Reports; +using Gameboard.Api.Services; + +namespace Gameboard.Api.Tests.Unit; + +public class EnrollmentReportServiceTests +{ + [Theory, GameboardAutoData] + public async Task GetResults_WithOnePlayerAndChallenge_ReportsCompleteSolve(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; + + var player = fixture.Create(); + player.Challenges = new Data.Challenge[] { challenge }; + player.Game.PlayerMode = PlayerMode.Competition; + 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); + results.Records.First().ChallengesCompletelySolvedCount.ShouldBe(1); + } + + [Theory, GameboardAutoData] + public async Task GetResults_WithOneTeamRecord_ReportsExpectedValues(IFixture fixture) + { + // given + var sponsors = new List + { + new Data.Sponsor + { + Id = "good-people", + Name = "The Good People", + Logo = "good-people.jpg", + Approved = true + }, + new Data.Sponsor + { + Id = "bad-eggs", + Name = "The Bad Eggs", + Logo = "bad-eggs.jpg", + Approved = true + } + }.BuildMock(); + + var challenge = fixture.Create(); + challenge.Points = 50; + challenge.Score = 50; + + var player1 = fixture.Create(); + player1.Challenges = new Data.Challenge[] { challenge }; + player1.Game.PlayerMode = PlayerMode.Competition; + player1.Sponsor = sponsors.First().Logo; + player1.Role = PlayerRole.Manager; + + var player2 = fixture.Create(); + player2.Game = player1.Game; + player2.GameId = player1.GameId; + player2.TeamId = player1.TeamId; + player2.Sponsor = "bad-eggs.jpg"; + + var players = new List { player1, player2 }.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(2); + results.Records.First().Team.Sponsors.Count().ShouldBe(2); + results.Records.SelectMany(r => r.Challenges).DistinctBy(c => c.SpecId).Count().ShouldBe(1); + } +} diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Reports/SupportReportServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Reports/SupportReportServiceTests.cs new file mode 100644 index 00000000..005df6bb --- /dev/null +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Reports/SupportReportServiceTests.cs @@ -0,0 +1,58 @@ +using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Features.Reports; +using Gameboard.Api.Services; + +namespace Gameboard.Api.Tests.Unit; + +public class SupportReportServiceTests +{ + [Fact] + public void GetDateTimeSupportWindow_ForEachWindow_CorrectlyCalculates() + { + // can't InlineData these across three theories because they're not constant expressions + // arrange + var tenAMDate = new DateTimeOffset(new DateTime(2023, 5, 9, 10, 0, 0)); + var sevenPmDate = new DateTimeOffset(new DateTime(2023, 5, 9, 19, 49, 0)); + var oneAmDate = new DateTimeOffset(new DateTime(2023, 5, 9, 3, 0, 0)); + var sut = FakeBuilder.BuildMeA(); + + // act + var tenAmWindow = sut.GetTicketDateSupportWindow(tenAMDate); + var sevenPmWindow = sut.GetTicketDateSupportWindow(sevenPmDate); + var oneAmWindow = sut.GetTicketDateSupportWindow(oneAmDate); + + // assert + tenAmWindow.ShouldBe(SupportReportTicketWindow.BusinessHours); + sevenPmWindow.ShouldBe(SupportReportTicketWindow.EveningHours); + oneAmWindow.ShouldBe(SupportReportTicketWindow.OffHours); + } + + [Fact] + public async Task QueryRecords_ByMinutesOpen_ExcludesNewerTickets() + { + // arrange + // pretend it's 10:30am on 5/9/2023 + var now = A.Fake(); + A.CallTo(() => now.Get()).Returns(new DateTimeOffset(new DateTime(2023, 5, 9, 10, 30, 0))); + + var tickets = new Data.Ticket[] + { + // this ticket was created at 8:58am on 5/9/2023, or about an hour and a half ago + new Data.Ticket { Created = new DateTimeOffset(new DateTime(2023, 5, 9, 8, 58, 0)) }, + // this one was created 2 minutes ago + new Data.Ticket { Created = new DateTimeOffset(new DateTime(2023, 5, 9, 10, 28, 0)) } + }.BuildMock(); + + var ticketStore = A.Fake(); + A.CallTo(() => ticketStore.ListWithNoTracking()).Returns(tickets); + + var parameters = new SupportReportParameters { MinutesSinceOpen = 60 }; + var sut = FakeBuilder.BuildMeA(now, ticketStore); + + // act + var results = await sut.QueryRecords(parameters); + + // assert + results.Count().ShouldBe(1); + } +} diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/UnityGames/UnityGameServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/UnityGames/UnityGameServiceTests.cs index b6456f2b..810b9674 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/UnityGames/UnityGameServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/UnityGames/UnityGameServiceTests.cs @@ -1,5 +1,4 @@ using Gameboard.Api.Features.UnityGames; -using Gameboard.Api.Tests.Unit.Fixtures; namespace Gameboard.Api.Tests.Unit; diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/User/UserServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/User/UserServiceTests.cs index 23bb8527..cc75a565 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/User/UserServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/User/UserServiceTests.cs @@ -14,7 +14,7 @@ public class UserServiceTests public void HasRole_WithMatchingRole_ReturnsTrue(UserRole usersRoles, UserRole targetRole) { // given - var userService = new UserService(A.Fake(), A.Fake(), A.Fake(), A.Fake(), A.Fake()); + var userService = new UserService(A.Fake(), A.Fake>(), A.Fake(), A.Fake(), A.Fake(), A.Fake()); var user = new User() { Role = usersRoles }; // when @@ -31,7 +31,7 @@ public void HasRole_WithMatchingRole_ReturnsTrue(UserRole usersRoles, UserRole t public void HasRole_WithoutMatchingRole_ReturnsFalse(UserRole usersRoles, UserRole targetRole) { // given - var userService = new UserService(A.Fake(), A.Fake(), A.Fake(), A.Fake(), A.Fake()); + var userService = new UserService(A.Fake(), A.Fake>(), A.Fake(), A.Fake(), A.Fake(), A.Fake()); var user = new User() { Role = usersRoles }; // when diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/_ControllerTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/_ControllerTests.cs index 81cd461f..1d9517de 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/_ControllerTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/_ControllerTests.cs @@ -8,9 +8,9 @@ namespace Gameboard.Api.Tests.Unit; // _Controller is inherited by every api controller in GameboardApi. To access some of its implementation for testing, we subclass it here. -public class _ControllerTestable : _Controller +public class ControllerTestable : _Controller { - public _ControllerTestable(ILogger logger, IDistributedCache cache, params IModelValidator[] validators) + public ControllerTestable(ILogger logger, IDistributedCache cache, params IModelValidator[] validators) : base(logger, cache, validators) { } public void AuthorizeAllTestable(Func[] requirements) @@ -29,14 +29,14 @@ internal void SetActor(User user) } } -public class _ControllerTests +public class ControllerTests { - private _ControllerTestable GetControllerTestable(User? withActor = null) + private ControllerTestable GetControllerTestable(User? withActor = null) { - var controllerTestable = new _ControllerTestable( + var controllerTestable = new ControllerTestable( A.Fake(), A.Fake(), - new IModelValidator[] { } + Array.Empty() ); if (withActor != null) diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Structure/ApiKeyAuthenticationHandlerTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Structure/ApiKeyAuthenticationHandlerTests.cs index 44cd8dad..f6069c3e 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Structure/ApiKeyAuthenticationHandlerTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Structure/ApiKeyAuthenticationHandlerTests.cs @@ -1,8 +1,6 @@ using System.Text.Encodings.Web; -using Gameboard.Api; -using Gameboard.Api.Auth; -using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.ApiKeys; +using Gameboard.Api.Structure.Auth; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -14,13 +12,13 @@ namespace Gameboard.Api.Tests.Unit; public class ApiKeyAuthenticationHandlerTests { // TODO: constructor fixture thing - private ApiKeyAuthenticationHandler GetSut() => new ApiKeyAuthenticationHandler + private ApiKeyAuthenticationHandler GetSut() => new ( - A.Fake>(), - A.Fake(), - A.Fake(), - A.Fake(), - A.Fake() + A.Fake>(), + A.Fake(), + A.Fake(), + A.Fake(), + A.Fake() ); [Theory, GameboardAutoData] diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Structure/ApiKeyServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Structure/ApiKeyServiceTests.cs index d09f1fe6..1ece0a54 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Structure/ApiKeyServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Structure/ApiKeyServiceTests.cs @@ -1,5 +1,4 @@ using AutoMapper; -using Gameboard.Api; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.ApiKeys; using Gameboard.Api.Services; @@ -8,17 +7,16 @@ namespace Gameboard.Api.Tests.Unit; public class ApiKeyServiceTests { - private ApiKeysService GetSut(ApiKeyOptions options, IRandomService? random = null) => new ApiKeysService - ( - options, - A.Fake(), - A.Fake(), - A.Fake(), - A.Fake(), - random ?? A.Fake(), - A.Fake(), - A.Fake() - ); + private ApiKeysService GetSut(ApiKeyOptions? options = null, IRandomService? random = null, IStore? userStore = null) => new + ( + options ?? new ApiKeyOptions { RandomCharactersLength = 15 }, + A.Fake(), + A.Fake(), + A.Fake(), + random ?? A.Fake(), + A.Fake(), + userStore ?? A.Fake>() + ); [Theory, InlineAutoData(3)] public void GeneratePlainKey_WithRandomnessLength_GeneratesExpectedLength(int randomnessLength, string randomness) @@ -61,4 +59,38 @@ public void GeneratePlainKey_WithFixedValues_GeneratesExpectedKey(string randomn // assert result.ShouldBe("1234567890"); } + + [Theory, GameboardAutoData] + public void GetUserFromApiKey_WithUserAssignedKey_ResolvesUser(string apiKey, IFixture fixture) + { + // given + var userStore = A.Fake>(); + var fakeUsers = new Data.User[] + { + new () + { + ApiKeys = new Data.ApiKey[] + { + new () + { + Id = fixture.Create(), + Name = fixture.Create(), + Key = apiKey.ToSha256(), + GeneratedOn = fixture.Create(), + OwnerId = fixture.Create() + } + }, + Enrollments = Array.Empty() + } + }.BuildMock(); + + A.CallTo(() => userStore.ListWithNoTracking()).Returns(fakeUsers); + var sut = GetSut(userStore: userStore); + + // when + var result = sut.GetUserFromApiKey(apiKey); + + // then + result.ShouldNotBeNull(); + } } diff --git a/src/Gameboard.Api.sln b/src/Gameboard.Api.sln new file mode 100644 index 00000000..5da9b89f --- /dev/null +++ b/src/Gameboard.Api.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard.Api", "Gameboard.Api\Gameboard.Api.csproj", "{B43DA9BD-444C-4965-8CDA-D3574F32F048}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard.Api.Tests.Integration", "Gameboard.Api.Tests.Integration\Gameboard.Api.Tests.Integration.csproj", "{7E62FF88-574C-4B98-A96D-47D32CFD32C9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard.Api.Tests.Shared", "Gameboard.Api.Tests.Shared\Gameboard.Api.Tests.Shared.csproj", "{848FC395-F375-4BD7-9C2D-91BD6EC1A724}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard.Api.Tests.Unit", "Gameboard.Api.Tests.Unit\Gameboard.Api.Tests.Unit.csproj", "{441C3EDF-824D-45BD-AA62-84BF03BBD4A5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B43DA9BD-444C-4965-8CDA-D3574F32F048}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B43DA9BD-444C-4965-8CDA-D3574F32F048}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B43DA9BD-444C-4965-8CDA-D3574F32F048}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B43DA9BD-444C-4965-8CDA-D3574F32F048}.Release|Any CPU.Build.0 = Release|Any CPU + {7E62FF88-574C-4B98-A96D-47D32CFD32C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E62FF88-574C-4B98-A96D-47D32CFD32C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E62FF88-574C-4B98-A96D-47D32CFD32C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E62FF88-574C-4B98-A96D-47D32CFD32C9}.Release|Any CPU.Build.0 = Release|Any CPU + {848FC395-F375-4BD7-9C2D-91BD6EC1A724}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {848FC395-F375-4BD7-9C2D-91BD6EC1A724}.Debug|Any CPU.Build.0 = Debug|Any CPU + {848FC395-F375-4BD7-9C2D-91BD6EC1A724}.Release|Any CPU.ActiveCfg = Release|Any CPU + {848FC395-F375-4BD7-9C2D-91BD6EC1A724}.Release|Any CPU.Build.0 = Release|Any CPU + {441C3EDF-824D-45BD-AA62-84BF03BBD4A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {441C3EDF-824D-45BD-AA62-84BF03BBD4A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {441C3EDF-824D-45BD-AA62-84BF03BBD4A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {441C3EDF-824D-45BD-AA62-84BF03BBD4A5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/Gameboard.Api/Common/CommonModels.cs b/src/Gameboard.Api/Common/CommonModels.cs new file mode 100644 index 00000000..9a1cce3c --- /dev/null +++ b/src/Gameboard.Api/Common/CommonModels.cs @@ -0,0 +1,33 @@ + +namespace Gameboard.Api.Common; + +public class SimpleEntity +{ + public string Id { get; set; } + public string Name { get; set; } +} + +public sealed class PagingParameters +{ + public required int PageNumber { get; set; } + public required int PageSize { get; set; } +} + +public enum GameEngineMode +{ + Cubespace, + External, + Vm +} + +public sealed class GameCardContext +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required GameEngineMode EngineMode { get; set; } + public required int LiveSessionCount { get; set; } + public required string Logo { get; set; } + public required bool IsPractice { get; set; } + public required bool IsPublished { get; set; } + public required bool IsTeamGame { get; set; } +} diff --git a/src/Gameboard.Api/Common/ExpressionVisitors.cs b/src/Gameboard.Api/Common/ExpressionVisitors.cs new file mode 100644 index 00000000..40b124e6 --- /dev/null +++ b/src/Gameboard.Api/Common/ExpressionVisitors.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq.Expressions; + +namespace Gameboard.Api.Common; + +public abstract class Visitor +{ + private readonly string _prefix; + private readonly Expression _node; + + protected Visitor(Expression node) => _node = node; + protected Visitor(Expression node, string logPrefix) => (_node, _prefix) = (node, logPrefix); + + public abstract void Visit(); + + public ExpressionType NodeType => _node.NodeType; + public static Visitor CreateFromExpression(Expression node) + { + return node.NodeType switch + { + ExpressionType.Constant => new ConstantVisitor((ConstantExpression)node), + ExpressionType.Lambda => new LambdaVisitor((LambdaExpression)node), + ExpressionType.Parameter => new ParameterVisitor((ParameterExpression)node), + ExpressionType.NotEqual => new BinaryVisitor((BinaryExpression)node), + ExpressionType.MemberAccess => new MemberVisitor((MemberExpression)node), + _ => throw new NotImplementedException($"Node not processed yet: {node.NodeType}") + }; + } + + protected void Log(string message) => System.Diagnostics.Debug.WriteLine($"{(string.IsNullOrWhiteSpace(_prefix) ? string.Empty : _prefix)}{message}"); +} + +public class LambdaVisitor : Visitor +{ + public static ReadOnlyCollection Parameters; + private readonly LambdaExpression _node; + + public LambdaVisitor(LambdaExpression node) : base(node) => _node = node; + public LambdaVisitor(LambdaExpression node, string logPrefix) : base(node, logPrefix) { } + + public override void Visit() + { + Log($"This expression is a {NodeType} expression type"); + Log($"The name of the lambda is {_node.Name ?? ""}"); + Log($"The return type is {_node.ReturnType}"); + Log($"The expression has {_node.Parameters.Count} argument(s). They are:"); + + // Visit each parameter: + Parameters = _node.Parameters; + foreach (var argumentExpression in _node.Parameters) + { + var argumentVisitor = CreateFromExpression(argumentExpression); + argumentVisitor.Visit(); + } + Log($"The expression body is:"); + + // Visit the body: + var bodyVisitor = CreateFromExpression(_node.Body); + bodyVisitor.Visit(); + } +} + +public class BinaryVisitor : Visitor +{ + private readonly BinaryExpression node; + public BinaryVisitor(BinaryExpression node) : base(node) => this.node = node; + + public override void Visit() + { + Log($"This binary expression is a {NodeType} expression"); + var left = CreateFromExpression(node.Left); + Log($"The Left argument is:"); + left.Visit(); + var right = CreateFromExpression(node.Right); + Log($"The Right argument is:"); + right.Visit(); + } +} + +public class ParameterVisitor : Visitor +{ + private readonly ParameterExpression node; + public ParameterVisitor(ParameterExpression node) : base(node) + { + this.node = node; + } + + public override void Visit() + { + Log($"This is an {NodeType} expression type"); + Log($"Type: {node.Type}, Name: {node.Name}, ByRef: {node.IsByRef}"); + } +} + +public class ConstantVisitor : Visitor +{ + private readonly ConstantExpression node; + public ConstantVisitor(ConstantExpression node) : base(node) => this.node = node; + + public override void Visit() + { + Log($"This is an {NodeType} expression type"); + Log($"The type of the constant value is {node.Type}"); + Log($"The value of the constant value is {node.Value}"); + } +} + +public class MemberVisitor : Visitor +{ + public readonly MemberExpression _node; + public MemberVisitor(MemberExpression node) : base(node) => _node = node; + + public override void Visit() + { + Log($"Node type: {NodeType} expression"); + Log($"The member is {_node.Member.MemberType} {_node.Member.Name}"); + Log($"Its value is {GetValue(_node)}"); + } + + private object GetValue(MemberExpression member) + { + var playerExpression = Expression.Convert(LambdaVisitor.Parameters[0], typeof(object)); + var playerGetterLambda = Expression.Lambda>(playerExpression); + var playerGetter = playerGetterLambda.Compile(); + + var objectMember = Expression.Convert(member, typeof(object)); + var getterLambda = Expression.Lambda>(objectMember, LambdaVisitor.Parameters[0]); + var getter = getterLambda.Compile(); + return getter(playerGetter() as Player); + } +} diff --git a/src/Gameboard.Api/Common/Extensions/EnumerableExtensions.cs b/src/Gameboard.Api/Common/Extensions/EnumerableExtensions.cs new file mode 100644 index 00000000..791573df --- /dev/null +++ b/src/Gameboard.Api/Common/Extensions/EnumerableExtensions.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Gameboard.Api.Common; + +public static class EnumerableExtensions +{ + public static bool IsNotEmpty(this IEnumerable enumerable) + => enumerable is not null && enumerable.Any(); + + public static bool IsEmpty(this IEnumerable enumerable) + => !IsNotEmpty(enumerable); + + public static IEnumerable ToEnumerable(this T thing) + => new T[] { thing }; +} diff --git a/src/Gameboard.Api/Common/Services/PagingService.cs b/src/Gameboard.Api/Common/Services/PagingService.cs new file mode 100644 index 00000000..21afd587 --- /dev/null +++ b/src/Gameboard.Api/Common/Services/PagingService.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Gameboard.Api.Common; + +public sealed class PagedEnumerable +{ + public required IEnumerable Items { get; set; } + public required PagingResults Paging { get; set; } +} + +public sealed class PagingResults +{ + public required int ItemCount { get; set; } + public required int? PageNumber { get; set; } + public required int? PageSize { get; set; } +} + +public sealed class PagingArgs +{ + public int? DefaultPageSize { get; set; } = null; + public int? PageNumber { get; set; } = null; + public int? PageSize { get; set; } = null; +} + +public interface IPagingService +{ + PagedEnumerable Page(IEnumerable items, PagingArgs pagingArgs = null); +} + +internal class PagingService : IPagingService +{ + private static readonly int DEFAULT_PAGE_SIZE = 20; + + public PagedEnumerable Page(IEnumerable items, PagingArgs pagingArgs = null) + { + var finalItems = items ?? Array.Empty(); + var itemCount = finalItems.Count(); + + if (pagingArgs is null) + { + pagingArgs = new() + { + PageNumber = 0, + PageSize = DEFAULT_PAGE_SIZE, + }; + } + else + { + pagingArgs.PageSize ??= DEFAULT_PAGE_SIZE; + pagingArgs.PageNumber ??= 0; + } + + + finalItems = finalItems + .Skip(pagingArgs.PageNumber.Value * pagingArgs.PageSize.Value) + .Take(pagingArgs.PageSize.Value); + + return new PagedEnumerable + { + Items = finalItems, + Paging = new PagingResults + { + ItemCount = itemCount, + PageNumber = pagingArgs.PageNumber, + PageSize = pagingArgs.PageSize + } + }; + } +} diff --git a/src/Gameboard.Api/Common/Services/TimeWindowService.cs b/src/Gameboard.Api/Common/Services/TimeWindowService.cs new file mode 100644 index 00000000..3e0bdd9b --- /dev/null +++ b/src/Gameboard.Api/Common/Services/TimeWindowService.cs @@ -0,0 +1,73 @@ +using System; +using Gameboard.Api.Services; + +namespace Gameboard.Api.Features.Player; + +public class TimeWindow +{ + public double Now { get; } + public double? Start { get; } + public double? End { get; } + public double? DurationMs { get; private set; } + public TimeWindowState State { get; private set; } + public double? MsTilStart { get; private set; } + public double? MsTilEnd { get; private set; } + + public TimeWindow(DateTimeOffset now, DateTimeOffset? start, DateTimeOffset? end) + { + Now = now.ToUnixTimeMilliseconds(); + Start = start?.ToUnixTimeMilliseconds(); + End = end?.ToUnixTimeMilliseconds(); + + State = TimeWindowState.Before; + if (start != null && now >= start && (end is null || now < end)) + { + State = TimeWindowState.During; + } + else if (end != null && now >= end) + { + State = TimeWindowState.After; + } + + MsTilStart = start is null ? null : (Start - Now); + MsTilEnd = end is null ? null : (End - Now); + DurationMs = start == null ? null : ((End ?? Now) - Start); + } +} + +public enum TimeWindowState +{ + Before, + During, + After +} + +public interface ITimeWindowService +{ + TimeWindow CreateWindow(DateTimeOffset start, DateTimeOffset end); + TimeWindow CreateWindow(DateTimeOffset now, DateTimeOffset start, DateTimeOffset end); +} + +public class TimeWindowService : ITimeWindowService +{ + private readonly INowService _now; + + public TimeWindowService(INowService now) + { + _now = now; + } + + public TimeWindow CreateWindow(DateTimeOffset start, DateTimeOffset end) + => CreateWindow(_now.Get(), start, end); + + public TimeWindow CreateWindow(DateTimeOffset now, DateTimeOffset start, DateTimeOffset end) + { + DateTimeOffset? finalStart = start == DateTimeOffset.MinValue ? null : start; + DateTimeOffset? finalEnd = end == DateTimeOffset.MinValue ? null : end; + + if (finalStart != null && finalEnd != null && finalStart.Value >= finalEnd.Value) + throw new ArgumentException("Can't create a time window with end date occurring before the start date."); + + return new TimeWindow(now, finalStart, finalEnd); + } +} diff --git a/src/Gameboard.Api/Common/Tools/StartProcessAsync.cs b/src/Gameboard.Api/Common/Tools/StartProcessAsync.cs new file mode 100644 index 00000000..5d187c8d --- /dev/null +++ b/src/Gameboard.Api/Common/Tools/StartProcessAsync.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; +using System.Threading.Tasks; + +namespace Gameboard.Api.Common; + +public static class StartProcessAsync +{ + public static Task StartAsync(string command, string[] args = null) + { + + var tcs = new TaskCompletionSource(); + var startInfo = new ProcessStartInfo { FileName = command }; + + if (args is not null) + foreach (var arg in args) { startInfo.ArgumentList.Add(arg); } + + var process = new Process + { + StartInfo = startInfo, + EnableRaisingEvents = true + }; + + process.Exited += (sender, args) => + { + tcs.SetResult(process.ExitCode); + process.Dispose(); + }; + + process.Start(); + return tcs.Task; + } +} diff --git a/src/Gameboard.Api/Data/DataStartupExtensions.cs b/src/Gameboard.Api/Data/DataStartupExtensions.cs index 900c4175..38fe81c9 100644 --- a/src/Gameboard.Api/Data/DataStartupExtensions.cs +++ b/src/Gameboard.Api/Data/DataStartupExtensions.cs @@ -11,7 +11,8 @@ namespace Microsoft.Extensions.DependencyInjection { public static class DataStartupExtensions { - public static IServiceCollection AddGameboardData( + public static IServiceCollection AddGameboardData + ( this IServiceCollection services, string provider, string connstr @@ -19,7 +20,6 @@ string connstr { switch (provider.ToLower()) { - case "sqlserver": services.AddDbContext( builder => builder.UseSqlServer(connstr) @@ -30,8 +30,8 @@ string connstr services.AddDbContext( builder => builder.UseNpgsql(connstr) ); - break; + break; default: services.AddDbContext( builder => builder.UseInMemoryDatabase("Gameboard_Db") diff --git a/src/Gameboard.Api/Data/Entities/ArchivedChallenge.cs b/src/Gameboard.Api/Data/Entities/ArchivedChallenge.cs index 2ad839e0..3f00a514 100644 --- a/src/Gameboard.Api/Data/Entities/ArchivedChallenge.cs +++ b/src/Gameboard.Api/Data/Entities/ArchivedChallenge.cs @@ -18,6 +18,7 @@ public class ArchivedChallenge : IEntity public DateTimeOffset LastScoreTime { get; set; } public DateTimeOffset LastSyncTime { get; set; } public bool HasGamespaceDeployed { get; set; } + public PlayerMode PlayerMode { get; set; } public string State { get; set; } public int Points { get; set; } public int Score { get; set; } diff --git a/src/Gameboard.Api/Data/Entities/Challenge.cs b/src/Gameboard.Api/Data/Entities/Challenge.cs index fee26510..88a635f7 100644 --- a/src/Gameboard.Api/Data/Entities/Challenge.cs +++ b/src/Gameboard.Api/Data/Entities/Challenge.cs @@ -19,6 +19,7 @@ public class Challenge : IEntity public string State { get; set; } public int Points { get; set; } public double Score { get; set; } + public PlayerMode PlayerMode { get; set; } public DateTimeOffset LastScoreTime { get; set; } public DateTimeOffset LastSyncTime { get; set; } public DateTimeOffset WhenCreated { get; set; } @@ -35,7 +36,7 @@ public class Challenge : IEntity : ChallengeResult.None; [NotMapped] - public long Duration => StartTime.NotEmpty() && LastScoreTime.NotEmpty() + public long Duration => StartTime.HasValue() && LastScoreTime.HasValue() ? (long)LastScoreTime.Subtract(StartTime).TotalMilliseconds : 0; diff --git a/src/Gameboard.Api/Data/Entities/ChallengeSpec.cs b/src/Gameboard.Api/Data/Entities/ChallengeSpec.cs index fc188e35..73b42217 100644 --- a/src/Gameboard.Api/Data/Entities/ChallengeSpec.cs +++ b/src/Gameboard.Api/Data/Entities/ChallengeSpec.cs @@ -27,5 +27,6 @@ public class ChallengeSpec : IEntity public Game Game { get; set; } public ICollection Feedback { get; set; } = new List(); // public ICollection Bonuses { get; set; } = new List(); + public ICollection PublishedPracticeCertificates { get; set; } = new List(); } } diff --git a/src/Gameboard.Api/Data/Entities/Game.cs b/src/Gameboard.Api/Data/Entities/Game.cs index 8d78a538..bd2354e3 100644 --- a/src/Gameboard.Api/Data/Entities/Game.cs +++ b/src/Gameboard.Api/Data/Entities/Game.cs @@ -3,77 +3,77 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Gameboard.Api.Data +namespace Gameboard.Api.Data; + +public class Game : IEntity { - public class Game : IEntity - { - public string Id { get; set; } - public string Name { get; set; } - public string Competition { get; set; } - public string Season { get; set; } - public string Track { get; set; } - public string Division { get; set; } - 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; } - public string FeedbackConfig { get; set; } - public string CertificateTemplate { get; set; } - public string RegistrationMarkdown { get; set; } - public DateTimeOffset RegistrationOpen { get; set; } - public DateTimeOffset RegistrationClose { get; set; } - public GameRegistrationType RegistrationType { get; set; } - public string RegistrationConstraint { get; set; } - public int MinTeamSize { get; set; } = 1; - public int MaxTeamSize { get; set; } = 1; - public int MaxAttempts { get; set; } = 0; - public int SessionMinutes { get; set; } = 60; - public int SessionLimit { get; set; } = 0; - public int GamespaceLimitPerSession { get; set; } = 1; - public bool IsPublished { get; set; } - public bool RequireSponsoredTeam { get; set; } - public bool RequireSynchronizedStart { get; set; } = false; - public bool AllowPreview { get; set; } - public bool AllowReset { get; set; } - public string Key { get; set; } - public string CardText1 { get; set; } - public string CardText2 { get; set; } - public string CardText3 { get; set; } - public string Mode { get; set; } - public PlayerMode PlayerMode { get; set; } + public string Id { get; set; } + public string Name { get; set; } + public string Competition { get; set; } + public string Season { get; set; } + public string Track { get; set; } + public string Division { get; set; } + 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; } + public string FeedbackConfig { get; set; } + public string CertificateTemplate { get; set; } + public string RegistrationMarkdown { get; set; } + public DateTimeOffset RegistrationOpen { get; set; } + public DateTimeOffset RegistrationClose { get; set; } + public GameRegistrationType RegistrationType { get; set; } + public string RegistrationConstraint { get; set; } + public int MinTeamSize { get; set; } = 1; + public int MaxTeamSize { get; set; } = 1; + public int MaxAttempts { get; set; } = 0; + public int SessionMinutes { get; set; } = 60; + public int SessionLimit { get; set; } = 0; + public int GamespaceLimitPerSession { get; set; } = 1; + public bool IsPublished { get; set; } + public bool RequireSponsoredTeam { get; set; } + public bool RequireSynchronizedStart { get; set; } = false; + public bool AllowPreview { get; set; } + public bool AllowReset { get; set; } + public string Key { get; set; } + public string CardText1 { get; set; } + public string CardText2 { get; set; } + public string CardText3 { get; set; } + public string Mode { get; set; } + public PlayerMode PlayerMode { get; set; } + + public ICollection Specs { get; set; } = new List(); + public ICollection Players { get; set; } = new List(); + public ICollection Challenges { get; set; } = new List(); + public ICollection Feedback { get; set; } = new List(); + public ICollection Prerequisites { get; set; } = new List(); + public ICollection PublishedCompetitiveCertificates { get; set; } + + [NotMapped] public bool RequireSession => SessionMinutes > 0; + [NotMapped] public bool RequireTeam => MinTeamSize > 1; + [NotMapped] public bool AllowTeam => MaxTeamSize > 1; + + [NotMapped] + public bool IsLive => + GameStart != DateTimeOffset.MinValue && + GameStart.CompareTo(DateTimeOffset.UtcNow) < 0 && + GameEnd.CompareTo(DateTimeOffset.UtcNow) > 0; + + [NotMapped] + public bool HasEnded => + GameEnd.CompareTo(DateTimeOffset.UtcNow) < 0; - public ICollection Specs { get; set; } = new List(); - public ICollection Players { get; set; } = new List(); - public ICollection Challenges { get; set; } = new List(); - public ICollection Prerequisites { get; set; } = new List(); - public ICollection Feedback { get; set; } = new List(); + [NotMapped] + public bool RegistrationActive => + RegistrationType != GameRegistrationType.None && + RegistrationOpen.CompareTo(DateTimeOffset.UtcNow) < 0 && + RegistrationClose.CompareTo(DateTimeOffset.UtcNow) > 0; - [NotMapped] public bool RequireSession => SessionMinutes > 0; - [NotMapped] public bool RequireTeam => MinTeamSize > 1; - [NotMapped] public bool AllowTeam => MaxTeamSize > 1; - [NotMapped] - public bool IsLive => - GameStart != DateTimeOffset.MinValue && - GameStart.CompareTo(DateTimeOffset.UtcNow) < 0 && - GameEnd.CompareTo(DateTimeOffset.UtcNow) > 0 - ; - [NotMapped] - public bool HasEnded => - GameEnd.CompareTo(DateTimeOffset.UtcNow) < 0 - ; - [NotMapped] - public bool RegistrationActive => - RegistrationType != GameRegistrationType.None && - RegistrationOpen.CompareTo(DateTimeOffset.UtcNow) < 0 && - RegistrationClose.CompareTo(DateTimeOffset.UtcNow) > 0 - ; - [NotMapped] public bool IsPracticeMode => PlayerMode == PlayerMode.Practice; - [NotMapped] public bool IsCompetitionMode => PlayerMode == PlayerMode.Competition; - } + [NotMapped] public bool IsCompetitionMode => PlayerMode == PlayerMode.Competition; + [NotMapped] public bool IsPracticeMode => PlayerMode == PlayerMode.Practice; } diff --git a/src/Gameboard.Api/Data/Entities/Player.cs b/src/Gameboard.Api/Data/Entities/Player.cs index 57d912b1..397d9581 100644 --- a/src/Gameboard.Api/Data/Entities/Player.cs +++ b/src/Gameboard.Api/Data/Entities/Player.cs @@ -5,47 +5,47 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; -namespace Gameboard.Api.Data +namespace Gameboard.Api.Data; + +public class Player : IEntity { - public class Player : IEntity - { - public string Id { get; set; } - public string TeamId { get; set; } - public string UserId { get; set; } - public string GameId { get; set; } - public string ApprovedName { get; set; } - public string Name { get; set; } - public string NameStatus { get; set; } - public string Sponsor { get; set; } - public string TeamSponsors { get; set; } - public string InviteCode { get; set; } - public bool IsReady { get; set; } - public PlayerRole Role { get; set; } - public DateTimeOffset SessionBegin { get; set; } - public DateTimeOffset SessionEnd { get; set; } - public int SessionMinutes { get; set; } - public int Rank { get; set; } - public int Score { get; set; } - public long Time { get; set; } - public int CorrectCount { get; set; } - public int PartialCount { get; set; } - public bool Advanced { get; set; } - public PlayerMode Mode { get; set; } - public User User { get; set; } - public Game Game { get; set; } - public ICollection Challenges { get; set; } = new List(); - [NotMapped] public bool IsManager => Role == PlayerRole.Manager; - [NotMapped] public bool IsPractice => Mode == PlayerMode.Practice; - [NotMapped] public bool IsCompetition => Mode == PlayerMode.Competition; - [NotMapped] - public bool IsLive => - SessionBegin > DateTimeOffset.MinValue && - SessionBegin < DateTimeOffset.UtcNow && - SessionEnd > DateTimeOffset.UtcNow; + public string Id { get; set; } + public string TeamId { get; set; } + public string UserId { get; set; } + public string GameId { get; set; } + public string ApprovedName { get; set; } + public string Name { get; set; } + public string NameStatus { get; set; } + public string Sponsor { get; set; } + public string TeamSponsors { get; set; } + public string InviteCode { get; set; } + public bool IsReady { get; set; } + public PlayerRole Role { get; set; } + public DateTimeOffset SessionBegin { get; set; } + public DateTimeOffset SessionEnd { get; set; } + public int SessionMinutes { get; set; } + public int Rank { get; set; } + public int Score { get; set; } + public long Time { get; set; } + public int CorrectCount { get; set; } + public int PartialCount { get; set; } + public bool Advanced { get; set; } + public PlayerMode Mode { get; set; } + public User User { get; set; } + public Game Game { get; set; } + public ICollection Challenges { get; set; } = new List(); + public DateTimeOffset WhenCreated { get; set; } - // Control delete behavior with relationships - public ICollection Feedback { get; set; } = new List(); - public ICollection Tickets { get; set; } = new List(); - } + [NotMapped] public bool IsManager => Role == PlayerRole.Manager; + [NotMapped] public bool IsPractice => Mode == PlayerMode.Practice; + [NotMapped] public bool IsCompetition => Mode == PlayerMode.Competition; + [NotMapped] + public bool IsLive => + SessionBegin > DateTimeOffset.MinValue && + SessionBegin < DateTimeOffset.UtcNow && + SessionEnd > DateTimeOffset.UtcNow; + // Control delete behavior with relationships + public ICollection Feedback { get; set; } = new List(); + public ICollection Tickets { get; set; } = new List(); } diff --git a/src/Gameboard.Api/Data/Entities/PracticeModeSettings.cs b/src/Gameboard.Api/Data/Entities/PracticeModeSettings.cs new file mode 100644 index 00000000..c2a65f50 --- /dev/null +++ b/src/Gameboard.Api/Data/Entities/PracticeModeSettings.cs @@ -0,0 +1,17 @@ +using System; + +namespace Gameboard.Api.Data; + +public class PracticeModeSettings : IEntity +{ + public string Id { get; set; } + public string CertificateHtmlTemplate { get; set; } + public int DefaultPracticeSessionLengthMinutes { get; set; } + public string IntroTextMarkdown { get; set; } + public int? MaxConcurrentPracticeSessions { get; set; } + public int? MaxPracticeSessionLengthMinutes { get; set; } + public DateTimeOffset? UpdatedOn { get; set; } + + public User UpdatedByUser { get; set; } + public string UpdatedByUserId { get; set; } +} diff --git a/src/Gameboard.Api/Data/Entities/PublishedCertificate.cs b/src/Gameboard.Api/Data/Entities/PublishedCertificate.cs new file mode 100644 index 00000000..b460fc0b --- /dev/null +++ b/src/Gameboard.Api/Data/Entities/PublishedCertificate.cs @@ -0,0 +1,32 @@ +using System; + +namespace Gameboard.Api.Data; + +public enum PublishedCertificateMode +{ + Competitive = 0, + Practice = 1 +} + +public abstract class PublishedCertificate : IEntity +{ + public string Id { get; set; } + public DateTimeOffset PublishedOn { get; set; } + public PublishedCertificateMode Mode { get; set; } + + // navigation + public string OwnerUserId { get; set; } + public User OwnerUser { get; set; } +} + +public class PublishedPracticeCertificate : PublishedCertificate, IEntity +{ + public string ChallengeSpecId { get; set; } + public ChallengeSpec ChallengeSpec { get; set; } +} + +public class PublishedCompetitiveCertificate : PublishedCertificate, IEntity +{ + public string GameId { get; set; } + public Game Game { get; set; } +} diff --git a/src/Gameboard.Api/Data/Entities/User.cs b/src/Gameboard.Api/Data/Entities/User.cs index aa982164..38840f72 100644 --- a/src/Gameboard.Api/Data/Entities/User.cs +++ b/src/Gameboard.Api/Data/Entities/User.cs @@ -1,25 +1,31 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. +using System; using System.Collections.Generic; -namespace Gameboard.Api.Data +namespace Gameboard.Api.Data; + +public class User : IEntity { - public class User : IEntity - { - public string Id { get; set; } - public string Username { get; set; } - public string Email { get; set; } - public string Name { get; set; } - public string NameStatus { get; set; } - public string ApprovedName { get; set; } - public string Sponsor { get; set; } - public UserRole Role { get; set; } + public string Id { get; set; } + public string Username { get; set; } + public string Email { get; set; } + public string Name { get; set; } + public string NameStatus { get; set; } + public string ApprovedName { get; set; } + public string Sponsor { get; set; } + public UserRole Role { get; set; } + public DateTimeOffset CreatedOn { get; set; } + public DateTimeOffset? LastLoginDate { get; set; } + public int LoginCount { get; set; } - // relational properties - public ICollection ApiKeys { get; set; } = new List(); - public ICollection Enrollments { get; set; } = new List(); - public ICollection EnteredManualChallengeBonuses { get; set; } = new List(); - public ICollection Feedback { get; set; } = new List(); - } + // relational properties + public ICollection ApiKeys { get; set; } = new List(); + public ICollection Enrollments { get; set; } = new List(); + public ICollection EnteredManualChallengeBonuses { get; set; } = new List(); + public ICollection Feedback { get; set; } = new List(); + public ICollection PublishedCompetitiveCertificates { get; set; } = new List(); + public ICollection PublishedPracticeCertificates { get; set; } = new List(); + public PracticeModeSettings UpdatedPracticeModeSettings { get; set; } } diff --git a/src/Gameboard.Api/Data/Extensions/EntityExtensions.cs b/src/Gameboard.Api/Data/Extensions/EntityExtensions.cs new file mode 100644 index 00000000..e63b2b69 --- /dev/null +++ b/src/Gameboard.Api/Data/Extensions/EntityExtensions.cs @@ -0,0 +1,24 @@ +namespace Gameboard.Api.Data; + +public static class ChallengeExtensions +{ + public static double GetPercentMaxPointsScored(this Challenge challenge) + { + return (double)(challenge.Points != 0 ? decimal.Divide(new decimal(challenge.Score), challenge.Points) : 0); + } + + public static ChallengeResult GetResult(this Challenge challenge) + => GetResult(challenge.Score, challenge.Points); + + public static ChallengeResult GetResult(double? score, double possiblePoints) + { + if (score == null || score == 0) + return ChallengeResult.None; + if (score >= possiblePoints) + return ChallengeResult.Success; + if (score > 0) + return ChallengeResult.Partial; + + return ChallengeResult.None; + } +} diff --git a/src/Gameboard.Api/Data/Extensions/QueryExtensions.cs b/src/Gameboard.Api/Data/Extensions/QueryExtensions.cs new file mode 100644 index 00000000..e7490350 --- /dev/null +++ b/src/Gameboard.Api/Data/Extensions/QueryExtensions.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Gameboard.Api.Data; + +public static class QueryExtensions +{ + /// + /// Allows simplified evaluation of dates in the DB with value 01/01/0001. + /// + /// The entity type upon which the query is based. + /// An existing Linq query for entity type T. + /// An expression which resolves to a date property on T (e.g. e => e.StartDate). + /// A query with an appended `.Where` call that eliminates entities of type T with 01/01/0001 in the date field specified by `dateExpression`. + /// + /// + public static IQueryable WhereDateIsNotEmpty(this IQueryable query, Expression> dateExpression) where T : class + => WhereDate(query, dateExpression, false); + + /// + /// Allows simplified evaluation of dates in the DB with value 01/01/0001. + /// + /// The entity type upon which the query is based. + /// An existing Linq query for entity type T. + /// An expression which resolves to a date property on T (e.g. e => e.StartDate). + /// A query with an appended `.Where` call that eliminates entities of type T with 01/01/0001 in the date field specified by `dateExpression`. + /// + /// + public static IQueryable WhereDateIsEmpty(this IQueryable query, Expression> dateExpression) where T : class + => WhereDate(query, dateExpression, true); + + private static IQueryable WhereDate(IQueryable query, Expression> dateExpression, bool isEmpty) where T : class + { + if (dateExpression == null) + throw new ArgumentNullException(nameof(dateExpression)); + if (dateExpression.Body is not MemberExpression) + throw new ArgumentException($"Can't use this extension with a {nameof(dateExpression)} argument which has a {nameof(dateExpression.Body)} property of a type not equal to {nameof(MemberExpression)}."); + + var entityParameter = Expression.Parameter(typeof(T)); + var accessMemberOnEntity = Expression.MakeMemberAccess(entityParameter, (dateExpression.Body as MemberExpression).Member); + var finalExpression = Expression.Lambda> + ( + ( + isEmpty ? + Expression.Equal(accessMemberOnEntity, Expression.Constant(DateTimeOffset.MinValue)) : + Expression.NotEqual(accessMemberOnEntity, Expression.Constant(DateTimeOffset.MinValue)) + ), entityParameter + ); + + return query.Where(finalExpression); + } + + public static IQueryable WhereIsScoringPlayer(this IQueryable query) + => query.Where(p => p.Score > 0); +} diff --git a/src/Gameboard.Api/Data/GameboardDbContext.cs b/src/Gameboard.Api/Data/GameboardDbContext.cs index ab7ddbb7..4559194d 100644 --- a/src/Gameboard.Api/Data/GameboardDbContext.cs +++ b/src/Gameboard.Api/Data/GameboardDbContext.cs @@ -1,6 +1,7 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. +using System.Reflection.Metadata; using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Data @@ -22,6 +23,7 @@ protected override void OnModelCreating(ModelBuilder builder) b.Property(u => u.NameStatus).HasMaxLength(40); b.Property(u => u.Email).HasMaxLength(64); b.Property(u => u.Sponsor).HasMaxLength(40); + b.Property(u => u.LoginCount).HasDefaultValueSql("0"); }); builder.Entity(k => @@ -193,6 +195,45 @@ protected override void OnModelCreating(ModelBuilder builder) b.Property(u => u.UserId).HasMaxLength(40); }); + builder.Entity(b => + { + b.HasKey(c => c.Id); + b.HasDiscriminator(c => c.Mode) + .HasValue(PublishedCertificateMode.Competitive) + .HasValue(PublishedCertificateMode.Practice); + }); + + builder.Entity(b => + { + b.Property(c => c.GameId).HasStandardGuidLength(); + b.HasOne(c => c.Game).WithMany(g => g.PublishedCompetitiveCertificates); + + b.HasOne(c => c.OwnerUser) + .WithMany(u => u.PublishedCompetitiveCertificates) + .HasConstraintName("FK_OwnerUserId_Users_Id"); + }); + + builder.Entity(b => + { + b.Property(c => c.ChallengeSpecId).HasStandardGuidLength(); + b.HasOne(c => c.ChallengeSpec).WithMany(s => s.PublishedPracticeCertificates); + + b.HasOne(c => c.OwnerUser) + .WithMany(u => u.PublishedPracticeCertificates) + .HasConstraintName("FK_OwnerUserId_Users_Id"); + }); + + builder.Entity(b => + { + b.HasKey(m => m.Id); + b.Property(m => m.Id).HasStandardGuidLength(); + b.Property(m => m.IntroTextMarkdown).HasMaxLength(4000); + b + .HasOne(m => m.UpdatedByUser) + .WithOne(u => u.UpdatedPracticeModeSettings) + .IsRequired(false); + }); + builder.Entity(b => { b.HasOne(p => p.Challenge).WithMany(u => u.Tickets).OnDelete(DeleteBehavior.SetNull); diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230622150410_AddPlayerWhenCreated.Designer.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230622150410_AddPlayerWhenCreated.Designer.cs new file mode 100644 index 00000000..8e5bb39f --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230622150410_AddPlayerWhenCreated.Designer.cs @@ -0,0 +1,1077 @@ +// +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("20230622150410_AddPlayerWhenCreated")] + partial class AddPlayerWhenCreated + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.1") + .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("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.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("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + 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.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("Name") + .HasColumnType("text"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .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.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Answers") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Submitted") + .HasColumnType("boolean"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AllowPreview") + .HasColumnType("boolean"); + + b.Property("AllowReset") + .HasColumnType("boolean"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("text"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("text"); + + b.Property("GameEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("GameMarkdown") + .HasColumnType("text"); + + b.Property("GameStart") + .HasColumnType("timestamp with time zone"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("integer"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxTeamSize") + .HasColumnType("integer"); + + b.Property("MinTeamSize") + .HasColumnType("integer"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("RegistrationClose") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationConstraint") + .HasColumnType("text"); + + b.Property("RegistrationMarkdown") + .HasColumnType("text"); + + b.Property("RegistrationOpen") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationType") + .HasColumnType("integer"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("boolean"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("boolean"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SessionLimit") + .HasColumnType("integer"); + + b.Property("SessionMinutes") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeId") + .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.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Advanced") + .HasColumnType("boolean"); + + 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("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("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + 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("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + 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.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + 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("Email") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + 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.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.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.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.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualChallengeBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Challenge"); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + 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.Challenge", b => + { + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualChallengeBonuses"); + + b.Navigation("Feedback"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230622150410_AddPlayerWhenCreated.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230622150410_AddPlayerWhenCreated.cs new file mode 100644 index 00000000..1ccf75c8 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230622150410_AddPlayerWhenCreated.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + /// + public partial class AddPlayerWhenCreated : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "WhenCreated", + table: "Players", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "WhenCreated", + table: "Players"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230629155839_AddUserCreatedLoginColumns.Designer.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230629155839_AddUserCreatedLoginColumns.Designer.cs new file mode 100644 index 00000000..2e4b2e63 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230629155839_AddUserCreatedLoginColumns.Designer.cs @@ -0,0 +1,1088 @@ +// +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("20230629155839_AddUserCreatedLoginColumns")] + partial class AddUserCreatedLoginColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.1") + .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("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.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("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + 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.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("Name") + .HasColumnType("text"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .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.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Answers") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Submitted") + .HasColumnType("boolean"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AllowPreview") + .HasColumnType("boolean"); + + b.Property("AllowReset") + .HasColumnType("boolean"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("text"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("text"); + + b.Property("GameEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("GameMarkdown") + .HasColumnType("text"); + + b.Property("GameStart") + .HasColumnType("timestamp with time zone"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("integer"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxTeamSize") + .HasColumnType("integer"); + + b.Property("MinTeamSize") + .HasColumnType("integer"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("RegistrationClose") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationConstraint") + .HasColumnType("text"); + + b.Property("RegistrationMarkdown") + .HasColumnType("text"); + + b.Property("RegistrationOpen") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationType") + .HasColumnType("integer"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("boolean"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("boolean"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SessionLimit") + .HasColumnType("integer"); + + b.Property("SessionMinutes") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeId") + .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.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Advanced") + .HasColumnType("boolean"); + + 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("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("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + 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("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + 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.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + 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("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("Role") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + 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.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.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.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.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualChallengeBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Challenge"); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + 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.Challenge", b => + { + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualChallengeBonuses"); + + b.Navigation("Feedback"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230629155839_AddUserCreatedLoginColumns.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230629155839_AddUserCreatedLoginColumns.cs new file mode 100644 index 00000000..2ab1a8ef --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230629155839_AddUserCreatedLoginColumns.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + /// + public partial class AddUserCreatedLoginColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedOn", + table: "Users", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "LastLoginDate", + table: "Users", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "LoginCount", + table: "Users", + type: "integer", + nullable: false, + defaultValueSql: "0"); + + migrationBuilder.Sql("""UPDATE "Users" SET "CreatedOn" = NOW();"""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CreatedOn", + table: "Users"); + + migrationBuilder.DropColumn( + name: "LastLoginDate", + table: "Users"); + + migrationBuilder.DropColumn( + name: "LoginCount", + table: "Users"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230724155027_AddChallengePlayerMode.Designer.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230724155027_AddChallengePlayerMode.Designer.cs new file mode 100644 index 00000000..76d5d3b3 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230724155027_AddChallengePlayerMode.Designer.cs @@ -0,0 +1,1094 @@ +// +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("20230724155027_AddChallengePlayerMode")] + partial class AddChallengePlayerMode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.8") + .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.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("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.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("Name") + .HasColumnType("text"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .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.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Answers") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Submitted") + .HasColumnType("boolean"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AllowPreview") + .HasColumnType("boolean"); + + b.Property("AllowReset") + .HasColumnType("boolean"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("text"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("text"); + + b.Property("GameEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("GameMarkdown") + .HasColumnType("text"); + + b.Property("GameStart") + .HasColumnType("timestamp with time zone"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("integer"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxTeamSize") + .HasColumnType("integer"); + + b.Property("MinTeamSize") + .HasColumnType("integer"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("RegistrationClose") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationConstraint") + .HasColumnType("text"); + + b.Property("RegistrationMarkdown") + .HasColumnType("text"); + + b.Property("RegistrationOpen") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationType") + .HasColumnType("integer"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("boolean"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("boolean"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SessionLimit") + .HasColumnType("integer"); + + b.Property("SessionMinutes") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeId") + .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.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Advanced") + .HasColumnType("boolean"); + + 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("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("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + 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("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + 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.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + 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("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("Role") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + 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.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.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.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.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualChallengeBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Challenge"); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + 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.Challenge", b => + { + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualChallengeBonuses"); + + b.Navigation("Feedback"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230724155027_AddChallengePlayerMode.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230724155027_AddChallengePlayerMode.cs new file mode 100644 index 00000000..fa400d5c --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230724155027_AddChallengePlayerMode.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + /// + public partial class AddChallengePlayerMode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PlayerMode", + table: "Challenges", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "PlayerMode", + table: "ArchivedChallenges", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.Sql(""" + UPDATE "Challenges" AS c + SET "PlayerMode" = g."PlayerMode" + FROM "Games" AS g + WHERE g."Id" = c."GameId"; + """); + + migrationBuilder.Sql(""" + UPDATE "ArchivedChallenges" AS ac + SET "PlayerMode" = g."PlayerMode" + FROM "Games" AS g + WHERE g."Id" = ac."GameId"; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PlayerMode", + table: "Challenges"); + + migrationBuilder.DropColumn( + name: "PlayerMode", + table: "ArchivedChallenges"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230815194653_AddPracticeModeSettings.Designer.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230815194653_AddPracticeModeSettings.Designer.cs new file mode 100644 index 00000000..543931b5 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230815194653_AddPracticeModeSettings.Designer.cs @@ -0,0 +1,1141 @@ +// +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("20230815194653_AddPracticeModeSettings")] + partial class AddPracticeModeSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.8") + .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.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("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.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("Name") + .HasColumnType("text"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .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.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Answers") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Submitted") + .HasColumnType("boolean"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AllowPreview") + .HasColumnType("boolean"); + + b.Property("AllowReset") + .HasColumnType("boolean"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("text"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("text"); + + b.Property("GameEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("GameMarkdown") + .HasColumnType("text"); + + b.Property("GameStart") + .HasColumnType("timestamp with time zone"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("integer"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxTeamSize") + .HasColumnType("integer"); + + b.Property("MinTeamSize") + .HasColumnType("integer"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("RegistrationClose") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationConstraint") + .HasColumnType("text"); + + b.Property("RegistrationMarkdown") + .HasColumnType("text"); + + b.Property("RegistrationOpen") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationType") + .HasColumnType("integer"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("boolean"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("boolean"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SessionLimit") + .HasColumnType("integer"); + + b.Property("SessionMinutes") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeId") + .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.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Advanced") + .HasColumnType("boolean"); + + 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("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("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + 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("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CertificateHtmlTemplate") + .HasColumnType("text"); + + b.Property("DefaultPracticeSessionLengthMinutes") + .HasColumnType("integer"); + + b.Property("IntroTextMarkdown") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("MaxConcurrentPracticeSessions") + .HasColumnType("integer"); + + b.Property("MaxPracticeSessionLengthMinutes") + .HasColumnType("integer"); + + b.Property("UpdatedByUserId") + .HasColumnType("character varying(40)"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique(); + + b.ToTable("PracticeModeSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.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.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + 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("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("Role") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + 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.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.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.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.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualChallengeBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Challenge"); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedPracticeModeSettings") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "UpdatedByUserId"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.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.Challenge", b => + { + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualChallengeBonuses"); + + b.Navigation("Feedback"); + + b.Navigation("UpdatedPracticeModeSettings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230815194653_AddPracticeModeSettings.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230815194653_AddPracticeModeSettings.cs new file mode 100644 index 00000000..7181b2be --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230815194653_AddPracticeModeSettings.cs @@ -0,0 +1,61 @@ +using System; +using Gameboard.Api.Services; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + /// + public partial class AddPracticeModeSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PracticeModeSettings", + columns: table => new + { + Id = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + CertificateHtmlTemplate = table.Column(type: "text", nullable: true), + DefaultPracticeSessionLengthMinutes = table.Column(type: "integer", nullable: false), + IntroTextMarkdown = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + MaxConcurrentPracticeSessions = table.Column(type: "integer", nullable: true), + MaxPracticeSessionLengthMinutes = table.Column(type: "integer", nullable: true), + UpdatedOn = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedByUserId = table.Column(type: "character varying(40)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PracticeModeSettings", x => x.Id); + table.ForeignKey( + name: "FK_PracticeModeSettings_Users_UpdatedByUserId", + column: x => x.UpdatedByUserId, + principalTable: "Users", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_PracticeModeSettings_UpdatedByUserId", + table: "PracticeModeSettings", + column: "UpdatedByUserId", + unique: true); + + + // seed default settings + var introTextMarkdown = "Welcome to the Practice area. Search for and select any challenge to practice your skills. If you''re a beginner, search for \"Training Labs\" for walkthroughs, and \"Practice Challenge\" for a place to start."; + + migrationBuilder.Sql($""" + INSERT INTO "PracticeModeSettings" ("Id", "DefaultPracticeSessionLengthMinutes", "IntroTextMarkdown", "MaxPracticeSessionLengthMinutes", "UpdatedOn") + VALUES ('{GuidService.StaticGenerateGuid()}', 60, '{introTextMarkdown}', 240, NOW()); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PracticeModeSettings"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230823165815_AddPublishedCertificates.Designer.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230823165815_AddPublishedCertificates.Designer.cs new file mode 100644 index 00000000..78a5408e --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230823165815_AddPublishedCertificates.Designer.cs @@ -0,0 +1,1234 @@ +// +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("20230823165815_AddPublishedCertificates")] + partial class AddPublishedCertificates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.8") + .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.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("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.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("Name") + .HasColumnType("text"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .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.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Answers") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Submitted") + .HasColumnType("boolean"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AllowPreview") + .HasColumnType("boolean"); + + b.Property("AllowReset") + .HasColumnType("boolean"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("text"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("text"); + + b.Property("GameEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("GameMarkdown") + .HasColumnType("text"); + + b.Property("GameStart") + .HasColumnType("timestamp with time zone"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("integer"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxTeamSize") + .HasColumnType("integer"); + + b.Property("MinTeamSize") + .HasColumnType("integer"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PlayerMode") + .HasColumnType("integer"); + + b.Property("RegistrationClose") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationConstraint") + .HasColumnType("text"); + + b.Property("RegistrationMarkdown") + .HasColumnType("text"); + + b.Property("RegistrationOpen") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationType") + .HasColumnType("integer"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("boolean"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("boolean"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SessionLimit") + .HasColumnType("integer"); + + b.Property("SessionMinutes") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeId") + .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.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Advanced") + .HasColumnType("boolean"); + + 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("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("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + 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("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CertificateHtmlTemplate") + .HasColumnType("text"); + + b.Property("DefaultPracticeSessionLengthMinutes") + .HasColumnType("integer"); + + b.Property("IntroTextMarkdown") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("MaxConcurrentPracticeSessions") + .HasColumnType("integer"); + + b.Property("MaxPracticeSessionLengthMinutes") + .HasColumnType("integer"); + + b.Property("UpdatedByUserId") + .HasColumnType("character varying(40)"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique(); + + b.ToTable("PracticeModeSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCertificate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("OwnerUserId") + .HasColumnType("character varying(40)"); + + b.Property("PublishedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PublishedCertificate"); + + b.HasDiscriminator("Mode"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("Logo") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + 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("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("Role") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + 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.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.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.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.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualChallengeBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Challenge"); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedPracticeModeSettings") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "UpdatedByUserId"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.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.PublishedCompetitiveCertificate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("Game"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedPracticeCertificate", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("ChallengeSpecId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + + b.Navigation("PublishedPracticeCertificates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualChallengeBonuses"); + + b.Navigation("Feedback"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("PublishedPracticeCertificates"); + + b.Navigation("UpdatedPracticeModeSettings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230823165815_AddPublishedCertificates.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230823165815_AddPublishedCertificates.cs new file mode 100644 index 00000000..366a864a --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20230823165815_AddPublishedCertificates.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + /// + public partial class AddPublishedCertificates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PublishedCertificate", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + PublishedOn = table.Column(type: "timestamp with time zone", nullable: false), + Mode = table.Column(type: "integer", nullable: false), + OwnerUserId = table.Column(type: "character varying(40)", nullable: true), + GameId = table.Column(type: "character varying(40)", maxLength: 40, nullable: true), + ChallengeSpecId = table.Column(type: "character varying(40)", maxLength: 40, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PublishedCertificate", x => x.Id); + table.ForeignKey( + name: "FK_OwnerUserId_Users_Id", + column: x => x.OwnerUserId, + principalTable: "Users", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_PublishedCertificate_ChallengeSpecs_ChallengeSpecId", + column: x => x.ChallengeSpecId, + principalTable: "ChallengeSpecs", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_PublishedCertificate_Games_GameId", + column: x => x.GameId, + principalTable: "Games", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_PublishedCertificate_ChallengeSpecId", + table: "PublishedCertificate", + column: "ChallengeSpecId"); + + migrationBuilder.CreateIndex( + name: "IX_PublishedCertificate_GameId", + table: "PublishedCertificate", + column: "GameId"); + + migrationBuilder.CreateIndex( + name: "IX_PublishedCertificate_OwnerUserId", + table: "PublishedCertificate", + column: "OwnerUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PublishedCertificate"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs index 9c1c80ff..5f402a7e 100644 --- a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.1") + .HasAnnotation("ProductVersion", "7.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -95,6 +95,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(40) .HasColumnType("character varying(40)"); + b.Property("PlayerMode") + .HasColumnType("integer"); + b.Property("PlayerName") .HasMaxLength(64) .HasColumnType("character varying(64)"); @@ -184,6 +187,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(40) .HasColumnType("character varying(40)"); + b.Property("PlayerMode") + .HasColumnType("integer"); + b.Property("Points") .HasColumnType("integer"); @@ -632,6 +638,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(40) .HasColumnType("character varying(40)"); + b.Property("WhenCreated") + .HasColumnType("timestamp with time zone"); + b.HasKey("Id"); b.HasIndex("GameId"); @@ -643,6 +652,65 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Players"); }); + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CertificateHtmlTemplate") + .HasColumnType("text"); + + b.Property("DefaultPracticeSessionLengthMinutes") + .HasColumnType("integer"); + + b.Property("IntroTextMarkdown") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("MaxConcurrentPracticeSessions") + .HasColumnType("integer"); + + b.Property("MaxPracticeSessionLengthMinutes") + .HasColumnType("integer"); + + b.Property("UpdatedByUserId") + .HasColumnType("character varying(40)"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique(); + + b.ToTable("PracticeModeSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCertificate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("OwnerUserId") + .HasColumnType("character varying(40)"); + + b.Property("PublishedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PublishedCertificate"); + + b.HasDiscriminator("Mode"); + + b.UseTphMappingStrategy(); + }); + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => { b.Property("Id") @@ -799,10 +867,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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("LastLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LoginCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("0"); + b.Property("Name") .HasMaxLength(64) .HasColumnType("character varying(64)"); @@ -827,6 +906,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Users"); }); + 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") @@ -955,6 +1064,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedPracticeModeSettings") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "UpdatedByUserId"); + + b.Navigation("UpdatedByUser"); + }); + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => { b.HasOne("Gameboard.Api.Data.User", "Assignee") @@ -1012,6 +1130,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Gameboard.Api.Data.PublishedCompetitiveCertificate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("Game"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedPracticeCertificate", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("ChallengeSpecId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("OwnerUser"); + }); + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => { b.Navigation("AwardedManualBonuses"); @@ -1026,6 +1176,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => { b.Navigation("Feedback"); + + b.Navigation("PublishedPracticeCertificates"); }); modelBuilder.Entity("Gameboard.Api.Data.Game", b => @@ -1038,6 +1190,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Prerequisites"); + b.Navigation("PublishedCompetitiveCertificates"); + b.Navigation("Specs"); }); @@ -1064,6 +1218,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("EnteredManualChallengeBonuses"); b.Navigation("Feedback"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("PublishedPracticeCertificates"); + + b.Navigation("UpdatedPracticeModeSettings"); }); #pragma warning restore 612, 618 } diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230426134535_AddReports.Designer.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230426134535_AddReports.Designer.cs new file mode 100644 index 00000000..864dfe41 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230426134535_AddReports.Designer.cs @@ -0,0 +1,1104 @@ +// +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("20230426134535_AddReports")] + partial class AddReports + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.1") + .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("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.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("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + 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.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("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .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.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Answers") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Submitted") + .HasColumnType("bit"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AllowPreview") + .HasColumnType("bit"); + + b.Property("AllowReset") + .HasColumnType("bit"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("GameEnd") + .HasColumnType("datetimeoffset"); + + b.Property("GameMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("GameStart") + .HasColumnType("datetimeoffset"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("int"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("MaxAttempts") + .HasColumnType("int"); + + b.Property("MaxTeamSize") + .HasColumnType("int"); + + b.Property("MinTeamSize") + .HasColumnType("int"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("RegistrationClose") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationConstraint") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationOpen") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationType") + .HasColumnType("int"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("bit"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("bit"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SessionLimit") + .HasColumnType("int"); + + b.Property("SessionMinutes") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .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.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Advanced") + .HasColumnType("bit"); + + 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("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("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + 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.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + 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("Email") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Report", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExampleFields") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExampleParameters") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Key") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Reports"); + }); + + 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.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.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.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.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualChallengeBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Challenge"); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + 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.Challenge", b => + { + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualChallengeBonuses"); + + b.Navigation("Feedback"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230426134535_AddReports.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230426134535_AddReports.cs new file mode 100644 index 00000000..b64b7fce --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230426134535_AddReports.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + /// + public partial class AddReports : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Reports", + columns: table => new + { + Id = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + Key = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + Name = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + Description = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + ExampleFields = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + ExampleParameters = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Reports", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Reports"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230622150416_AddPlayerWhenCreated.Designer.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230622150416_AddPlayerWhenCreated.Designer.cs new file mode 100644 index 00000000..74d27891 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230622150416_AddPlayerWhenCreated.Designer.cs @@ -0,0 +1,1076 @@ +// +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("20230622150416_AddPlayerWhenCreated")] + partial class AddPlayerWhenCreated + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.1") + .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("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.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("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + 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.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("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .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.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Answers") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Submitted") + .HasColumnType("bit"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AllowPreview") + .HasColumnType("bit"); + + b.Property("AllowReset") + .HasColumnType("bit"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("GameEnd") + .HasColumnType("datetimeoffset"); + + b.Property("GameMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("GameStart") + .HasColumnType("datetimeoffset"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("int"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("MaxAttempts") + .HasColumnType("int"); + + b.Property("MaxTeamSize") + .HasColumnType("int"); + + b.Property("MinTeamSize") + .HasColumnType("int"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("RegistrationClose") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationConstraint") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationOpen") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationType") + .HasColumnType("int"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("bit"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("bit"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SessionLimit") + .HasColumnType("int"); + + b.Property("SessionMinutes") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .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.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Advanced") + .HasColumnType("bit"); + + 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("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("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + 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.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + 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("Email") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + 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.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.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.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.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualChallengeBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Challenge"); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + 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.Challenge", b => + { + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualChallengeBonuses"); + + b.Navigation("Feedback"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230622150416_AddPlayerWhenCreated.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230622150416_AddPlayerWhenCreated.cs new file mode 100644 index 00000000..0e2f0b92 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230622150416_AddPlayerWhenCreated.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + /// + public partial class AddPlayerWhenCreated : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Reports"); + + migrationBuilder.AddColumn( + name: "WhenCreated", + table: "Players", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "WhenCreated", + table: "Players"); + + migrationBuilder.CreateTable( + name: "Reports", + columns: table => new + { + Id = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + Description = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + ExampleFields = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + ExampleParameters = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + Key = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + Name = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Reports", x => x.Id); + }); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230629155845_AddUserCreatedLoginColumns.Designer.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230629155845_AddUserCreatedLoginColumns.Designer.cs new file mode 100644 index 00000000..7f0ff508 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230629155845_AddUserCreatedLoginColumns.Designer.cs @@ -0,0 +1,1087 @@ +// +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("20230629155845_AddUserCreatedLoginColumns")] + partial class AddUserCreatedLoginColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.1") + .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("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.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("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + 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.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("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .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.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Answers") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Submitted") + .HasColumnType("bit"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AllowPreview") + .HasColumnType("bit"); + + b.Property("AllowReset") + .HasColumnType("bit"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("GameEnd") + .HasColumnType("datetimeoffset"); + + b.Property("GameMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("GameStart") + .HasColumnType("datetimeoffset"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("int"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("MaxAttempts") + .HasColumnType("int"); + + b.Property("MaxTeamSize") + .HasColumnType("int"); + + b.Property("MinTeamSize") + .HasColumnType("int"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("RegistrationClose") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationConstraint") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationOpen") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationType") + .HasColumnType("int"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("bit"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("bit"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SessionLimit") + .HasColumnType("int"); + + b.Property("SessionMinutes") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .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.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Advanced") + .HasColumnType("bit"); + + 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("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("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + 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.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + 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("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("Role") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + 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.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.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.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.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualChallengeBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Challenge"); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + 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.Challenge", b => + { + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualChallengeBonuses"); + + b.Navigation("Feedback"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230629155845_AddUserCreatedLoginColumns.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230629155845_AddUserCreatedLoginColumns.cs new file mode 100644 index 00000000..3b6f884d --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230629155845_AddUserCreatedLoginColumns.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + /// + public partial class AddUserCreatedLoginColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedOn", + table: "Users", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "LastLoginDate", + table: "Users", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "LoginCount", + table: "Users", + type: "int", + nullable: false, + defaultValueSql: "0"); + + migrationBuilder.Sql("UPDATE Users SET CreatedOn = GETDATE();"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CreatedOn", + table: "Users"); + + migrationBuilder.DropColumn( + name: "LastLoginDate", + table: "Users"); + + migrationBuilder.DropColumn( + name: "LoginCount", + table: "Users"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230724155032_AddChallengePlayerMode.Designer.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230724155032_AddChallengePlayerMode.Designer.cs new file mode 100644 index 00000000..199d0ace --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230724155032_AddChallengePlayerMode.Designer.cs @@ -0,0 +1,1093 @@ +// +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("20230724155032_AddChallengePlayerMode")] + partial class AddChallengePlayerMode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.8") + .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.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("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.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("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .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.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Answers") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Submitted") + .HasColumnType("bit"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AllowPreview") + .HasColumnType("bit"); + + b.Property("AllowReset") + .HasColumnType("bit"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("GameEnd") + .HasColumnType("datetimeoffset"); + + b.Property("GameMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("GameStart") + .HasColumnType("datetimeoffset"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("int"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("MaxAttempts") + .HasColumnType("int"); + + b.Property("MaxTeamSize") + .HasColumnType("int"); + + b.Property("MinTeamSize") + .HasColumnType("int"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("RegistrationClose") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationConstraint") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationOpen") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationType") + .HasColumnType("int"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("bit"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("bit"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SessionLimit") + .HasColumnType("int"); + + b.Property("SessionMinutes") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .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.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Advanced") + .HasColumnType("bit"); + + 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("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("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + 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.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + 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("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("Role") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + 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.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.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.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.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualChallengeBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Challenge"); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + 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.Challenge", b => + { + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualChallengeBonuses"); + + b.Navigation("Feedback"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230724155032_AddChallengePlayerMode.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230724155032_AddChallengePlayerMode.cs new file mode 100644 index 00000000..7b20eff7 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230724155032_AddChallengePlayerMode.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + /// + public partial class AddChallengePlayerMode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PlayerMode", + table: "Challenges", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "PlayerMode", + table: "ArchivedChallenges", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.Sql(""" + UPDATE c + SET c.PlayerMode = g.PlayerMode + FROM Challenges c + INNER JOIN Games g ON g.Id = c.GameId; + """); + + migrationBuilder.Sql(""" + UPDATE ac + SET c.PlayerMode = g.PlayerMode + FROM ArchivedChallenges ac + INNER JOIN Games g ON g.Id = ac.GameId; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PlayerMode", + table: "Challenges"); + + migrationBuilder.DropColumn( + name: "PlayerMode", + table: "ArchivedChallenges"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230815194658_AddPracticeModeSettings.Designer.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230815194658_AddPracticeModeSettings.Designer.cs new file mode 100644 index 00000000..48dc8851 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230815194658_AddPracticeModeSettings.Designer.cs @@ -0,0 +1,1141 @@ +// +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("20230815194658_AddPracticeModeSettings")] + partial class AddPracticeModeSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.8") + .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.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("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.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("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .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.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Answers") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Submitted") + .HasColumnType("bit"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AllowPreview") + .HasColumnType("bit"); + + b.Property("AllowReset") + .HasColumnType("bit"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("GameEnd") + .HasColumnType("datetimeoffset"); + + b.Property("GameMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("GameStart") + .HasColumnType("datetimeoffset"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("int"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("MaxAttempts") + .HasColumnType("int"); + + b.Property("MaxTeamSize") + .HasColumnType("int"); + + b.Property("MinTeamSize") + .HasColumnType("int"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("RegistrationClose") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationConstraint") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationOpen") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationType") + .HasColumnType("int"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("bit"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("bit"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SessionLimit") + .HasColumnType("int"); + + b.Property("SessionMinutes") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .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.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Advanced") + .HasColumnType("bit"); + + 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("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("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CertificateHtmlTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultPracticeSessionLengthMinutes") + .HasColumnType("int"); + + b.Property("IntroTextMarkdown") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("MaxConcurrentPracticeSessions") + .HasColumnType("int"); + + b.Property("MaxPracticeSessionLengthMinutes") + .HasColumnType("int"); + + b.Property("UpdatedByUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique() + .HasFilter("[UpdatedByUserId] IS NOT NULL"); + + b.ToTable("PracticeModeSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.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.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + 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("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("Role") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + 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.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.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.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.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualChallengeBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Challenge"); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedPracticeModeSettings") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "UpdatedByUserId"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.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.Challenge", b => + { + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualChallengeBonuses"); + + b.Navigation("Feedback"); + + b.Navigation("UpdatedPracticeModeSettings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230815194658_AddPracticeModeSettings.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230815194658_AddPracticeModeSettings.cs new file mode 100644 index 00000000..3467ee6c --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230815194658_AddPracticeModeSettings.cs @@ -0,0 +1,61 @@ +using System; +using Gameboard.Api.Services; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + /// + public partial class AddPracticeModeSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PracticeModeSettings", + columns: table => new + { + Id = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + CertificateHtmlTemplate = table.Column(type: "nvarchar(max)", nullable: true), + DefaultPracticeSessionLengthMinutes = table.Column(type: "int", nullable: false), + IntroTextMarkdown = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: true), + MaxConcurrentPracticeSessions = table.Column(type: "int", nullable: true), + MaxPracticeSessionLengthMinutes = table.Column(type: "int", nullable: true), + UpdatedOn = table.Column(type: "datetimeoffset", nullable: true), + UpdatedByUserId = table.Column(type: "nvarchar(40)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PracticeModeSettings", x => x.Id); + table.ForeignKey( + name: "FK_PracticeModeSettings_Users_UpdatedByUserId", + column: x => x.UpdatedByUserId, + principalTable: "Users", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_PracticeModeSettings_UpdatedByUserId", + table: "PracticeModeSettings", + column: "UpdatedByUserId", + unique: true, + filter: "[UpdatedByUserId] IS NOT NULL"); + + // seed default settings + var introTextMarkdown = "Welcome to the Practice area. Search for and select any challenge to practice your skills. If you''re a beginner, search for \"Training Labs\" for walkthroughs, and \"Practice Challenge\" for a place to start."; + + migrationBuilder.Sql($""" + INSERT INTO PracticeModeSettings (Id, DefaultPracticeSessionLengthMinutes, IntroTextMarkdown, MaxPracticeSessionLengthMinutes, UpdatedOn) + VALUES ('{GuidService.StaticGenerateGuid()}', 60, '{introTextMarkdown}', 240, GETUTCDATE()); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PracticeModeSettings"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230823165821_AddPublishedCertificates.Designer.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230823165821_AddPublishedCertificates.Designer.cs new file mode 100644 index 00000000..c7693fd9 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230823165821_AddPublishedCertificates.Designer.cs @@ -0,0 +1,1234 @@ +// +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("20230823165821_AddPublishedCertificates")] + partial class AddPublishedCertificates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.8") + .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.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("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.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("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .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.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Answers") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Submitted") + .HasColumnType("bit"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AllowPreview") + .HasColumnType("bit"); + + b.Property("AllowReset") + .HasColumnType("bit"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("GameEnd") + .HasColumnType("datetimeoffset"); + + b.Property("GameMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("GameStart") + .HasColumnType("datetimeoffset"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("int"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("MaxAttempts") + .HasColumnType("int"); + + b.Property("MaxTeamSize") + .HasColumnType("int"); + + b.Property("MinTeamSize") + .HasColumnType("int"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PlayerMode") + .HasColumnType("int"); + + b.Property("RegistrationClose") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationConstraint") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationOpen") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationType") + .HasColumnType("int"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("bit"); + + b.Property("RequireSynchronizedStart") + .HasColumnType("bit"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SessionLimit") + .HasColumnType("int"); + + b.Property("SessionMinutes") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ManualChallengeBonus", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .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.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("EnteredByUserId"); + + b.ToTable("ManualChallengeBonuses"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Advanced") + .HasColumnType("bit"); + + 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("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("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CertificateHtmlTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultPracticeSessionLengthMinutes") + .HasColumnType("int"); + + b.Property("IntroTextMarkdown") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("MaxConcurrentPracticeSessions") + .HasColumnType("int"); + + b.Property("MaxPracticeSessionLengthMinutes") + .HasColumnType("int"); + + b.Property("UpdatedByUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique() + .HasFilter("[UpdatedByUserId] IS NOT NULL"); + + b.ToTable("PracticeModeSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCertificate", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Mode") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("PublishedCertificate"); + + b.HasDiscriminator("Mode"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Approved") + .HasColumnType("bit"); + + b.Property("Logo") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + 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("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("Role") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + 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.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.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.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.ManualChallengeBonus", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("AwardedManualBonuses") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "EnteredByUser") + .WithMany("EnteredManualChallengeBonuses") + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Challenge"); + + b.Navigation("EnteredByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedPracticeModeSettings") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "UpdatedByUserId"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.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.PublishedCompetitiveCertificate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("Game"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedPracticeCertificate", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("ChallengeSpecId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Navigation("AwardedManualBonuses"); + + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + + b.Navigation("PublishedPracticeCertificates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Enrollments"); + + b.Navigation("EnteredManualChallengeBonuses"); + + b.Navigation("Feedback"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("PublishedPracticeCertificates"); + + b.Navigation("UpdatedPracticeModeSettings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230823165821_AddPublishedCertificates.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230823165821_AddPublishedCertificates.cs new file mode 100644 index 00000000..a924463b --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20230823165821_AddPublishedCertificates.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + /// + public partial class AddPublishedCertificates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PublishedCertificate", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + PublishedOn = table.Column(type: "datetimeoffset", nullable: false), + Mode = table.Column(type: "int", nullable: false), + OwnerUserId = table.Column(type: "nvarchar(40)", nullable: true), + GameId = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: true), + ChallengeSpecId = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PublishedCertificate", x => x.Id); + table.ForeignKey( + name: "FK_OwnerUserId_Users_Id", + column: x => x.OwnerUserId, + principalTable: "Users", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_PublishedCertificate_ChallengeSpecs_ChallengeSpecId", + column: x => x.ChallengeSpecId, + principalTable: "ChallengeSpecs", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_PublishedCertificate_Games_GameId", + column: x => x.GameId, + principalTable: "Games", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_PublishedCertificate_ChallengeSpecId", + table: "PublishedCertificate", + column: "ChallengeSpecId"); + + migrationBuilder.CreateIndex( + name: "IX_PublishedCertificate_GameId", + table: "PublishedCertificate", + column: "GameId"); + + migrationBuilder.CreateIndex( + name: "IX_PublishedCertificate_OwnerUserId", + table: "PublishedCertificate", + column: "OwnerUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PublishedCertificate"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs index 3cecc5a2..42a19543 100644 --- a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.1") + .HasAnnotation("ProductVersion", "7.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -96,6 +96,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(40) .HasColumnType("nvarchar(40)"); + b.Property("PlayerMode") + .HasColumnType("int"); + b.Property("PlayerName") .HasMaxLength(64) .HasColumnType("nvarchar(64)"); @@ -185,6 +188,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(40) .HasColumnType("nvarchar(40)"); + b.Property("PlayerMode") + .HasColumnType("int"); + b.Property("Points") .HasColumnType("int"); @@ -633,6 +639,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(40) .HasColumnType("nvarchar(40)"); + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + b.HasKey("Id"); b.HasIndex("GameId"); @@ -644,6 +653,66 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Players"); }); + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CertificateHtmlTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultPracticeSessionLengthMinutes") + .HasColumnType("int"); + + b.Property("IntroTextMarkdown") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("MaxConcurrentPracticeSessions") + .HasColumnType("int"); + + b.Property("MaxPracticeSessionLengthMinutes") + .HasColumnType("int"); + + b.Property("UpdatedByUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("UpdatedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("UpdatedByUserId") + .IsUnique() + .HasFilter("[UpdatedByUserId] IS NOT NULL"); + + b.ToTable("PracticeModeSettings"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedCertificate", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Mode") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("nvarchar(40)"); + + b.Property("PublishedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("PublishedCertificate"); + + b.HasDiscriminator("Mode"); + + b.UseTphMappingStrategy(); + }); + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => { b.Property("Id") @@ -798,10 +867,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + b.Property("Email") .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("LastLoginDate") + .HasColumnType("datetimeoffset"); + + b.Property("LoginCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("0"); + b.Property("Name") .HasMaxLength(64) .HasColumnType("nvarchar(64)"); @@ -826,6 +906,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Users"); }); + 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") @@ -954,6 +1064,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Gameboard.Api.Data.PracticeModeSettings", b => + { + b.HasOne("Gameboard.Api.Data.User", "UpdatedByUser") + .WithOne("UpdatedPracticeModeSettings") + .HasForeignKey("Gameboard.Api.Data.PracticeModeSettings", "UpdatedByUserId"); + + b.Navigation("UpdatedByUser"); + }); + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => { b.HasOne("Gameboard.Api.Data.User", "Assignee") @@ -1011,6 +1130,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Gameboard.Api.Data.PublishedCompetitiveCertificate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedCompetitiveCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("Game"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.PublishedPracticeCertificate", b => + { + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("ChallengeSpecId"); + + b.HasOne("Gameboard.Api.Data.User", "OwnerUser") + .WithMany("PublishedPracticeCertificates") + .HasForeignKey("OwnerUserId") + .HasConstraintName("FK_OwnerUserId_Users_Id"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("OwnerUser"); + }); + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => { b.Navigation("AwardedManualBonuses"); @@ -1025,6 +1176,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => { b.Navigation("Feedback"); + + b.Navigation("PublishedPracticeCertificates"); }); modelBuilder.Entity("Gameboard.Api.Data.Game", b => @@ -1037,6 +1190,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Prerequisites"); + b.Navigation("PublishedCompetitiveCertificates"); + b.Navigation("Specs"); }); @@ -1063,6 +1218,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("EnteredManualChallengeBonuses"); b.Navigation("Feedback"); + + b.Navigation("PublishedCompetitiveCertificates"); + + b.Navigation("PublishedPracticeCertificates"); + + b.Navigation("UpdatedPracticeModeSettings"); }); #pragma warning restore 612, 618 } diff --git a/src/Gameboard.Api/Data/ProviderDbContext.cs b/src/Gameboard.Api/Data/ProviderDbContext.cs index fe1730a4..f03f1daa 100644 --- a/src/Gameboard.Api/Data/ProviderDbContext.cs +++ b/src/Gameboard.Api/Data/ProviderDbContext.cs @@ -3,29 +3,22 @@ using Microsoft.EntityFrameworkCore; -namespace Gameboard.Api.Data +namespace Gameboard.Api.Data; + +public class GameboardDbContextInMemory : GameboardDbContext { - public class GameboardDbContextInMemory: GameboardDbContext - { - public GameboardDbContextInMemory(DbContextOptions options) - : base(options) - { - } - } + public GameboardDbContextInMemory(DbContextOptions options) + : base(options) { } +} - public class GameboardDbContextSqlServer: GameboardDbContext - { - public GameboardDbContextSqlServer(DbContextOptions options) - : base(options) - { - } - } +public class GameboardDbContextSqlServer : GameboardDbContext +{ + public GameboardDbContextSqlServer(DbContextOptions options) + : base(options) { } +} - public class GameboardDbContextPostgreSQL: GameboardDbContext - { - public GameboardDbContextPostgreSQL(DbContextOptions options) - : base(options) - { - } - } +public class GameboardDbContextPostgreSQL : GameboardDbContext +{ + public GameboardDbContextPostgreSQL(DbContextOptions options) + : base(options) { } } diff --git a/src/Gameboard.Api/Data/Store/IStore[TEntity].cs b/src/Gameboard.Api/Data/Store/IStore[TEntity].cs index d48b349c..798b2bc3 100644 --- a/src/Gameboard.Api/Data/Store/IStore[TEntity].cs +++ b/src/Gameboard.Api/Data/Store/IStore[TEntity].cs @@ -4,25 +4,27 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Threading.Tasks; -namespace Gameboard.Api.Data.Abstractions +namespace Gameboard.Api.Data.Abstractions; + +public interface IStore where TEntity : class, IEntity { - public interface IStore - where TEntity : class, IEntity - { - GameboardDbContext DbContext { get; } - IQueryable DbSet { get; } + GameboardDbContext DbContext { get; } + IQueryable DbSet { get; } - IQueryable List(string term = null); - Task Create(TEntity entity); - Task> Create(IEnumerable range); - Task Exists(string id); - Task Retrieve(string id); - Task Retrieve(string id, Func, IQueryable> includes); - Task Update(TEntity entity); - Task Update(IEnumerable range); - Task Delete(string id); - Task CountAsync(Func, IQueryable> queryBuilder = null); - } + Task AnyAsync(); + Task AnyAsync(Expression> predicate); + IQueryable List(string term = null); + IQueryable ListWithNoTracking(); + Task Create(TEntity entity); + Task> Create(IEnumerable range); + Task Exists(string id); + Task Retrieve(string id); + Task Retrieve(string id, Func, IQueryable> includes); + Task Update(TEntity entity); + Task Update(IEnumerable range); + Task Delete(string id); + Task CountAsync(Func, IQueryable> queryBuilder = null); } diff --git a/src/Gameboard.Api/Data/Store/Store.cs b/src/Gameboard.Api/Data/Store/Store.cs new file mode 100644 index 00000000..2a375907 --- /dev/null +++ b/src/Gameboard.Api/Data/Store/Store.cs @@ -0,0 +1,158 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; + +namespace Gameboard.Api.Data; + +public interface IStore +{ + Task AnyAsync() where TEntity : class, IEntity; + Task AnyAsync(Expression> predicate) where TEntity : class, IEntity; + Task CountAsync(Func, IQueryable> queryBuilder) where TEntity : class, IEntity; + Task Create(TEntity entity) where TEntity : class, IEntity; + Task Delete(string id) where TEntity : class, IEntity; + Task ExecuteUpdateAsync + ( + Expression> predicate, + Expression, SetPropertyCalls>> setPropertyCalls + ) where TEntity : class, IEntity; + Task Exists(string id) where TEntity : class, IEntity; + Task FirstOrDefaultAsync(CancellationToken cancellationToken) where TEntity : class, IEntity; + Task FirstOrDefaultAsync(bool enableTracking, CancellationToken cancellationToken) where TEntity : class, IEntity; + Task FirstOrDefaultAsync(Expression> predicate, CancellationToken cancellationToken) where TEntity : class, IEntity; + IQueryable List(bool enableTracking = false) where TEntity : class, IEntity; + Task Retrieve(string id, bool enableTracking = false) where TEntity : class, IEntity; + Task Retrieve(string id, Func, IQueryable> queryBuilder, bool enableTracking = false) where TEntity : class, IEntity; + Task SingleOrDefaultAsync(CancellationToken cancellationToken) where TEntity : class, IEntity; + Task SingleOrDefaultAsync(Expression> predicate, CancellationToken cancellationToken) where TEntity : class, IEntity; + Task Update(TEntity entity, CancellationToken cancellationToken) where TEntity : class, IEntity; +} + +internal class Store : IStore +{ + private readonly IGuidService _guids; + private readonly GameboardDbContext _dbContext; + + public Store(IGuidService guids, GameboardDbContext dbContext) + { + _dbContext = dbContext; + _guids = guids; + } + + public Task AnyAsync() where TEntity : class, IEntity + => _dbContext.Set().AnyAsync(); + + public Task AnyAsync(Expression> predicate) where TEntity : class, IEntity + => _dbContext.Set().AnyAsync(predicate); + + public async Task CountAsync(Func, IQueryable> queryBuilder) where TEntity : class, IEntity + { + var query = _dbContext.Set().AsNoTracking(); + query = queryBuilder?.Invoke(query); + return await query.CountAsync(); + } + + public async Task Create(TEntity entity) where TEntity : class, IEntity + { + if (string.IsNullOrWhiteSpace(entity.Id)) + entity.Id = _guids.GetGuid(); + + _dbContext.Add(entity); + await _dbContext.SaveChangesAsync(); + + return entity; + } + + public async Task Delete(string id) where TEntity : class, IEntity + { + var rowsAffected = await _dbContext + .Set() + .Where(e => e.Id == id) + .ExecuteDeleteAsync(); + + if (rowsAffected != 1) + throw new GameboardException($"""Delete of entity type {typeof(TEntity)} with id "{id}" affected {rowsAffected} rows (expected 1)."""); + } + + public Task ExecuteUpdateAsync + ( + Expression> predicate, + Expression, SetPropertyCalls>> setPropertyCalls + ) where TEntity : class, IEntity + { + return _dbContext + .Set() + .Where(predicate) + .ExecuteUpdateAsync(setPropertyCalls); + } + + public Task Exists(string id) where TEntity : class, IEntity + => _dbContext + .Set() + .AnyAsync(e => e.Id == id); + + public Task FirstOrDefaultAsync(CancellationToken cancellationToken) where TEntity : class, IEntity + => FirstOrDefaultAsync(null as Expression>, false, cancellationToken); + + public Task FirstOrDefaultAsync(bool enableTracking, CancellationToken cancellationToken) where TEntity : class, IEntity + => FirstOrDefaultAsync(null as Expression>, enableTracking, cancellationToken); + + public Task FirstOrDefaultAsync(Expression> predicate, CancellationToken cancellationToken) where TEntity : class, IEntity + => FirstOrDefaultAsync(predicate, false, cancellationToken); + + public Task FirstOrDefaultAsync(Expression> predicate, bool enableTracking, CancellationToken cancellationToken) where TEntity : class, IEntity + { + var query = GetQueryBase(enableTracking); + + if (predicate is not null) + return query.FirstOrDefaultAsync(predicate, cancellationToken); + + return query.FirstOrDefaultAsync(cancellationToken); + } + + public IQueryable List(bool enableTracking = false) where TEntity : class, IEntity + => GetQueryBase(enableTracking); + + public Task Retrieve(string id, bool enableTracking = false) where TEntity : class, IEntity + => GetQueryBase(enableTracking).FirstOrDefaultAsync(e => e.Id == id); + + public Task Retrieve(string id, Func, IQueryable> queryBuilder, bool enableTracking = false) where TEntity : class, IEntity + { + var query = GetQueryBase(enableTracking); + query = queryBuilder?.Invoke(query); + return query.FirstOrDefaultAsync(e => e.Id == id); + } + + public Task SingleOrDefaultAsync(CancellationToken cancellationToken) where TEntity : class, IEntity + => GetQueryBase().SingleOrDefaultAsync(cancellationToken); + + public Task SingleOrDefaultAsync(Expression> predicate, CancellationToken cancellationToken) where TEntity : class, IEntity + { + return GetQueryBase().SingleOrDefaultAsync(predicate, cancellationToken); + } + + public async Task Update(TEntity entity, CancellationToken cancellationToken) where TEntity : class, IEntity + { + if (_dbContext.Entry(entity).State == EntityState.Detached) + _dbContext.Attach(entity); + + _dbContext.Update(entity); + await _dbContext.SaveChangesAsync(); + return entity; + } + + private IQueryable GetQueryBase(bool enableTracking = false) where TEntity : class, IEntity + { + var query = _dbContext.Set().AsQueryable(); + + if (!enableTracking) + query = query.AsNoTracking(); + + return query; + } +} diff --git a/src/Gameboard.Api/Data/Store/Store[TEntity].cs b/src/Gameboard.Api/Data/Store/Store[TEntity].cs index ba9ca2be..5ce4416c 100644 --- a/src/Gameboard.Api/Data/Store/Store[TEntity].cs +++ b/src/Gameboard.Api/Data/Store/Store[TEntity].cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Threading.Tasks; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Services; @@ -25,11 +26,24 @@ public Store(IGuidService guids, GameboardDbContext dbContext) public GameboardDbContext DbContext { get; private set; } public IQueryable DbSet { get; private set; } + public Task AnyAsync() + => DbContext.Set().AnyAsync(); + + public Task AnyAsync(Expression> predicate) + => DbContext.Set().AnyAsync(predicate); + public virtual IQueryable List(string term = null) { return DbContext.Set(); } + public IQueryable ListWithNoTracking() + => DbContext. + Set() + .AsNoTracking() + .AsQueryable(); + + public virtual async Task Create(TEntity entity) { if (string.IsNullOrWhiteSpace(entity.Id)) @@ -88,17 +102,11 @@ public virtual async Task Update(IEnumerable range) await DbContext.SaveChangesAsync(); } - public virtual async Task Delete(string id) - { - var entity = await DbContext.Set().FindAsync(id); - - if (entity is TEntity) - { - DbContext.Set().Remove(entity); - - await DbContext.SaveChangesAsync(); - } - } + public async Task Delete(string id) + => await DbContext + .Set() + .Where(e => e.Id == id) + .ExecuteDeleteAsync(); public virtual async Task CountAsync(Func, IQueryable> queryBuilder = null) { diff --git a/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs b/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs index f4d88131..c7d54fc4 100644 --- a/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs +++ b/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs @@ -21,24 +21,22 @@ public static class DatabaseStartupExtensions { public static WebApplication InitializeDatabase(this WebApplication app, AppSettings settings, ILogger logger) { - using (var scope = app.Services.CreateScope()) - { - var services = scope.ServiceProvider; - var config = services.GetRequiredService(); - var env = services.GetService(); + using var scope = app.Services.CreateScope(); + var services = scope.ServiceProvider; + var config = services.GetRequiredService(); + var env = services.GetService(); - using (var db = services.GetService()) + using (var db = services.GetService()) + { + if (!db.Database.IsInMemory()) { - if (!db.Database.IsInMemory()) - { - db.Database.Migrate(); - } - - SeedDatabase(env, config, db, settings, logger); + db.Database.Migrate(); } - return app; + SeedDatabase(env, config, db, settings, logger); } + + return app; } private static void SeedEnumerable(this GameboardDbContext db, IEnumerable entities, ILogger logger) where T : class, IEntity @@ -121,7 +119,7 @@ private static DbSeedModel LoadSeedModel(string seedFilePath) { var text = File.ReadAllText(seedFilePath); var extension = Path.GetExtension(seedFilePath).ToLower(); - DbSeedModel seedModel = null; + DbSeedModel seedModel; switch (extension) { diff --git a/src/Gameboard.Api/Extensions/JsonExceptionMiddleware.cs b/src/Gameboard.Api/Extensions/JsonExceptionMiddleware.cs index 113c052a..caa5b5c2 100644 --- a/src/Gameboard.Api/Extensions/JsonExceptionMiddleware.cs +++ b/src/Gameboard.Api/Extensions/JsonExceptionMiddleware.cs @@ -47,7 +47,7 @@ public async Task Invoke(HttpContext context) context.Response.StatusCode = 400; message = ex.Message; } - else if (typeof(GameboardValidationException).IsAssignableFrom(type)) + else if (typeof(GameboardValidationException).IsAssignableFrom(type) || typeof(GameboardAggregatedValidationExceptions).IsAssignableFrom(type)) { context.Response.StatusCode = 400; message = ex.Message; diff --git a/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs b/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs index 9f5eff48..9ae4958a 100644 --- a/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs @@ -29,6 +29,8 @@ public static AppSettings BuildAppSettings(this WebApplicationBuilder builder, I settings.Core.ImageFolder ?? "" ); + settings.Core.WebHostRoot = builder.Environment.ContentRootPath; + if (settings.Core.ChallengeDocUrl.IsEmpty()) settings.Core.ChallengeDocUrl = settings.PathBase; @@ -37,6 +39,18 @@ public static AppSettings BuildAppSettings(this WebApplicationBuilder builder, I Directory.CreateDirectory(settings.Core.ImageFolder); + settings.Core.TempDirectory = Path.Combine + ( + builder.Environment.ContentRootPath, + settings.Core.TempDirectory = "wwwroot/temp" + ); + + settings.Core.TemplatesDirectory = Path.Combine + ( + builder.Environment.ContentRootPath, + settings.Core.TemplatesDirectory ?? "wwwroot/templates" + ); + CsvConfig>.OmitHeaders = true; CsvConfig>.OmitHeaders = true; CsvConfig.OmitHeaders = true; diff --git a/src/Gameboard.Api/Extensions/WebApplicationExtensions.cs b/src/Gameboard.Api/Extensions/WebApplicationExtensions.cs index 91992200..352a5240 100644 --- a/src/Gameboard.Api/Extensions/WebApplicationExtensions.cs +++ b/src/Gameboard.Api/Extensions/WebApplicationExtensions.cs @@ -33,9 +33,7 @@ public static WebApplication ConfigureGameboard(this WebApplication app, AppSett app.UseStaticFiles(); if (settings.Logging.EnableHttpLogging) - { app.UseHttpLogging(); - } if (settings.OpenApi.Enabled) app.UseConfiguredSwagger(settings.OpenApi, settings.Oidc.Audience, settings.PathBase); diff --git a/src/Gameboard.Api/Features/ApiKeys/ApiKeysService.cs b/src/Gameboard.Api/Features/ApiKeys/ApiKeysService.cs index f78a76b0..36adac40 100644 --- a/src/Gameboard.Api/Features/ApiKeys/ApiKeysService.cs +++ b/src/Gameboard.Api/Features/ApiKeys/ApiKeysService.cs @@ -15,6 +15,7 @@ public interface IApiKeysService Task Authenticate(string headerValue); Task Create(NewApiKey newApiKey); Task Delete(string apiKeyId); + Task GetUserFromApiKey(string apiKey); Task> ListKeys(string userId); } @@ -23,24 +24,21 @@ internal class ApiKeysService : IApiKeysService private readonly IGuidService _guids; private readonly IMapper _mapper; private readonly INowService _now; - private readonly IHashService _hasher; private readonly IRandomService _rng; private readonly IApiKeysStore _store; private readonly ApiKeyOptions _options; - private readonly IUserStore _userStore; + private readonly IStore _userStore; public ApiKeysService( ApiKeyOptions options, IGuidService guids, IMapper mapper, INowService now, - IHashService hasher, IRandomService rng, IApiKeysStore store, - IUserStore userStore) + IStore userStore) { _guids = guids; - _hasher = hasher; _mapper = mapper; _now = now; _rng = rng; @@ -50,16 +48,12 @@ public ApiKeysService( } public async Task Authenticate(string headerValue) - { - var apiKey = headerValue.Trim(); - - return await _store.GetFromApiKey(apiKey); - } + => await GetUserFromApiKey(headerValue.Trim()); public async Task Create(NewApiKey newApiKey) { var user = await _userStore.Retrieve(newApiKey.UserId); - if (user == null) + if (user is null) throw new ResourceNotFound(newApiKey.UserId); var generatedKey = GenerateKey(); @@ -85,6 +79,18 @@ public async Task Create(NewApiKey newApiKey) public async Task Delete(string apiKeyId) => await _store.Delete(apiKeyId); + public async Task GetUserFromApiKey(string apiKey) + { + var hashedKey = apiKey.ToSha256(); + + return await _userStore + .ListWithNoTracking() + .Include(u => u.ApiKeys) + // we use SingleOrDefaultAsync to ensure that we only get one result - + // if we get more than one, some weird stuff is happening and we need to know. + .SingleOrDefaultAsync(u => u.ApiKeys.Any(k => k.Key == hashedKey)); + } + public async Task> ListKeys(string userId) { return await _mapper @@ -98,7 +104,7 @@ internal string GenerateKey() return keyRaw.Substring(0, Math.Min(keyRaw.Length, _options.RandomCharactersLength)); } - internal bool IsValidKey(string hashedKey, Data.ApiKey candidate) + internal bool IsValidKey(string hashedKey, ApiKey candidate) => hashedKey == candidate.Key && ( candidate.ExpiresOn == null || diff --git a/src/Gameboard.Api/Features/ApiKeys/ApiKeysStore.cs b/src/Gameboard.Api/Features/ApiKeys/ApiKeysStore.cs index 9135330a..073cefbe 100644 --- a/src/Gameboard.Api/Features/ApiKeys/ApiKeysStore.cs +++ b/src/Gameboard.Api/Features/ApiKeys/ApiKeysStore.cs @@ -9,7 +9,6 @@ public interface IApiKeysStore { Task Delete(string id); Task Exists(string id); - Task GetFromApiKey(string apiKey); Task Create(ApiKey apiKey); IQueryable List(string userId); } @@ -28,16 +27,6 @@ public async Task Exists(string apiKeyId) .ApiKeys .SingleOrDefaultAsync(k => k.Id == apiKeyId)) != null; - public async Task GetFromApiKey(string apiKey) - { - var hashedKey = apiKey.ToSha256(); - - return await _dbContext - .Users - .Include(u => u.ApiKeys) - .SingleOrDefaultAsync(u => u.ApiKeys.Any(k => k.Key == hashedKey)); - } - public async Task Create(ApiKey apiKey) { _dbContext diff --git a/src/Gameboard.Api/Features/ApiKeys/ApiKeysValidator.cs b/src/Gameboard.Api/Features/ApiKeys/ApiKeysValidator.cs index fbd27a59..a6fb9977 100644 --- a/src/Gameboard.Api/Features/ApiKeys/ApiKeysValidator.cs +++ b/src/Gameboard.Api/Features/ApiKeys/ApiKeysValidator.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using Gameboard.Api.Data; using Gameboard.Api.Features.Users; +using Gameboard.Api.Services; using Gameboard.Api.Validators; namespace Gameboard.Api.Features.ApiKeys; diff --git a/src/Gameboard.Api/Features/Certificates/CertificatesController.cs b/src/Gameboard.Api/Features/Certificates/CertificatesController.cs new file mode 100644 index 00000000..4f92e091 --- /dev/null +++ b/src/Gameboard.Api/Features/Certificates/CertificatesController.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common.Services; +using Gameboard.Api.Features.Practice; +using Gameboard.Api.Services; +using Gameboard.Api.Structure; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Gameboard.Api.Features.Certificates; + +[Authorize] +[Route("/api/user")] +public class CertificatesController : ControllerBase +{ + private readonly IActingUserService _actingUser; + private readonly IHtmlToImageService _htmlToImage; + private readonly IMediator _mediator; + + public CertificatesController + ( + IActingUserService actingUser, + IHtmlToImageService htmlToImage, + IMediator mediator + ) + { + _actingUser = actingUser; + _htmlToImage = htmlToImage; + _mediator = mediator; + } + + [HttpGet] + [Route("{userId}/certificates/practice")] + public Task> ListCertificates([FromRoute] string userId) + => _mediator.Send(new GetPracticeModeCertificatesQuery(userId, _actingUser.Get())); + + [HttpPost] + [Route("{userId}/certificates/competitive/{gameId}")] + public Task PublishCompetitiveCertificate([FromRoute] string gameId, CancellationToken cancellationToken) + => _mediator.Send(new SetCompetitiveCertificateIsPublishedCommand(gameId, true, _actingUser.Get()), cancellationToken); + + [HttpDelete] + [Route("{userId}/certificates/competitive/{gameId}")] + public Task UnpublishCompetitiveCertificate([FromRoute] string gameId, CancellationToken cancellationToken) + => _mediator.Send(new SetCompetitiveCertificateIsPublishedCommand(gameId, false, _actingUser.Get()), cancellationToken); + + [HttpPost] + [Route("{userId}/certificates/practice/{challengeSpecId}")] + public Task PublishPracticeCertificate([FromRoute] string challengeSpecId, CancellationToken cancellationToken) + => _mediator.Send(new SetPracticeCertificateIsPublishedCommand(challengeSpecId, true, _actingUser.Get()), cancellationToken); + + [HttpDelete] + [Route("{userId}/certificates/practice/{challengeSpecId}")] + public Task UnpublishPracticeCertificate([FromRoute] string challengeSpecId, CancellationToken cancellationToken) + => _mediator.Send(new SetPracticeCertificateIsPublishedCommand(challengeSpecId, false, _actingUser.Get()), cancellationToken); + + [HttpGet] + [Route("{userId}/certificates/practice/{awardedForEntityId}")] + [AllowAnonymous] // anyone can _try_, but we only serve them the cert if it's published (or if they're the owner) + public async Task GetPracticeCertificatePng([FromRoute] string userId, [FromRoute] string awardedForEntityId, CancellationToken cancellationToken) + { + var html = await _mediator.Send(new GetPracticeModeCertificateHtmlQuery(awardedForEntityId, userId, _actingUser.Get()), cancellationToken); + return File(await _htmlToImage.ToPng($"{_actingUser.Get().Id}_{awardedForEntityId}", html, 3300, 2550), MimeTypes.ImagePng); + } + + [HttpGet] + [Route("{userId}/certificates/practice/{awardedForEntityId}/pdf")] + [AllowAnonymous] // anyone can _try_, but we only serve them the cert if it's published (or if they're the owner) + public async Task GetPracticeCertificatePdf([FromRoute] string userId, [FromRoute] string awardedForEntityId, CancellationToken cancellationToken) + { + var html = await _mediator.Send(new GetPracticeModeCertificateHtmlQuery(awardedForEntityId, userId, _actingUser.Get()), cancellationToken); + return File(await _htmlToImage.ToPdf($"{_actingUser.Get().Id}_{awardedForEntityId}", html, 3300, 2550), MimeTypes.ApplicationPdf); + } + + [HttpGet] + [Route("{userId}/certificates/competitive/{awardedForEntityId}")] + [AllowAnonymous] // anyone can _try_, but we only serve them the cert if it's published (or if they're the owner) + public async Task GetCompetitiveCertificatePng([FromRoute] string userId, [FromRoute] string awardedForEntityId, CancellationToken cancellationToken) + { + var html = await _mediator.Send(new GetCompetitiveModeCertificateHtmlQuery(awardedForEntityId, userId, _actingUser.Get().Id), cancellationToken); + return File(await _htmlToImage.ToPng($"{_actingUser.Get().Id}_{awardedForEntityId}", html, 3300, 2550), MimeTypes.ImagePng); + } +} diff --git a/src/Gameboard.Api/Features/Certificates/CertificatesExceptions.cs b/src/Gameboard.Api/Features/Certificates/CertificatesExceptions.cs new file mode 100644 index 00000000..1c14e248 --- /dev/null +++ b/src/Gameboard.Api/Features/Certificates/CertificatesExceptions.cs @@ -0,0 +1,10 @@ +using Gameboard.Api.Data; +using Gameboard.Api.Structure; + +namespace Gameboard.Api.Features.Certificates; + +public class CertificateIsntPublished : GameboardValidationException +{ + public CertificateIsntPublished(string ownerUserId, PublishedCertificateMode mode, string entityId) + : base($"""There is no published certificate for user "{ownerUserId}" playing entity "{entityId}" in {mode.ToString()} mode.""") { } +} diff --git a/src/Gameboard.Api/Features/Certificates/CertificatesModels.cs b/src/Gameboard.Api/Features/Certificates/CertificatesModels.cs new file mode 100644 index 00000000..9e75d26b --- /dev/null +++ b/src/Gameboard.Api/Features/Certificates/CertificatesModels.cs @@ -0,0 +1,42 @@ +using System; +using Gameboard.Api.Common; +using Gameboard.Api.Data; + +namespace Gameboard.Api.Features.Certificates; + +public sealed class PracticeModeCertificate +{ + public required PracticeModeCertificateChallenge Challenge { get; set; } + public required string PlayerName { get; set; } + public required DateTimeOffset Date { get; set; } + public required double Score { get; set; } + public required TimeSpan Time { get; set; } + public required PracticeModeCertificateGame Game { get; set; } + public required DateTimeOffset? PublishedOn { get; set; } +} + +public sealed class PracticeModeCertificateChallenge +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string Description { get; set; } + public required string ChallengeSpecId { get; set; } +} + +public sealed class PracticeModeCertificateGame +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string Division { get; set; } + public required string Season { get; set; } + public required string Track { get; set; } +} + +public class PublishedCertificateViewModel +{ + public required string Id { get; set; } + public required DateTimeOffset? PublishedOn { get; set; } + public required PublishedCertificateMode Mode { get; set; } + public required SimpleEntity AwardedForEntity { get; set; } + public required SimpleEntity OwnerUser { get; set; } +} diff --git a/src/Gameboard.Api/Features/Certificates/CertificatesService.cs b/src/Gameboard.Api/Features/Certificates/CertificatesService.cs new file mode 100644 index 00000000..ea9cf1cd --- /dev/null +++ b/src/Gameboard.Api/Features/Certificates/CertificatesService.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Gameboard.Api.Data; +using Gameboard.Api.Services; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Certificates; + +public interface ICertificatesService +{ + Task> GetPracticeCertificates(string userId); +} + +internal class CertificatesService : ICertificatesService +{ + private readonly INowService _now; + private readonly IStore _store; + + public CertificatesService(INowService now, IStore store) + { + _now = now; + _store = store; + } + + public async Task> GetPracticeCertificates(string userId) + { + var challenges = await _store + .List() + .Include(c => c.Game) + .Include(c => c.Player) + .ThenInclude(p => p.User) + .Where(c => c.Score >= c.Points) + .Where(c => c.PlayerMode == PlayerMode.Practice) + .Where(c => c.Player.UserId == userId) + .WhereDateIsNotEmpty(c => c.LastScoreTime) + .GroupBy(c => c.SpecId) + .ToDictionaryAsync(g => g.Key, g => g.ToList().OrderBy(c => c.StartTime).FirstOrDefault()); + + // have to hit specs separately for now + var specIds = challenges.Values.Select(c => c.SpecId); + var specs = await _store + .List() + .Include(s => s.PublishedPracticeCertificates.Where(c => c.OwnerUserId == userId)) + .Where(s => specIds.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => s); + + return challenges + .Select(entry => entry.Value) + .Select(attempt => new PracticeModeCertificate + { + Challenge = new() + { + Id = attempt.Id, + Name = attempt.Name, + Description = specs.ContainsKey(attempt.SpecId) ? specs[attempt.SpecId].Description : string.Empty, + ChallengeSpecId = attempt.SpecId + }, + PlayerName = attempt.Player.User.ApprovedName, + Date = attempt.StartTime, + Score = attempt.Score, + Time = attempt.LastScoreTime - attempt.StartTime, + Game = new() + { + Id = attempt.GameId, + Name = attempt.Game.Name, + Division = attempt.Game.Competition, + Season = attempt.Game.Season, + Track = attempt.Game.Track + }, + PublishedOn = specs.ContainsKey(attempt.SpecId) ? specs[attempt.SpecId].PublishedPracticeCertificates.FirstOrDefault()?.PublishedOn : null + }).ToArray(); + } +} diff --git a/src/Gameboard.Api/Features/Certificates/Requests/GetCompetitiveModeCertificateHtml.cs b/src/Gameboard.Api/Features/Certificates/Requests/GetCompetitiveModeCertificateHtml.cs new file mode 100644 index 00000000..8ce8c998 --- /dev/null +++ b/src/Gameboard.Api/Features/Certificates/Requests/GetCompetitiveModeCertificateHtml.cs @@ -0,0 +1,62 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Data; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Certificates; + +public record GetCompetitiveModeCertificateHtmlQuery(string GameId, string OwnerUserId, string ActingUserId) : IRequest; + +internal class GetCompetitiveModeCertificateHtmlHandler : IRequestHandler +{ + private readonly EntityExistsValidator _gameExists; + private readonly PlayerService _playerService; + private readonly IStore _store; + private readonly IValidatorService _validatorService; + + public GetCompetitiveModeCertificateHtmlHandler + ( + EntityExistsValidator gameExists, + PlayerService playerService, + IStore store, + IValidatorService validatorService + ) + { + _gameExists = gameExists; + _playerService = playerService; + _store = store; + _validatorService = validatorService; + } + + public async Task Handle(GetCompetitiveModeCertificateHtmlQuery request, CancellationToken cancellationToken) + { + var isPublished = await _store + .List() + .AnyAsync(c => c.GameId == request.GameId && c.OwnerUserId == request.OwnerUserId); + + await _validatorService + .AddValidator(_gameExists.UseProperty(r => r.GameId)) + .AddValidator((request, context) => + { + if (request.OwnerUserId != request.ActingUserId && !isPublished) + context.AddValidationException(new CertificateIsntPublished(request.OwnerUserId, PublishedCertificateMode.Competitive, request.GameId)); + + return Task.CompletedTask; + }) + .Validate(request); + + var player = await _store + .List() + .Where(p => p.UserId == request.OwnerUserId) + .Where(p => p.GameId == request.GameId) + .FirstAsync(); + + var certificate = await _playerService.MakeCertificate(player.Id); + return certificate.Html; + } +} diff --git a/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificateHtml.cs b/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificateHtml.cs new file mode 100644 index 00000000..c10dc8cd --- /dev/null +++ b/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificateHtml.cs @@ -0,0 +1,112 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Certificates; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; + +namespace Gameboard.Api.Features.Practice; + +public record GetPracticeModeCertificateHtmlQuery(string ChallengeSpecId, string CertificateOwnerUserId, User ActingUser) : IRequest; + +internal class GetPracticeModeCertificateHtmlHandler : IRequestHandler +{ + private readonly ICertificatesService _certificatesService; + private readonly CoreOptions _coreOptions; + private readonly EntityExistsValidator _actingUserExists; + private readonly EntityExistsValidator _certificateOwnerExists; + private readonly IPracticeService _practiceService; + private readonly IValidatorService _validatorService; + + public GetPracticeModeCertificateHtmlHandler + ( + EntityExistsValidator actingUserExists, + EntityExistsValidator certificateOwnerExists, + ICertificatesService certificatesService, + CoreOptions coreOptions, + IPracticeService practiceService, + IValidatorService validatorService + ) + { + _actingUserExists = actingUserExists; + _certificateOwnerExists = certificateOwnerExists; + _certificatesService = certificatesService; + _coreOptions = coreOptions; + _practiceService = practiceService; + _validatorService = validatorService; + } + + public async Task Handle(GetPracticeModeCertificateHtmlQuery request, CancellationToken cancellationToken) + { + var certificate = (await _certificatesService.GetPracticeCertificates(request.CertificateOwnerUserId)) + .FirstOrDefault(c => c.Challenge.ChallengeSpecId == request.ChallengeSpecId); + + await _validatorService + .AddValidator(_actingUserExists.UseProperty(r => r.ActingUser.Id)) + .AddValidator(_certificateOwnerExists.UseProperty(r => r.CertificateOwnerUserId)) + .AddValidator((request, context) => + { + if (request.CertificateOwnerUserId != request.ActingUser.Id && certificate.PublishedOn is null) + context.AddValidationException(new CertificateIsntPublished(request.CertificateOwnerUserId, PublishedCertificateMode.Practice, request.ChallengeSpecId)); + + return Task.CompletedTask; + }) + .Validate(request); + + if (certificate is null) + throw new ResourceNotFound(request.ChallengeSpecId, $"Couldn't resolve a certificate for owner {request.CertificateOwnerUserId} and challenge spec {request.ChallengeSpecId}"); + + // load the outer template from this application (this is custom crafted by us to ensure we end up) + // with a consistent HTML-compliant base + var outerTemplatePath = Path.Combine(_coreOptions.TemplatesDirectory, "practice-certificate.template.html"); + var outerTemplate = File.ReadAllText(outerTemplatePath); + + // the "inner" template is user-defined and loaded from settings + var settings = await _practiceService.GetSettings(cancellationToken); + var innerTemplate = $""" +

+ You successfully completed challenge {certificate.Challenge.Name} on {certificate.Date} with + a score of {certificate.Score} and a time of {certificate.Time}, but the administrator of this + site hasn't configured a certificate template for the Practice Area. +

+ """.Trim(); + + if (!settings.CertificateHtmlTemplate.IsEmpty()) + innerTemplate = settings.CertificateHtmlTemplate + .Replace("{{challengeName}}", certificate.Challenge.Name) + .Replace("{{challengeDescription}}", certificate.Challenge.Description) + .Replace("{{date}}", certificate.Date.ToLocalTime().ToString("M/d/yyyy")) + .Replace("{{division}}", certificate.Game.Division) + .Replace("{{playerName}}", certificate.PlayerName) + .Replace("{{score}}", certificate.Score.ToString()) + .Replace("{{season}}", certificate.Game.Season) + .Replace("{{time}}", GetDurationDescription(certificate.Time)) + .Replace("{{track}}", certificate.Game.Track); + + // compose final html and save to a temp file + return outerTemplate.Replace("{{bodyContent}}", innerTemplate); + } + + internal string GetDurationDescription(TimeSpan time) + { + // compute time string - hours and minutes rounded off + var timeString = "Less than a minute"; + if (Math.Round(time.TotalMinutes, 0) > 0) + { + var hours = Math.Floor(time.TotalHours); + + // for each of the hour and minute strings, do pluralization stuff or set to empty + // if the value is zero + var hoursString = hours > 0 ? $"{hours} hour{(hours == 1 ? "" : "s")}" : string.Empty; + var minutesString = time.Minutes > 0 ? $"{time.Minutes} minute{(time.Minutes == 1 ? "" : "s")}" : string.Empty; + + timeString = $"{hoursString}{(!hoursString.IsEmpty() && !minutesString.IsEmpty() ? " and " : "")}{minutesString}"; + } + + return timeString; + } +} diff --git a/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificates.cs b/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificates.cs new file mode 100644 index 00000000..2a588d63 --- /dev/null +++ b/src/Gameboard.Api/Features/Certificates/Requests/GetPracticeModeCertificates.cs @@ -0,0 +1,46 @@ +using Gameboard.Api.Features.Certificates; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Authorizers; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Gameboard.Api.Features.Practice; + +public record GetPracticeModeCertificatesQuery(string CertificateOwnerUserId, User ActingUser) : IRequest>; + +internal class GetPracticeModeCertificatesHandler : IRequestHandler> +{ + private readonly ICertificatesService _certificatesService; + private readonly EntityExistsValidator _userExists; + private readonly UserRoleAuthorizer _userRoleAuthorizer; + private readonly IValidatorService _validatorService; + + public GetPracticeModeCertificatesHandler + ( + ICertificatesService certificatesService, + EntityExistsValidator userExists, + UserRoleAuthorizer userRoleAuthorizer, + IValidatorService validatorService + ) + { + _certificatesService = certificatesService; + _userExists = userExists; + _userRoleAuthorizer = userRoleAuthorizer; + _validatorService = validatorService; + } + + public async Task> Handle(GetPracticeModeCertificatesQuery request, CancellationToken cancellationToken) + { + _userRoleAuthorizer.AllowedRoles = new UserRole[] { UserRole.Admin }; + _userRoleAuthorizer.AllowedUserId = request.CertificateOwnerUserId; + _userRoleAuthorizer.Authorize(); + + _validatorService.AddValidator(_userExists.UseProperty(r => r.CertificateOwnerUserId)); + await _validatorService.Validate(request); + + return await _certificatesService.GetPracticeCertificates(request.CertificateOwnerUserId); + } +} diff --git a/src/Gameboard.Api/Features/Certificates/Requests/SetCompetitiveCertificateIsPublished.cs b/src/Gameboard.Api/Features/Certificates/Requests/SetCompetitiveCertificateIsPublished.cs new file mode 100644 index 00000000..76c02bc9 --- /dev/null +++ b/src/Gameboard.Api/Features/Certificates/Requests/SetCompetitiveCertificateIsPublished.cs @@ -0,0 +1,101 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common; +using Gameboard.Api.Data; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Certificates; + +public record SetCompetitiveCertificateIsPublishedCommand(string GameId, bool IsPublished, User ActingUser) : IRequest; + +internal class SetCompetitiveCertificateIsPublishedHandler : IRequestHandler +{ + private readonly EntityExistsValidator _gameExists; + private readonly IGuidService _guidService; + private readonly INowService _nowService; + private readonly IStore _store; + private readonly IValidatorService _validator; + + public SetCompetitiveCertificateIsPublishedHandler + ( + EntityExistsValidator gameExists, + IGuidService guidService, + INowService nowService, + IStore store, + IValidatorService validator + ) + { + _guidService = guidService; + _nowService = nowService; + _gameExists = gameExists; + _store = store; + _validator = validator; + } + + public async Task Handle(SetCompetitiveCertificateIsPublishedCommand request, CancellationToken cancellationToken) + { + await _validator + .AddValidator(_gameExists.UseProperty(r => r.GameId)) + .Validate(request); + + // pull the existing publish if it exists - we need it for return on the unpublish case + var existingPublish = await GetExistingPublish(request.ActingUser.Id, request.GameId, cancellationToken); + + if (request.IsPublished && existingPublish is null) + { + var publish = new PublishedCompetitiveCertificate + { + Id = _guidService.GetGuid(), + OwnerUserId = request.ActingUser.Id, + PublishedOn = _nowService.Get(), + GameId = request.GameId + }; + await _store.Create(publish); + + // pull the game and owner for return val + var createdPublish = await GetExistingPublish(request.ActingUser.Id, request.GameId, cancellationToken); + + return new PublishedCertificateViewModel + { + Id = createdPublish.Id, + PublishedOn = createdPublish.PublishedOn, + AwardedForEntity = new SimpleEntity { Id = createdPublish.GameId, Name = createdPublish.Game.Name }, + OwnerUser = new SimpleEntity { Id = createdPublish.OwnerUser.Id, Name = createdPublish.OwnerUser.ApprovedName }, + Mode = PublishedCertificateMode.Competitive + }; + } + else if (!request.IsPublished && existingPublish is not null) + { + await _store + .List() + .Where(c => c.GameId == request.GameId) + .Where(c => c.OwnerUserId == request.ActingUser.Id) + .ExecuteDeleteAsync(cancellationToken); + + return new PublishedCertificateViewModel + { + Id = existingPublish.Id, + PublishedOn = null, + AwardedForEntity = new SimpleEntity { Id = existingPublish.GameId, Name = existingPublish.Game.Name }, + OwnerUser = new SimpleEntity { Id = existingPublish.OwnerUser.Id, Name = existingPublish.OwnerUser.ApprovedName }, + Mode = PublishedCertificateMode.Competitive + }; + } + + return null; + } + + private async Task GetExistingPublish(string ownerUserId, string gameId, CancellationToken cancellationToken) + => await _store + .List() + .Include(c => c.Game) + .Include(c => c.OwnerUser) + .Where(c => c.GameId == gameId) + .Where(c => c.OwnerUserId == ownerUserId) + .FirstOrDefaultAsync(cancellationToken); +} diff --git a/src/Gameboard.Api/Features/Certificates/Requests/SetPracticeCertificateIsPublished.cs b/src/Gameboard.Api/Features/Certificates/Requests/SetPracticeCertificateIsPublished.cs new file mode 100644 index 00000000..af720e25 --- /dev/null +++ b/src/Gameboard.Api/Features/Certificates/Requests/SetPracticeCertificateIsPublished.cs @@ -0,0 +1,102 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common; +using Gameboard.Api.Data; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Certificates; + +public record SetPracticeCertificateIsPublishedCommand(string ChallengeSpecId, bool IsPublished, User ActingUser) : IRequest; + +internal class SetPracticeCertificateIsPublishedHandler : IRequestHandler +{ + private readonly IGuidService _guidService; + private readonly INowService _nowService; + private readonly EntityExistsValidator _specExists; + private readonly IStore _store; + private readonly IValidatorService _validator; + + public SetPracticeCertificateIsPublishedHandler + ( + IGuidService guidService, + INowService nowService, + EntityExistsValidator specExists, + IStore store, + IValidatorService validator + ) + { + _guidService = guidService; + _nowService = nowService; + _specExists = specExists; + _store = store; + _validator = validator; + } + + public async Task Handle(SetPracticeCertificateIsPublishedCommand request, CancellationToken cancellationToken) + { + _validator.AddValidator(_specExists.UseProperty(r => r.ChallengeSpecId)); + await _validator.Validate(request); + + // pull the existing publish if it exists - we need it for return on the unpublish case + var existingPublish = await GetExistingPublish(request.ActingUser.Id, request.ChallengeSpecId, cancellationToken); + + if (request.IsPublished && existingPublish is null) + { + var publish = new PublishedPracticeCertificate + { + Id = _guidService.GetGuid(), + OwnerUserId = request.ActingUser.Id, + PublishedOn = _nowService.Get(), + Mode = PublishedCertificateMode.Practice, + ChallengeSpecId = request.ChallengeSpecId + }; + await _store.Create(publish); + + // pull the challenge spec and owner for return val + var createdPublish = await GetExistingPublish(request.ActingUser.Id, request.ChallengeSpecId, cancellationToken); + + return new PublishedCertificateViewModel + { + Id = createdPublish.Id, + PublishedOn = createdPublish.PublishedOn, + AwardedForEntity = new SimpleEntity { Id = createdPublish.ChallengeSpecId, Name = createdPublish.ChallengeSpec.Name }, + OwnerUser = new SimpleEntity { Id = createdPublish.OwnerUser.Id, Name = createdPublish.OwnerUser.ApprovedName }, + Mode = PublishedCertificateMode.Practice + }; + } + else if (!request.IsPublished && existingPublish is not null) + { + await _store + .List() + .Where(c => c.ChallengeSpecId == request.ChallengeSpecId) + .Where(c => c.OwnerUserId == request.ActingUser.Id) + .ExecuteDeleteAsync(cancellationToken); + + return new PublishedCertificateViewModel + { + Id = existingPublish.Id, + PublishedOn = null, + AwardedForEntity = new SimpleEntity { Id = existingPublish.ChallengeSpecId, Name = existingPublish.ChallengeSpec.Name }, + OwnerUser = new SimpleEntity { Id = existingPublish.OwnerUser.Id, Name = existingPublish.OwnerUser.ApprovedName }, + Mode = PublishedCertificateMode.Practice + }; + } + + return null; + } + + private async Task GetExistingPublish(string ownerUserId, string challengeSpecId, CancellationToken cancellationToken) + => await _store + .List() + .Include(c => c.ChallengeSpec) + .Include(c => c.OwnerUser) + .Where(c => c.ChallengeSpecId == challengeSpecId) + .Where(c => c.OwnerUserId == ownerUserId) + .FirstOrDefaultAsync(cancellationToken); +} diff --git a/src/Gameboard.Api/Features/Challenge/Challenge.cs b/src/Gameboard.Api/Features/Challenge/Challenge.cs index 1acd7fe7..6dbbf9d9 100644 --- a/src/Gameboard.Api/Features/Challenge/Challenge.cs +++ b/src/Gameboard.Api/Features/Challenge/Challenge.cs @@ -3,197 +3,235 @@ using System; using System.Collections.Generic; +using Gameboard.Api.Common; using Gameboard.Api.Features.GameEngine; +using Gameboard.Api.Features.Player; -namespace Gameboard.Api -{ - public class Challenge - { - public string Id { get; set; } - public string SpecId { get; set; } - public string TeamId { get; set; } - public string Name { get; set; } - public string Tag { get; set; } - public DateTimeOffset StartTime { get; set; } - public DateTimeOffset EndTime { get; set; } - public DateTimeOffset LastScoreTime { get; set; } - public DateTimeOffset LastSyncTime { get; set; } - public bool HasDeployedGamespace { get; set; } - public int Points { get; set; } - public int Score { get; set; } - public long Duration { get; set; } - public ChallengeResult Result { get; set; } - public ChallengeEventSummary[] Events { get; set; } - public GameEngineGameState State { get; set; } - } - - public class ChallengeSummary - { - public string Id { get; set; } - public string TeamId { get; set; } - public string Name { get; set; } - public string Tag { get; set; } - public string GameName { get; set; } - public IEnumerable Players { get; set; } - public DateTimeOffset StartTime { get; set; } - public DateTimeOffset EndTime { get; set; } - public DateTimeOffset LastScoreTime { get; set; } - public DateTimeOffset LastSyncTime { get; set; } - public bool HasDeployedGamespace { get; set; } - public int Points { get; set; } - public int Score { get; set; } - public long Duration { get; set; } - public ChallengeResult Result { get; set; } - public ChallengeEventSummary[] Events { get; set; } - public bool IsActive { get; set; } - } - - public class ChallengePlayer - { - public string Id { get; set; } - public string Name { get; set; } - public bool IsManager { get; set; } - public string UserId { get; set; } - } - - public class NewChallenge - { - public string SpecId { get; set; } - public string PlayerId { get; set; } - public int Variant { get; set; } - } - - public class ChangedChallenge - { - public string Id { get; set; } - } - - public class TeamChallenge - { - public string Id { get; set; } - public string TeamId { get; set; } - public string Name { get; set; } - public string Tag { get; set; } - public int Points { get; set; } - public int Score { get; set; } - public long Duration { get; set; } - public ChallengeResult Result { get; set; } - public ChallengeEventSummary[] Events { get; set; } - } - - public class ChallengeOverview - { - public string Id { get; set; } - public string TeamId { get; set; } - public string GameId { get; set; } - public string GameName { get; set; } - public string Name { get; set; } - public string Tag { get; set; } - public int Points { get; set; } - public int Score { get; set; } - public long Duration { get; set; } - public bool AllowTeam { get; set; } - } - - public class ObserveChallenge - { - public string Id { get; set; } - public string TeamId { get; set; } - public string Name { get; set; } - public string Tag { get; set; } - public string PlayerId { get; set; } - public string PlayerName { get; set; } - public long Duration { get; set; } - public int ChallengeScore { get; set; } - public int GameScore { get; set; } - public int GameRank { get; set; } - public bool isActive { get; set; } - public ObserveVM[] Consoles { get; set; } - } - - public class ObserveVM - { - public string Id { get; set; } - public string Name { get; set; } - public string ChallengeId { get; set; } - public bool IsRunning { get; set; } - public bool IsVisible { get; set; } - } - - public class ConsoleRequest - { - public string Name { get; set; } - public string SessionId { get; set; } - public ConsoleAction Action { get; set; } - public string Id => $"{Name}#{SessionId}"; - } - - public class ConsoleSummary - { - public string Id { get; set; } - public string Name { get; set; } - public string SessionId { get; set; } - public string Url { get; set; } - public bool IsRunning { get; set; } - public bool IsObserver { get; set; } - } - - public enum ConsoleAction - { - None, - Ticket, - Reset - } - - public class ConsoleActor - { - public string UserId { get; set; } - public string UserName { get; set; } - public string PlayerName { get; set; } - public string ChallengeName { get; set; } - public string ChallengeId { get; set; } - public string GameId { get; set; } - public string TeamId { get; set; } - public string VmName { get; set; } - public DateTimeOffset Timestamp { get; set; } - } - - public class ArchivedChallenge - { - public string Id { get; set; } - public string TeamId { get; set; } - public string Name { get; set; } - public string Tag { get; set; } - public string GameId { get; set; } - public string GameName { get; set; } - public string PlayerId { get; set; } - public string PlayerName { get; set; } - public string UserId { get; set; } - public DateTimeOffset StartTime { get; set; } - public DateTimeOffset EndTime { get; set; } - public DateTimeOffset LastScoreTime { get; set; } - public DateTimeOffset LastSyncTime { get; set; } - public bool HasGamespaceDeployed { get; set; } - public int Points { get; set; } - public int Score { get; set; } - public long Duration { get; set; } - public ChallengeResult Result { get; set; } - public ChallengeEventSummary[] Events { get; set; } - public string[] TeamMembers { get; set; } // User Ids of all team members - public bool IsActive { get; set; } - public GameEngineSectionSubmission[] Submissions { get; set; } - } - - public class ChallengeEventSummary - { - public string UserId { get; set; } - public string Text { get; set; } - public ChallengeEventType Type { get; set; } - public DateTimeOffset Timestamp { get; set; } - } - - public class ChallengeSearchFilter : SearchFilter - { - public string uid { get; set; } // Used to search for all challenges of a user - } +namespace Gameboard.Api; + +public class Challenge +{ + public string Id { get; set; } + public string SpecId { get; set; } + public string TeamId { get; set; } + public string Name { get; set; } + public string Tag { get; set; } + public DateTimeOffset StartTime { get; set; } + public DateTimeOffset EndTime { get; set; } + public DateTimeOffset LastScoreTime { get; set; } + public DateTimeOffset LastSyncTime { get; set; } + public bool HasDeployedGamespace { get; set; } + public int Points { get; set; } + public int Score { get; set; } + public long Duration { get; set; } + public ChallengeResult Result { get; set; } + public ChallengeEventSummary[] Events { get; set; } + public GameEngineGameState State { get; set; } +} + +public class ChallengeSummary +{ + public string Id { get; set; } + public string TeamId { get; set; } + public string Name { get; set; } + public string Tag { get; set; } + public string GameName { get; set; } + public IEnumerable Players { get; set; } + public DateTimeOffset StartTime { get; set; } + public DateTimeOffset EndTime { get; set; } + public DateTimeOffset LastScoreTime { get; set; } + public DateTimeOffset LastSyncTime { get; set; } + public bool HasDeployedGamespace { get; set; } + public int Points { get; set; } + public int Score { get; set; } + public long Duration { get; set; } + public ChallengeResult Result { get; set; } + public ChallengeEventSummary[] Events { get; set; } + public bool IsActive { get; set; } +} + +public class ActiveChallenge +{ + public required ActiveChallengeSpec Spec { get; set; } + public required SimpleEntity Game { get; set; } + public required SimpleEntity Player { get; set; } + public required SimpleEntity User { get; set; } + public required ActiveChallengeDeployment ChallengeDeployment { get; set; } + public required string TeamId { get; set; } + public required TimeWindow Session { get; set; } + public required PlayerMode PlayerMode { get; set; } + public required ActiveChallengeScoreAndAttemptsState ScoreAndAttemptsState { get; set; } +} + +public sealed class ActiveChallengeSpec +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string Tag { get; set; } + public required int AverageDeploySeconds { get; set; } +} + +public class ActiveChallengeDeployment +{ + public required string ChallengeId { get; set; } + public required bool IsDeployed { get; set; } + public required string Markdown { get; set; } + public required IEnumerable Vms { get; set; } +} + +public class ActiveChallengeScoreAndAttemptsState +{ + public required int Attempts { get; set; } + public required int MaxAttempts { get; set; } + public required decimal Score { get; set; } + public required decimal MaxPossibleScore { get; set; } +} + +public class ChallengePlayer +{ + public string Id { get; set; } + public string Name { get; set; } + public bool IsManager { get; set; } + public string UserId { get; set; } +} + +public class NewChallenge +{ + public string SpecId { get; set; } + public string PlayerId { get; set; } + public int Variant { get; set; } +} + +public class ChangedChallenge +{ + public string Id { get; set; } +} + +public class TeamChallenge +{ + public string Id { get; set; } + public string TeamId { get; set; } + public string Name { get; set; } + public string Tag { get; set; } + public int Points { get; set; } + public int Score { get; set; } + public long Duration { get; set; } + public ChallengeResult Result { get; set; } + public ChallengeEventSummary[] Events { get; set; } +} + +public class ChallengeOverview +{ + public string Id { get; set; } + public string TeamId { get; set; } + public string GameId { get; set; } + public string GameName { get; set; } + public string Name { get; set; } + public string Tag { get; set; } + public int Points { get; set; } + public int Score { get; set; } + public long Duration { get; set; } + public bool AllowTeam { get; set; } +} + +public class ObserveChallenge +{ + public string Id { get; set; } + public string TeamId { get; set; } + public string Name { get; set; } + public string Tag { get; set; } + public string PlayerId { get; set; } + public string PlayerName { get; set; } + public long Duration { get; set; } + public int ChallengeScore { get; set; } + public int GameScore { get; set; } + public int GameRank { get; set; } + public bool IsActive { get; set; } + public ObserveVM[] Consoles { get; set; } +} + +public class ObserveVM +{ + public string Id { get; set; } + public string Name { get; set; } + public string ChallengeId { get; set; } + public bool IsRunning { get; set; } + public bool IsVisible { get; set; } +} + +public class ConsoleRequest +{ + public string Name { get; set; } + public string SessionId { get; set; } + public ConsoleAction Action { get; set; } + public string Id => $"{Name}#{SessionId}"; +} + +public class ConsoleSummary +{ + public string Id { get; set; } + public string Name { get; set; } + public string SessionId { get; set; } + public string Url { get; set; } + public bool IsRunning { get; set; } + public bool IsObserver { get; set; } +} + +public enum ConsoleAction +{ + None, + Ticket, + Reset +} + +public class ConsoleActor +{ + public string UserId { get; set; } + public string UserName { get; set; } + public string PlayerName { get; set; } + public string ChallengeName { get; set; } + public string ChallengeId { get; set; } + public string GameId { get; set; } + public string TeamId { get; set; } + public string VmName { get; set; } + public DateTimeOffset Timestamp { get; set; } +} + +public class ArchivedChallenge +{ + public string Id { get; set; } + public string TeamId { get; set; } + public string Name { get; set; } + public string Tag { get; set; } + public string GameId { get; set; } + public string GameName { get; set; } + public string PlayerId { get; set; } + public string PlayerName { get; set; } + public string UserId { get; set; } + public DateTimeOffset StartTime { get; set; } + public DateTimeOffset EndTime { get; set; } + public DateTimeOffset LastScoreTime { get; set; } + public DateTimeOffset LastSyncTime { get; set; } + public bool HasGamespaceDeployed { get; set; } + public int Points { get; set; } + public int Score { get; set; } + public long Duration { get; set; } + public ChallengeResult Result { get; set; } + public ChallengeEventSummary[] Events { get; set; } + public string[] TeamMembers { get; set; } // User Ids of all team members + public bool IsActive { get; set; } + public GameEngineSectionSubmission[] Submissions { get; set; } +} + +public class ChallengeEventSummary +{ + public string UserId { get; set; } + public string Text { get; set; } + public ChallengeEventType Type { get; set; } + public DateTimeOffset Timestamp { get; set; } +} + +public class ChallengeSearchFilter : SearchFilter +{ + public string uid { get; set; } // Used to search for all challenges of a user } diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeController.cs b/src/Gameboard.Api/Features/Challenge/ChallengeController.cs index e3ceb7b8..082a852e 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeController.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeController.cs @@ -116,19 +116,6 @@ public async Task Preview([FromBody] NewChallenge model) return await ChallengeService.Preview(model); } - /// - /// Change challenge - /// - /// - /// - [HttpPut("api/challenge")] - [Authorize] - public Task Update([FromBody] ChangedChallenge model) - { - // await ChallengeService.Update(model); - return Task.CompletedTask; - } - /// /// Delete challenge /// @@ -197,7 +184,8 @@ public async Task StopGamespace([FromBody] ChangedChallenge model) var result = await ChallengeService.StopGamespace(model.Id, Actor.Id); - await Hub.Clients.Group(result.TeamId).ChallengeEvent( + await Hub.Clients.Group(result.TeamId).ChallengeEvent + ( new HubEvent { Model = result, @@ -219,8 +207,10 @@ await Hub.Clients.Group(result.TeamId).ChallengeEvent( public async Task Grade([FromBody] GameEngineSectionSubmission model) { AuthorizeAny( + // this is set by _Controller if the caller authenticated with a grader key + () => AuthenticatedGraderForChallengeId == model.Id, + // these are set if the caller authenticated with standard JWT () => Actor.IsDirector, - () => Actor.Id == model.Id, // auto-grader () => ChallengeService.UserIsTeamPlayer(model.Id, Actor.Id).Result ); diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeExtensions.cs b/src/Gameboard.Api/Features/Challenge/ChallengeExtensions.cs new file mode 100644 index 00000000..c51b0582 --- /dev/null +++ b/src/Gameboard.Api/Features/Challenge/ChallengeExtensions.cs @@ -0,0 +1,2 @@ +namespace Gameboard.Api.Features.Challenges; + diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeMapper.cs b/src/Gameboard.Api/Features/Challenge/ChallengeMapper.cs index 9c7bbee6..328ef8d2 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeMapper.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeMapper.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using AutoMapper; +using Gameboard.Api.Common; using Gameboard.Api.Features.GameEngine; namespace Gameboard.Api.Services @@ -27,6 +28,7 @@ public ChallengeMapper() CreateMap().ConvertUsing(str => str == null ? null : str.Trim()); + CreateMap(); CreateMap(); CreateMap() .ForMember(d => d.Score, opt => opt.MapFrom(s => (int)Math.Floor(s.Score))) @@ -121,7 +123,7 @@ public ChallengeMapper() .ForMember(d => d.Consoles, opt => opt.MapFrom(s => JsonSerializer.Deserialize(s.State, JsonOptions).Vms) ) - .ForMember(d => d.isActive, opt => opt.MapFrom(s => + .ForMember(d => d.IsActive, opt => opt.MapFrom(s => JsonSerializer.Deserialize(s.State, JsonOptions).IsActive) ) ; diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeService.cs b/src/Gameboard.Api/Features/Challenge/ChallengeService.cs index 5a02188e..11becc96 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeService.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeService.cs @@ -5,652 +5,666 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.ExceptionServices; +using System.Threading; using System.Threading.Tasks; using AutoMapper; +using Gameboard.Api.Data; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.GameEngine; +using Gameboard.Api.Features.Practice; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -namespace Gameboard.Api.Services +namespace Gameboard.Api.Services; + +public class ChallengeService : _Service { - public class ChallengeService : _Service + IChallengeStore Store { get; } + IGameEngineService GameEngine { get; } + + private readonly IMemoryCache _localcache; + private readonly ConsoleActorMap _actorMap; + private readonly IGameStore _gameStore; + private readonly IGuidService _guids; + private readonly IJsonService _jsonService; + private readonly IMapper _mapper; + private readonly INowService _now; + private readonly IPlayerStore _playerStore; + private readonly IPracticeChallengeScoringListener _practiceChallengeScoringListener; + private readonly IStore _specStore; + + public ChallengeService( + ILogger logger, + IMapper mapper, + CoreOptions options, + IChallengeStore store, + IStore specStore, + IGameEngineService gameEngine, + IGameStore gameStore, + IGuidService guids, + IJsonService jsonService, + IMemoryCache localcache, + INowService now, + IPlayerStore playerStore, + IPracticeChallengeScoringListener practiceChallengeScoringListener, + ConsoleActorMap actorMap + ) : base(logger, mapper, options) { - IChallengeStore Store { get; } - IGameEngineService GameEngine { get; } - - private IMemoryCache _localcache; - private ConsoleActorMap _actorMap; - private readonly IGameStore _gameStore; - private readonly IGuidService _guids; - private readonly IJsonService _jsonService; - private readonly IMapper _mapper; - private readonly INowService _now; - private readonly IPlayerStore _playerStore; - private readonly IStore _specStore; - - public ChallengeService( - ILogger logger, - IMapper mapper, - CoreOptions options, - IChallengeStore store, - IStore specStore, - IGameEngineService gameEngine, - IGameStore gameStore, - IGuidService guids, - IJsonService jsonService, - IMemoryCache localcache, - INowService now, - IPlayerStore playerStore, - ConsoleActorMap actorMap - ) : base(logger, mapper, options) - { - Store = store; - GameEngine = gameEngine; - _localcache = localcache; - _actorMap = actorMap; - _gameStore = gameStore; - _guids = guids; - _mapper = mapper; - _jsonService = jsonService; - _now = now; - _playerStore = playerStore; - _specStore = specStore; - } - - public async Task GetOrCreate(NewChallenge model, string actorId, string graderUrl) - { - var entity = await Store.Load(model); - - if (entity is not null) - return Mapper.Map(entity); - - return await Create(model, actorId, graderUrl); - } + Store = store; + GameEngine = gameEngine; + _localcache = localcache; + _actorMap = actorMap; + _gameStore = gameStore; + _guids = guids; + _mapper = mapper; + _jsonService = jsonService; + _now = now; + _playerStore = playerStore; + _practiceChallengeScoringListener = practiceChallengeScoringListener; + _specStore = specStore; + } - public async Task Create(NewChallenge model, string actorId, string graderUrl) - { - var player = await _playerStore.Retrieve(model.PlayerId); + public async Task GetOrCreate(NewChallenge model, string actorId, string graderUrl) + { + var entity = await Store.Load(model); - var game = await _gameStore - .List() - .Include(g => g.Prerequisites) - .Where(g => g.Id == player.GameId) - .FirstOrDefaultAsync(); + if (entity is not null) + return Mapper.Map(entity); - if (await AtGamespaceLimit(game, player.TeamId)) - throw new GamespaceLimitReached(); + return await Create(model, actorId, graderUrl); + } - if ((await IsUnlocked(player, game, model.SpecId)).Equals(false)) - throw new ChallengeLocked(); + public async Task Create(NewChallenge model, string actorId, string graderUrl) + { + var player = await _playerStore.Retrieve(model.PlayerId); - var lockkey = $"{player.TeamId}{model.SpecId}"; - var lockval = _guids.GetGuid(); - var locked = _localcache.GetOrCreate(lockkey, entry => - { - entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60); - return lockval; - }); + var game = await _gameStore + .List() + .Include(g => g.Prerequisites) + .Where(g => g.Id == player.GameId) + .FirstOrDefaultAsync(); - if (locked != lockval) - throw new ChallengeStartPending(); + if (await AtGamespaceLimit(game, player.TeamId)) + throw new GamespaceLimitReached(); - var spec = await _specStore.Retrieve(model.SpecId); + if ((await IsUnlocked(player, game, model.SpecId)).Equals(false)) + throw new ChallengeLocked(); - int playerCount = 1; - if (game.AllowTeam) - { - playerCount = await _playerStore.CountAsync(q => q.Where(p => p.TeamId == player.TeamId)); - } + var lockkey = $"{player.TeamId}{model.SpecId}"; + var lockval = _guids.GetGuid(); + var locked = _localcache.GetOrCreate(lockkey, entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60); + return lockval; + }); - try - { - // build and register - var challenge = await BuildAndRegisterChallenge(model, spec, game, player, actorId, graderUrl, playerCount, model.Variant); + if (locked != lockval) + throw new ChallengeStartPending(); - await Store.Create(challenge); - await Store.UpdateEtd(challenge.SpecId); + var spec = await _specStore.Retrieve(model.SpecId); - return Mapper.Map(challenge); - } - // we need to catch here to allow cleanup in `finally`, but we want a complete rethrow - // (and the compiler doesn't know we're doing this, so it needs to relax a little) -#pragma warning disable CA2200 - catch (Exception ex) - { - Logger.LogWarning($"Challenge registration failure: {ex.GetType().Name} -- {ex.Message}"); - ExceptionDispatchInfo.Capture(ex.InnerException == null ? ex : ex.InnerException).Throw(); - throw; - } - finally - { - _localcache.Remove(lockkey); - } + int playerCount = 1; + if (game.AllowTeam) + { + playerCount = await _playerStore.CountAsync(q => q.Where(p => p.TeamId == player.TeamId)); } - private async Task IsUnlocked(Data.Player player, Data.Game game, string specId) + try { - bool result = true; - - foreach (var prereq in game.Prerequisites.Where(p => p.TargetId == specId)) - { - var condition = await Store.DbSet.AnyAsync - ( - c => - c.TeamId == player.TeamId && - c.SpecId == prereq.RequiredId && - c.Score >= prereq.RequiredScore - ); - - result &= condition; - } + var challenge = await BuildAndRegisterChallenge(model, spec, game, player, actorId, graderUrl, playerCount, model.Variant); + await Store.Create(challenge); + await Store.UpdateEtd(challenge.SpecId); - return result; + return Mapper.Map(challenge); } - - public async Task Retrieve(string id) + catch (Exception ex) { - var result = Mapper.Map( - await Store.Load(id) - ); - - return result; + Logger.LogWarning(message: $"Challenge registration failure: {ex.GetType().Name} -- {ex.Message}"); + ExceptionDispatchInfo.Capture(ex.InnerException ?? ex).Throw(); + throw; } - - public async Task Delete(string id) + finally { - await Store.Delete(id); - var entity = await Store.Load(id); - await GameEngine.DeleteGamespace(entity); + _localcache.Remove(lockkey); } + } - public async Task UserIsTeamPlayer(string id, string subjectId) - { - var entity = await Store.Retrieve(id); + private async Task IsUnlocked(Data.Player player, Data.Game game, string specId) + { + bool result = true; - return await Store.DbContext.Users.AnyAsync(u => - u.Id == subjectId && - u.Enrollments.Any(e => e.TeamId == entity.TeamId) + foreach (var prereq in game.Prerequisites.Where(p => p.TargetId == specId)) + { + var condition = await Store.DbSet.AnyAsync + ( + c => + c.TeamId == player.TeamId && + c.SpecId == prereq.RequiredId && + c.Score >= prereq.RequiredScore ); + + result &= condition; } - public async Task List(SearchFilter model) - { - var q = Store.List(model.Term); + return result; + } - // filter out challenge records with no state used to give starting score to player - q = q.Where(p => p.Name != "_initialscore_" && p.State != null); - q = q.OrderByDescending(p => p.LastSyncTime); - q = q.Skip(model.Skip); + public async Task Retrieve(string id) + { + var result = Mapper.Map( + await Store.Load(id) + ); - if (model.Take > 0) - q = q.Take(model.Take); + return result; + } - // we have to resolve the query here, because we need to include player data as well - // (and there's no direct model relation between challenge and the players in a team) - var summaries = await Mapper.ProjectTo(q).ToArrayAsync(); + public async Task Delete(string id) + { + await Store.Delete(id); + var entity = await Store.Load(id); + await GameEngine.DeleteGamespace(entity); + } - // resolve the players of the challenges that are coming back - var teamIds = summaries.Select(s => s.TeamId); - var teamPlayerMap = await _playerStore - .List() - .AsNoTracking() - .Where(p => teamIds.Contains(p.TeamId)) - .GroupBy(p => p.TeamId) - .ToDictionaryAsync(g => g.Key, g => g); + public async Task UserIsTeamPlayer(string id, string subjectId) + { + var entity = await Store.Retrieve(id); - foreach (var summary in summaries) - { - var teamPlayers = teamPlayerMap[summary.TeamId]; - summary.Players = teamPlayers.Select(p => _mapper.Map(p)); - } + return await Store.DbContext.Users.AnyAsync(u => + u.Id == subjectId && + u.Enrollments.Any(e => e.TeamId == entity.TeamId) + ); + } - return summaries; - } + public async Task List(SearchFilter model = null) + { + var q = Store.List(model?.Term ?? null); - public async Task ListByUser(string uid) - { - var q = Store.List(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); + q = q.OrderByDescending(p => p.LastSyncTime); + q = q.Skip(model?.Skip ?? 0); - var userTeams = await Store.DbContext.Players - .Where(p => p.UserId == uid && p.TeamId != null && p.TeamId != "") - .Select(p => p.TeamId) - .ToListAsync(); + if (model?.Take > 0) + { + q = q.Take(model.Take); + } - q = q.Where(t => userTeams.Any(i => i == t.TeamId)); + // we have to resolve the query here, because we need to include player data as well + // (and there's no direct model relation between challenge and the players in a team) + var summaries = await Mapper.ProjectTo(q).ToArrayAsync(); - DateTimeOffset recent = DateTimeOffset.UtcNow.AddDays(-1); - q = q.Include(c => c.Player).Include(c => c.Game); - q = q.Where(c => c.Game.GameEnd > recent); - q = q.OrderByDescending(p => p.StartTime); + // resolve the players of the challenges that are coming back + var teamIds = summaries.Select(s => s.TeamId); + var teamPlayerMap = await _playerStore + .List() + .AsNoTracking() + .Where(p => teamIds.Contains(p.TeamId)) + .GroupBy(p => p.TeamId) + .ToDictionaryAsync(g => g.Key, g => g); - return await Mapper.ProjectTo(q).ToArrayAsync(); + foreach (var summary in summaries) + { + var teamPlayers = teamPlayerMap[summary.TeamId]; + summary.Players = teamPlayers.Select(p => _mapper.Map(p)); } - public async Task ListArchived(SearchFilter model) - { - var q = Store.DbContext.ArchivedChallenges.AsQueryable(); + return summaries; + } - if (model.Term.NotEmpty()) - { - var term = model.Term.ToLower(); - q = q.Where(c => - c.Id.StartsWith(term) || // Challenge Id - c.Tag.ToLower().StartsWith(term) || // Challenge Tag - c.UserId.StartsWith(term) || // User Id - c.Name.ToLower().Contains(term) || // Challenge Title - c.PlayerName.ToLower().Contains(term) // Team Name (or indiv. Player Name) - ); - } + public async Task ListByUser(string uid) + { + var q = Store.List(null); - q = q.OrderByDescending(p => p.LastSyncTime); - q = q.Skip(model.Skip); + var userTeams = await Store.DbContext.Players + .Where(p => p.UserId == uid && p.TeamId != null && p.TeamId != "") + .Select(p => p.TeamId) + .ToListAsync(); - if (model.Take > 0) - q = q.Take(model.Take); + q = q.Where(t => userTeams.Any(i => i == t.TeamId)); - return await Mapper.ProjectTo(q).ToArrayAsync(); - } + DateTimeOffset recent = DateTimeOffset.UtcNow.AddDays(-1); + q = q.Include(c => c.Player).Include(c => c.Game); + q = q.Where(c => c.Game.GameEnd > recent); + q = q.OrderByDescending(p => p.StartTime); + + return await Mapper.ProjectTo(q).ToArrayAsync(); + } + + public async Task ListArchived(SearchFilter model) + { + var q = Store.DbContext.ArchivedChallenges.AsQueryable(); - public async Task Preview(NewChallenge model) + if (model.Term.NotEmpty()) { - var entity = await Store.Load(model); + var term = model.Term.ToLower(); + q = q.Where(c => + c.Id.StartsWith(term) || // Challenge Id + c.Tag.ToLower().StartsWith(term) || // Challenge Tag + c.UserId.StartsWith(term) || // User Id + c.Name.ToLower().Contains(term) || // Challenge Title + c.PlayerName.ToLower().Contains(term) // Team Name (or indiv. Player Name) + ); + } - if (entity is Data.Challenge) - return Mapper.Map(entity); + q = q.OrderByDescending(p => p.LastSyncTime); + q = q.Skip(model.Skip); - var spec = await Store.DbContext.ChallengeSpecs.FindAsync(model.SpecId); - var challenge = Mapper.Map(spec); + if (model.Take > 0) + q = q.Take(model.Take); - var result = Mapper.Map(challenge); - GameEngineGameState state = new() - { - Markdown = spec.Text - }; - Transform(state); - result.State = state; - return result; - } + return await Mapper.ProjectTo(q).ToArrayAsync(); + } - public async Task SyncExpired() + public async Task Preview(NewChallenge model) + { + var entity = await Store.Load(model); + + if (entity is not null) + return Mapper.Map(entity); + + var spec = await Store.DbContext.ChallengeSpecs.FindAsync(model.SpecId); + var challenge = Mapper.Map(spec); + + var result = Mapper.Map(challenge); + GameEngineGameState state = new() { - var ts = DateTimeOffset.UtcNow; + Markdown = spec.Text + }; + Transform(state); + result.State = state; + return result; + } - var challenges = await Store.DbSet - .Where(c => - c.LastSyncTime < c.Player.SessionEnd && - c.Player.SessionEnd < ts - ) - .ToArrayAsync() - ; + public async Task SyncExpired() + { + var ts = DateTimeOffset.UtcNow; - foreach (var challenge in challenges) - _actorMap.RemoveTeam(challenge.TeamId); + var challenges = await Store.DbSet + .Where(c => + c.LastSyncTime < c.Player.SessionEnd && + c.Player.SessionEnd < ts + ) + .ToArrayAsync() + ; - var tasks = challenges.Select( - c => Sync(c) - ); + foreach (var challenge in challenges) + _actorMap.RemoveTeam(challenge.TeamId); - await Task.WhenAll(tasks); - } + var tasks = challenges.Select( + c => Sync(c) + ); - private async Task Sync(Data.Challenge entity, Task task = null) - { - if (task is null) - task = GameEngine.LoadGamespace(entity); + await Task.WhenAll(tasks); + } - try - { - var state = await task; - - // TODO - // this is currently awkward because the game state that comes back here has the team ID as the subjectId (because that's what we're passing to Topo - see - // GameEngine.RegisterGamespace). it's unclear whether topo cares what we pass as the players argument there, but since we're passing team ID - // there we need to NOT overwrite the playerId on the entity during the call to Map. Obviously, we could fix this by setting a rule on the map, - // but I'm leaving it here because this is the anomalous case. - var playerId = entity.PlayerId; - Mapper.Map(state, entity); - entity.PlayerId = playerId; - } - catch (Exception ex) - { - entity.LastSyncTime = DateTimeOffset.UtcNow; - Logger.LogError(ex, "Sync error on {0} {1}", entity.Id, entity.Name); - } + private async Task Sync(Data.Challenge entity, Task task = null) + { + task ??= GameEngine.LoadGamespace(entity); - await Store.Update(entity); - return entity; + try + { + var state = await task; + + // TODO + // this is currently awkward because the game state that comes back here has the team ID as the subjectId (because that's what we're passing to Topo - see + // GameEngine.RegisterGamespace). it's unclear whether topo cares what we pass as the players argument there, but since we're passing team ID + // there we need to NOT overwrite the playerId on the entity during the call to Map. Obviously, we could fix this by setting a rule on the map, + // but I'm leaving it here because this is the anomalous case. + var playerId = entity.PlayerId; + Mapper.Map(state, entity); + entity.PlayerId = playerId; } - - private async Task Sync(string id, Task task = null) + catch (Exception ex) { - var entity = await Store.Retrieve(id); - - return await Sync(entity, task); + entity.LastSyncTime = DateTimeOffset.UtcNow; + Logger.LogError(message: $"Sync error on {entity.Id} {entity.Name}: {ex.Message}", ex); } - public async Task StartGamespace(string id, string actorId) - { - var entity = await Store.Retrieve(id); - var game = await Store.DbContext.Games.FindAsync(entity.GameId); + await Store.Update(entity); + return entity; + } - if (await AtGamespaceLimit(game, entity.TeamId)) - throw new GamespaceLimitReached(); + public async Task StartGamespace(string id, string actorId) + { + var entity = await Store.Retrieve(id); + var game = await Store.DbContext.Games.FindAsync(entity.GameId); - entity.Events.Add(new Data.ChallengeEvent - { - Id = _guids.GetGuid(), - UserId = actorId, - TeamId = entity.TeamId, - Timestamp = DateTimeOffset.UtcNow, - Type = ChallengeEventType.GamespaceOn - }); - - await Sync( - entity, - GameEngine.StartGamespace(entity) - ); + if (await AtGamespaceLimit(game, entity.TeamId)) + throw new GamespaceLimitReached(); - return Mapper.Map(entity); - } + entity.Events.Add(new Data.ChallengeEvent + { + Id = _guids.GetGuid(), + UserId = actorId, + TeamId = entity.TeamId, + Timestamp = DateTimeOffset.UtcNow, + Type = ChallengeEventType.GamespaceOn + }); + + await Sync( + entity, + GameEngine.StartGamespace(entity) + ); + + return Mapper.Map(entity); + } + + public async Task StopGamespace(string id, string actorId) + { + var entity = await Store.Retrieve(id); - public async Task StopGamespace(string id, string actorId) + await Sync + ( + entity, + GameEngine.StopGamespace(entity) + ); + + entity.Events.Add(new Data.ChallengeEvent { - var entity = await Store.Retrieve(id); + Id = _guids.GetGuid(), + UserId = actorId, + TeamId = entity.TeamId, + Timestamp = DateTimeOffset.UtcNow, + Type = ChallengeEventType.GamespaceOff + }); + + return Mapper.Map(entity); + } - entity.Events.Add(new Data.ChallengeEvent - { - Id = _guids.GetGuid(), - UserId = actorId, - TeamId = entity.TeamId, - Timestamp = DateTimeOffset.UtcNow, - Type = ChallengeEventType.GamespaceOff - }); - - await Sync - ( - entity, - GameEngine.StopGamespace(entity) - ); + public async Task Grade(GameEngineSectionSubmission model, string actorId) + { + var entity = await Store.Retrieve(model.Id); - return Mapper.Map(entity); + entity.Events.Add(new Data.ChallengeEvent + { + Id = _guids.GetGuid(), + UserId = actorId, + TeamId = entity.TeamId, + Timestamp = _now.Get(), + Type = ChallengeEventType.Submission + }); + + double currentScore = entity.Score; + + var gradingTask = GameEngine.GradeChallenge(entity, model); + var result = await Sync( + entity, + gradingTask + ); + + if (result.Score > currentScore) + { + await Store.UpdateTeam(entity.TeamId); } - public async Task Grade(GameEngineSectionSubmission model, string actorId) + if (entity.PlayerMode == PlayerMode.Practice) { - var entity = await Store.Retrieve(model.Id); + if (result.Score >= entity.Points) + { + // in the practice area, we proactively end their session if they complete the challenge + await _practiceChallengeScoringListener.NotifyChallengeScored(entity, CancellationToken.None); + } - entity.Events.Add(new Data.ChallengeEvent + // also for the practice area: + // if they've consumed all of their attempts for a challenge, we proactively end their session as well + var typedState = await GameEngine.GetChallengeState(entity.GameEngineType, entity.State); + if (typedState.Challenge.Attempts >= typedState.Challenge.MaxAttempts) { - Id = _guids.GetGuid(), - UserId = actorId, - TeamId = entity.TeamId, - Timestamp = DateTimeOffset.UtcNow, - Type = ChallengeEventType.Submission - }); + await _practiceChallengeScoringListener.NotifyAttemptsExhausted(entity, CancellationToken.None); + } + } - double currentScore = entity.Score; + return Mapper.Map(entity); + } - var gradingTask = GameEngine.GradeChallenge(entity, model); + public async Task Regrade(string id) + { + var entity = await Store.Retrieve(id); - var result = await Sync( - entity, - gradingTask - ); + double currentScore = entity.Score; - if (result.Score > currentScore) - await Store.UpdateTeam(entity.TeamId); + var result = await Sync( + entity, + GameEngine.RegradeChallenge(entity) + ); - return Mapper.Map(entity); - } - - public async Task Regrade(string id) - { - var entity = await Store.Retrieve(id); + if (result.Score > currentScore) + await Store.UpdateTeam(entity.TeamId); - double currentScore = entity.Score; + await _practiceChallengeScoringListener.NotifyChallengeScored(entity, CancellationToken.None); + return Mapper.Map(entity); + } - var result = await Sync( - entity, - GameEngine.RegradeChallenge(entity) - ); + public async Task ArchivePlayerChallenges(Data.Player player) + { + // for this, we need to make sure that we're not cleaning up any challenges + // that still belong to other members of the player's team (if they) + // have any + var candidateChallenges = await Store + .List() + .AsNoTracking() + .Where(c => c.PlayerId == player.Id) + .ToArrayAsync(); + + var teamChallenges = await Store + .List() + .AsNoTracking() + .Where(c => c.TeamId == player.TeamId && c.PlayerId != player.Id) + .ToArrayAsync(); + + var playerOnlyChallenges = candidateChallenges.Where(c => !teamChallenges.Any(tc => tc.Id == c.Id)); + + await ArchiveChallenges(playerOnlyChallenges); + } - if (result.Score > currentScore) - await Store.UpdateTeam(entity.TeamId); + public async Task ArchiveTeamChallenges(string teamId) + { + var challenges = await Store + .List() + .AsNoTracking() + .Where(c => c.TeamId == teamId) + .ToArrayAsync(); - return Mapper.Map(entity); - } + await ArchiveChallenges(challenges); + } - public async Task ArchivePlayerChallenges(Data.Player player) + private async Task ArchiveChallenges(IEnumerable challenges) + { + if (challenges != null && challenges.Any()) { - // for this, we need to make sure that we're not cleaning up any challenges - // that still belong to other members of the player's team (if they) - // have any - var candidateChallenges = await Store - .List() - .AsNoTracking() - .Where(c => c.PlayerId == player.Id) - .ToArrayAsync(); + var toArchiveIds = challenges.Select(c => c.Id).ToArray(); - var teamChallenges = await Store - .List() + var teamMemberMap = await Store + .DbSet .AsNoTracking() - .Where(c => c.TeamId == player.TeamId && c.PlayerId != player.Id) - .ToArrayAsync(); + .Include(c => c.Player) + .Where(c => toArchiveIds.Contains(c.Id)) + .GroupBy(c => c.Player.TeamId) + .ToDictionaryAsync(g => g.Key, g => g.Select(c => c.Player.Id).AsEnumerable()); - var playerOnlyChallenges = candidateChallenges.Where(c => !teamChallenges.Any(tc => tc.Id == c.Id)); + var toArchiveTasks = challenges.Select(async challenge => + { + var submissions = Array.Empty(); - await ArchiveChallenges(playerOnlyChallenges); - } + // gamespace may be deleted in TopoMojo which would cause error and prevent reset + try + { + submissions = Mapper.Map(await GameEngine.AuditChallenge(challenge)); + if (challenge.HasDeployedGamespace) + await GameEngine.CompleteGamespace(challenge); + } + catch + { + // no-op - leave as empty array + } - public async Task ArchiveTeamChallenges(string teamId) - { - var challenges = await Store - .List() - .AsNoTracking() - .Where(c => c.TeamId == teamId) - .ToArrayAsync(); + var mappedChallenge = _mapper.Map(challenge); + mappedChallenge.Submissions = submissions; + mappedChallenge.TeamMembers = teamMemberMap[challenge.TeamId].ToArray(); - await ArchiveChallenges(challenges); - } + return mappedChallenge; + }).ToArray(); - private async Task ArchiveChallenges(IEnumerable challenges) - { - if (challenges != null && challenges.Count() > 0) - { - var toArchiveIds = challenges.Select(c => c.Id).ToArray(); + var toArchive = await Task.WhenAll(toArchiveTasks); - var teamMemberMap = await Store - .DbSet - .AsNoTracking() - .Include(c => c.Player) - .Where(c => toArchiveIds.Contains(c.Id)) - .GroupBy(c => c.Player.TeamId) - .ToDictionaryAsync(g => g.Key, g => g.Select(c => c.Player.Id).AsEnumerable()); + // this is a backstoppy kind of thing - we aren't quite sure about the conditions under which this happens, but we've had + // some stale challenges appear in the archive table and the real challenges table. if for whatever reason we're trying to + // archive something that's already in the archive table, instead, delete it, replace it with the updated object - var toArchiveTasks = challenges.Select(async challenge => - { - var submissions = new GameEngineSectionSubmission[] { }; - - // gamespace may be deleted in TopoMojo which would cause error and prevent reset - try - { - submissions = Mapper.Map(await GameEngine.AuditChallenge(challenge)); - if (challenge.HasDeployedGamespace) - await GameEngine.CompleteGamespace(challenge); - } - catch - { - // no-op - leave as empty array - } - - var mappedChallenge = _mapper.Map(challenge); - mappedChallenge.Submissions = submissions; - mappedChallenge.TeamMembers = teamMemberMap[challenge.TeamId].ToArray(); - - return mappedChallenge; - }).ToArray(); - - var toArchive = await Task.WhenAll(toArchiveTasks); - - // this is a backstoppy kind of thing - we aren't quite sure about the conditions under which this happens, but we've had - // some stale challenges appear in the archive table and the real challenges table. if for whatever reason we're trying to - // archive something that's already in the archive table, instead, delete it, replace it with the updated object - - var recordsAffected = await Store - .DbContext - .ArchivedChallenges - .Where(c => toArchiveIds.Contains(c.Id)) - .ExecuteDeleteAsync(); - - if (recordsAffected > 0) - Logger.LogWarning($"While attempting to archive challenges (Ids: {string.Join(",", toArchiveIds)}) resulted in the deletion of ${recordsAffected} stale archive records."); - - Store.DbContext.ArchivedChallenges.AddRange(_mapper.Map(toArchive)); - await Store.DbContext.SaveChangesAsync(); - } + var recordsAffected = await Store + .DbContext + .ArchivedChallenges + .Where(c => toArchiveIds.Contains(c.Id)) + .ExecuteDeleteAsync(); + + if (recordsAffected > 0) + Logger.LogWarning($"While attempting to archive challenges (Ids: {string.Join(",", toArchiveIds)}) resulted in the deletion of ${recordsAffected} stale archive records."); + + Store.DbContext.ArchivedChallenges.AddRange(_mapper.Map(toArchive)); + await Store.DbContext.SaveChangesAsync(); } + } - public async Task GetConsole(ConsoleRequest model, bool observer) - { - var entity = await Store.Retrieve(model.SessionId); - var challenge = Mapper.Map(entity); + public async Task GetConsole(ConsoleRequest model, bool observer) + { + var entity = await Store.Retrieve(model.SessionId); + var challenge = Mapper.Map(entity); - var thing = challenge.State.Vms.First(); - if (!challenge.State.Vms.Any(v => v.Name == model.Name)) - throw new ResourceNotFound("n/a", $"VMS for challenge {model.Name}"); + if (!challenge.State.Vms.Any(v => v.Name == model.Name)) + throw new ResourceNotFound("n/a", $"VMS for challenge {model.Name}"); - var console = await GameEngine.GetConsole(entity, model, observer); - return console ?? throw new InvalidConsoleAction(); - } + var console = await GameEngine.GetConsole(entity, model, observer); + return console ?? throw new InvalidConsoleAction(); + } - public async Task> GetChallengeConsoles(string gameId) + public async Task> GetChallengeConsoles(string gameId) + { + var q = Store.DbContext.Challenges + .Where(c => c.GameId == gameId && + c.HasDeployedGamespace) + .Include(c => c.Player) + .OrderBy(c => c.Player.Name) + .ThenBy(c => c.Name); + var challenges = Mapper.Map(await q.ToArrayAsync()); + var result = new List(); + foreach (var challenge in challenges.Where(c => c.IsActive)) { - var q = Store.DbContext.Challenges - .Where(c => c.GameId == gameId && - c.HasDeployedGamespace) - .Include(c => c.Player) - .OrderBy(c => c.Player.Name) - .ThenBy(c => c.Name); - var challenges = Mapper.Map(await q.ToArrayAsync()); - var result = new List(); - foreach (var challenge in challenges.Where(c => c.isActive)) - { - challenge.Consoles = challenge.Consoles - .Where(v => v.IsVisible) - .ToArray(); - result.Add(challenge); - } - return result; + challenge.Consoles = challenge.Consoles + .Where(v => v.IsVisible) + .ToArray(); + result.Add(challenge); } + return result; + } - public ConsoleActor[] GetConsoleActors(string gameId) - { - return _actorMap.Find(gameId); - } + public ConsoleActor[] GetConsoleActors(string gameId) + { + return _actorMap.Find(gameId); + } - public ConsoleActor GetConsoleActor(string userId) - { - return _actorMap.FindActor(userId); - } + public ConsoleActor GetConsoleActor(string userId) + { + return _actorMap.FindActor(userId); + } + + internal async Task SetConsoleActor(ConsoleRequest model, string id, string name) + { + var entity = await Store.DbSet + .Include(c => c.Player) + .FirstOrDefaultAsync(c => c.Id == model.SessionId); - internal async Task SetConsoleActor(ConsoleRequest model, string id, string name) + return new ConsoleActor { - var entity = await Store.DbSet - .Include(c => c.Player) - .FirstOrDefaultAsync(c => c.Id == model.SessionId); + UserId = id, + UserName = name, + PlayerName = entity.Player.Name, + ChallengeName = entity.Name, + ChallengeId = model.SessionId, + GameId = entity.GameId, + TeamId = entity.TeamId, + VmName = model.Name, + Timestamp = DateTimeOffset.UtcNow + }; + } - return new ConsoleActor - { - UserId = id, - UserName = name, - PlayerName = entity.Player.Name, - ChallengeName = entity.Name, - ChallengeId = model.SessionId, - GameId = entity.GameId, - TeamId = entity.TeamId, - VmName = model.Name, - Timestamp = DateTimeOffset.UtcNow - }; - } + internal async Task> Audit(string id) + { + var entity = await Store.Load(id); + return await GameEngine.AuditChallenge(entity); + } - internal async Task> Audit(string id) - { - var entity = await Store.Load(id); - return await GameEngine.AuditChallenge(entity); - } + internal async Task BuildAndRegisterChallenge + ( + NewChallenge newChallenge, + Data.ChallengeSpec spec, + Data.Game game, + Data.Player player, + string actorUserId, + string graderUrl, + int playerCount, + int variant + ) + { + var graderKey = _guids.GetGuid(); + var challenge = Mapper.Map(newChallenge); - internal async Task BuildAndRegisterChallenge - ( - NewChallenge newChallenge, - Api.Data.ChallengeSpec spec, - Api.Data.Game game, - Api.Data.Player player, - string actorUserId, - string graderUrl, - int playerCount, - int variant - ) - { - var graderKey = _guids.GetGuid(); - var challenge = Mapper.Map(newChallenge); - Mapper.Map(spec, challenge); - challenge.PlayerId = player.Id; - challenge.TeamId = player.TeamId; - challenge.GraderKey = graderKey.ToSha256(); - challenge.WhenCreated = _now.Get(); - - var state = await GameEngine.RegisterGamespace(new GameEngineChallengeRegistration - { - Challenge = challenge, - ChallengeSpec = spec, - Game = game, - GraderKey = graderKey, - GraderUrl = graderUrl, - Player = player, - PlayerCount = playerCount, - Variant = variant - }); - - Transform(state); - - // manually map here - we need the player object and other references to stay the same for - // db add - challenge.Id = state.Id; - challenge.ExternalId = spec.ExternalId; - challenge.HasDeployedGamespace = state.IsActive; - challenge.State = _jsonService.Serialize(state); - challenge.StartTime = state.StartTime; - challenge.EndTime = state.EndTime; - - challenge.Events.Add(new Data.ChallengeEvent - { - Id = _guids.GetGuid(), - UserId = actorUserId, - TeamId = challenge.TeamId, - Timestamp = DateTimeOffset.UtcNow, - Type = ChallengeEventType.Started - }); - - return challenge; - } + Mapper.Map(spec, challenge); + challenge.PlayerId = player.Id; + challenge.TeamId = player.TeamId; + challenge.GraderKey = graderKey.ToSha256(); + challenge.PlayerMode = game.PlayerMode; + challenge.WhenCreated = _now.Get(); - private void Transform(GameEngineGameState state) + var state = await GameEngine.RegisterGamespace(new GameEngineChallengeRegistration + { + Challenge = challenge, + ChallengeSpec = spec, + Game = game, + GraderKey = graderKey, + GraderUrl = graderUrl, + Player = player, + PlayerCount = playerCount, + Variant = variant + }); + + Transform(state); + + // manually map here - we need the player object and other references to stay the same for + // db add + challenge.Id = state.Id; + challenge.ExternalId = spec.ExternalId; + challenge.HasDeployedGamespace = state.IsActive; + challenge.State = _jsonService.Serialize(state); + challenge.StartTime = state.StartTime; + challenge.EndTime = state.EndTime; + + challenge.Events.Add(new Data.ChallengeEvent { - if (!string.IsNullOrWhiteSpace(state.Markdown)) - state.Markdown = state.Markdown.Replace("](/docs", $"]({Options.ChallengeDocUrl}docs"); + Id = _guids.GetGuid(), + UserId = actorUserId, + TeamId = challenge.TeamId, + Timestamp = DateTimeOffset.UtcNow, + Type = ChallengeEventType.Started + }); + + return challenge; + } - if (state.Challenge is not null && !string.IsNullOrWhiteSpace(state.Challenge.Text)) - state.Challenge.Text = state.Challenge.Text.Replace("](/docs", $"]({Options.ChallengeDocUrl}docs"); - } + private void Transform(GameEngineGameState state) + { + if (!string.IsNullOrWhiteSpace(state.Markdown)) + state.Markdown = state.Markdown.Replace("](/docs", $"]({Options.ChallengeDocUrl}docs"); - private async Task AtGamespaceLimit(Data.Game game, string teamId) - { - int gamespaceCount = await Store.ChallengeGamespaceCount(teamId); - int gamespaceLimit = game.IsCompetitionMode ? game.GamespaceLimitPerSession : 1; + if (state.Challenge is not null && !string.IsNullOrWhiteSpace(state.Challenge.Text)) + state.Challenge.Text = state.Challenge.Text.Replace("](/docs", $"]({Options.ChallengeDocUrl}docs"); + } - return gamespaceCount >= gamespaceLimit; - } + private async Task AtGamespaceLimit(Data.Game game, string teamId) + { + int gamespaceCount = await Store.ChallengeGamespaceCount(teamId); + int gamespaceLimit = game.IsCompetitionMode ? game.GamespaceLimitPerSession : 1; + + return gamespaceCount >= gamespaceLimit; } } diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeStore.cs b/src/Gameboard.Api/Features/Challenge/ChallengeStore.cs index eebd2d32..a09ceb03 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeStore.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeStore.cs @@ -4,28 +4,27 @@ using System.Linq; using System.Threading.Tasks; using Gameboard.Api.Data.Abstractions; -using AutoMapper; using Microsoft.EntityFrameworkCore; using System; using Gameboard.Api.Services; namespace Gameboard.Api.Data; -public class ChallengeStore : Store, IChallengeStore +public interface IChallengeStore : IStore { - private readonly IGuidService _guids; - private readonly GameboardDbContext _dbContext; - private readonly IMapper _mapper; + Task Load(NewChallenge model); + Task Load(string id); + Task UpdateTeam(string teamId); + Task UpdateEtd(string specId); + Task ChallengeGamespaceCount(string teamId); +} +public class ChallengeStore : Store, IChallengeStore +{ public ChallengeStore( IGuidService guids, - GameboardDbContext dbContext, - IMapper mapper) : base(guids, dbContext) - { - _dbContext = dbContext; - _guids = guids; - _mapper = mapper; - } + GameboardDbContext dbContext) : base(guids, dbContext) + { } public override IQueryable List(string term) { diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeValidator.cs b/src/Gameboard.Api/Features/Challenge/ChallengeValidator.cs index 2c06dd44..a4943139 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeValidator.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeValidator.cs @@ -2,6 +2,7 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using System.Threading.Tasks; +using Gameboard.Api.Data; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.Player; @@ -9,10 +10,14 @@ namespace Gameboard.Api.Validators { public class ChallengeValidator : IModelValidator { + private readonly IStore _specStore; private readonly IChallengeStore _store; + private readonly IPlayerStore _playerStore; - public ChallengeValidator(IChallengeStore store) + public ChallengeValidator(IChallengeStore store, IStore specStore, IPlayerStore playerStore) { + _playerStore = playerStore; + _specStore = specStore; _store = store; } @@ -37,16 +42,16 @@ private Task _validate(PlayerDataFilter model) private async Task _validate(Entity model) { - if ((await Exists(model.Id)).Equals(false)) + if ((await _store.Exists(model.Id)).Equals(false)) throw new ResourceNotFound(model.Id); } private async Task _validate(NewChallenge model) { - if ((await PlayerExists(model.PlayerId)).Equals(false)) + if ((await _playerStore.Exists(model.PlayerId)).Equals(false)) throw new ResourceNotFound(model.PlayerId); - if ((await SpecExists(model.SpecId)).Equals(false)) + if ((await _specStore.Exists(model.SpecId)).Equals(false)) throw new ResourceNotFound(model.SpecId); var player = await _store.DbContext.Players.FindAsync(model.PlayerId); @@ -65,42 +70,10 @@ private async Task _validate(NewChallenge model) private async Task _validate(ChangedChallenge model) { - if ((await Exists(model.Id)).Equals(false)) + if ((await _store.Exists(model.Id)).Equals(false)) throw new ResourceNotFound(model.Id); await Task.CompletedTask; } - - private async Task Exists(string id) - { - return - id.NotEmpty() && - (await _store.Retrieve(id)) is Data.Challenge - ; - } - - private async Task GameExists(string id) - { - return - id.NotEmpty() && - (await _store.DbContext.Games.FindAsync(id)) is Data.Game - ; - } - - private async Task SpecExists(string id) - { - return - id.NotEmpty() && - (await _store.DbContext.ChallengeSpecs.FindAsync(id)) is Data.ChallengeSpec - ; - } - - private async Task PlayerExists(string id) - { - return - id.NotEmpty() && - (await _store.DbContext.Players.FindAsync(id)) is Data.Player - ; - } } } diff --git a/src/Gameboard.Api/Features/Challenge/ConsoleActorMap.cs b/src/Gameboard.Api/Features/Challenge/ConsoleActorMap.cs index c997e619..6bf21dbe 100644 --- a/src/Gameboard.Api/Features/Challenge/ConsoleActorMap.cs +++ b/src/Gameboard.Api/Features/Challenge/ConsoleActorMap.cs @@ -11,7 +11,7 @@ namespace Gameboard.Api.Services { public class ConsoleActorMap { - ConcurrentDictionary _cache = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _cache = new(); public ConsoleActorMap() { @@ -46,7 +46,7 @@ public void Update(ConsoleActor actor) public ConsoleActor[] Find(string gid = "") { - var q = gid.HasValue() + var q = gid.NotEmpty() ? _cache.Values.Where(a => a.GameId == gid) : _cache.Values ; @@ -63,8 +63,8 @@ public Dictionary> ReverseLookup(string gid) string key = $"{a.ChallengeId}#{a.VmName}"; if (vmToActor.ContainsKey(key)) vmToActor[key].Add(a.UserName); - else - vmToActor.Add(key, new List{a.UserName}); + else + vmToActor.Add(key, new List { a.UserName }); } return vmToActor; } diff --git a/src/Gameboard.Api/Features/Challenge/IChallengeStore.cs b/src/Gameboard.Api/Features/Challenge/IChallengeStore.cs deleted file mode 100644 index 8d464e49..00000000 --- a/src/Gameboard.Api/Features/Challenge/IChallengeStore.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2021 Carnegie Mellon University. All Rights Reserved. -// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. - -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Gameboard.Api.Data.Abstractions -{ - public interface IChallengeStore : IStore - { - Task Load(NewChallenge model); - Task Load(string id); - Task UpdateTeam(string teamId); - Task UpdateEtd(string specId); - Task ChallengeGamespaceCount(string teamId); - } -} diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusCommand.cs b/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusCommand.cs deleted file mode 100644 index 6d21df22..00000000 --- a/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusCommand.cs +++ /dev/null @@ -1,5 +0,0 @@ -using MediatR; - -namespace Gameboard.Api.Features.ChallengeBonuses; - -public record AddManualBonusCommand(string ChallengeId, CreateManualChallengeBonus Model) : IRequest; diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusHandler.cs b/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusHandler.cs index 329aa232..b841eb56 100644 --- a/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusHandler.cs +++ b/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusHandler.cs @@ -9,6 +9,8 @@ namespace Gameboard.Api.Features.GameEngine.Requests; +public record AddManualBonusCommand(string ChallengeId, CreateManualChallengeBonus Model) : IRequest; + internal class AddManualBonusHandler : IRequestHandler { private readonly IStore _challengeBonusStore; diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusValidator.cs b/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusValidator.cs index 837fffd8..d9387da6 100644 --- a/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusValidator.cs +++ b/src/Gameboard.Api/Features/ChallengeBonuses/AddManualBonus/AddManualBonusValidator.cs @@ -1,25 +1,21 @@ namespace Gameboard.Api.Features.ChallengeBonuses; -using System.Collections.Generic; using System.Threading.Tasks; +using Gameboard.Api.Features.GameEngine.Requests; using Gameboard.Api.Structure.MediatR; using Gameboard.Api.Structure.MediatR.Validators; -using Microsoft.AspNetCore.Http; internal class AddManualBonusValidator : IGameboardRequestValidator { private readonly EntityExistsValidator _challengeExists; - private readonly User _actor; private readonly IValidatorService _validatorService; public AddManualBonusValidator ( EntityExistsValidator challengeExists, - IHttpContextAccessor httpContextAccessor, IValidatorService validatorService ) { - _actor = httpContextAccessor.HttpContext.User.ToActor(); _challengeExists = challengeExists; _validatorService = validatorService; } diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusController.cs b/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusController.cs index f1c7c176..ba086c37 100644 --- a/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusController.cs +++ b/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusController.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using Gameboard.Api.Features.GameEngine.Requests; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusMaps.cs b/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusMaps.cs index 12f94257..79477376 100644 --- a/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusMaps.cs +++ b/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusMaps.cs @@ -1,6 +1,6 @@ using AutoMapper; using Gameboard.Api.Data; -using Gameboard.Api.Features.Common; +using Gameboard.Api.Common; namespace Gameboard.Api.Features.ChallengeBonuses; diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusModels.cs b/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusModels.cs index 8ebb858c..ac7f34c0 100644 --- a/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusModels.cs +++ b/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusModels.cs @@ -1,5 +1,7 @@ using System; -using Gameboard.Api.Features.Common; +using Gameboard.Api.Common; + +namespace Gameboard.Api.Features.ChallengeBonuses; public class CreateManualChallengeBonus { diff --git a/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusStore.cs b/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusStore.cs index 3abb9378..e4a548ac 100644 --- a/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusStore.cs +++ b/src/Gameboard.Api/Features/ChallengeBonuses/ChallengeBonusStore.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Threading.Tasks; using Gameboard.Api.Data; using Gameboard.Api.Data.Abstractions; @@ -23,14 +24,20 @@ public ChallengeBonusStore(GameboardDbContext db, IGuidService guids) public GameboardDbContext DbContext => _db; public IQueryable DbSet => _db.ManualChallengeBonuses; - public async Task CountAsync(Func, IQueryable> queryBuilder = null) + public Task AnyAsync() + => _db.ManualChallengeBonuses.AnyAsync(); + + public Task AnyAsync(Expression> predicate = null) + => _db.ManualChallengeBonuses.AnyAsync(predicate); + + public Task CountAsync(Func, IQueryable> queryBuilder = null) { var query = _db.ManualChallengeBonuses.AsNoTracking(); if (queryBuilder != null) query = queryBuilder(query); - return await query.CountAsync(); + return query.CountAsync(); } public async Task Create(ManualChallengeBonus entity) @@ -55,12 +62,10 @@ public Task> Create(IEnumerable await _db - .ManualChallengeBonuses - .Where(b => b.Id == id) - .ExecuteDeleteAsync(); - + public Task Delete(string id) + { + throw new NotImplementedException(); + } public async Task Exists(string id) { @@ -78,6 +83,9 @@ public IQueryable List(string term = null) .AsNoTracking() .Include(c => c.EnteredByUser); + public IQueryable ListWithNoTracking() + => List(null); + public Task Retrieve(string id) => _db .ManualChallengeBonuses diff --git a/src/Gameboard.Api/Features/ChallengeGate/ChallengeGateService.cs b/src/Gameboard.Api/Features/ChallengeGate/ChallengeGateService.cs index f4204538..d74f3ede 100644 --- a/src/Gameboard.Api/Features/ChallengeGate/ChallengeGateService.cs +++ b/src/Gameboard.Api/Features/ChallengeGate/ChallengeGateService.cs @@ -33,7 +33,7 @@ public async Task AddOrUpdate(NewChallengeGate model) s.GameId == model.GameId ); - if (entity is Data.ChallengeGate) + if (entity is not null) { Mapper.Map(model, entity); await Store.Update(entity); @@ -69,7 +69,7 @@ public async Task Delete(string id) internal async Task List(string id) { if (id.IsEmpty()) - return new ChallengeGate[] { }; + return Array.Empty(); return await Mapper.ProjectTo( diff --git a/src/Gameboard.Api/Features/ChallengeGate/ChallengeGateValidator.cs b/src/Gameboard.Api/Features/ChallengeGate/ChallengeGateValidator.cs index 3a26941f..6d6eb9c9 100644 --- a/src/Gameboard.Api/Features/ChallengeGate/ChallengeGateValidator.cs +++ b/src/Gameboard.Api/Features/ChallengeGate/ChallengeGateValidator.cs @@ -8,10 +8,17 @@ namespace Gameboard.Api.ChallengeGates; public class ChallengeGateValidator : IModelValidator { + private readonly IGameStore _gameStore; + private readonly IStore _specStore; private readonly IStore _store; - public ChallengeGateValidator(IStore store) + public ChallengeGateValidator( + IGameStore gameStore, + IStore specStore, + IStore store) { + _gameStore = gameStore; + _specStore = specStore; _store = store; } @@ -31,7 +38,7 @@ public Task Validate(object model) private async Task _validate(Entity model) { - if ((await Exists(model.Id)).Equals(false)) + if ((await _store.Exists(model.Id)).Equals(false)) throw new ResourceNotFound(model.Id); await Task.CompletedTask; @@ -39,13 +46,13 @@ private async Task _validate(Entity model) private async Task _validate(NewChallengeGate model) { - if ((await GameExists(model.GameId)).Equals(false)) + if ((await _gameStore.Exists(model.GameId)).Equals(false)) throw new ResourceNotFound(model.GameId); - if ((await SpecExists(model.TargetId)).Equals(false)) + if ((await _specStore.Exists(model.TargetId)).Equals(false)) throw new ResourceNotFound(model.TargetId, "The target spec"); - if ((await SpecExists(model.RequiredId)).Equals(false)) + if ((await _specStore.Exists(model.RequiredId)).Equals(false)) throw new ResourceNotFound(model.RequiredId, "The required spec"); string cycleDescription = DetectCycles(model.TargetId, model.RequiredId); @@ -59,36 +66,12 @@ private async Task _validate(NewChallengeGate model) private async Task _validate(ChangedChallengeGate model) { - if ((await Exists(model.Id)).Equals(false)) + if ((await _store.Exists(model.Id)).Equals(false)) throw new ResourceNotFound(model.Id); await Task.CompletedTask; } - private async Task Exists(string id) - { - return - id.NotEmpty() && - (await _store.Retrieve(id)) is Data.ChallengeGate - ; - } - - private async Task GameExists(string id) - { - return - id.NotEmpty() && - (await _store.DbContext.Games.FindAsync(id)) is Data.Game - ; - } - - private async Task SpecExists(string id) - { - return - id.NotEmpty() && - (await _store.DbContext.ChallengeSpecs.FindAsync(id)) is Data.ChallengeSpec - ; - } - // later, enhance with actual cycle detection // https://github.com/cmu-sei/Gameboard/issues/114 internal string DetectCycles(string gameId, string targetId) diff --git a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpec.cs b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpec.cs index 0331d6da..d530f8d8 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpec.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpec.cs @@ -1,68 +1,66 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -namespace Gameboard.Api -{ - public class ExternalSpec - { - public string ExternalId { get; set; } - public string Name { get; set; } - public string Description { get; set; } - public string Text { get; set; } - public GameEngineType GameEngineType { get; set; } - } - - public class SpecDetail : ExternalSpec - { - public string Tag { get; set; } - public bool Disabled { get; set; } - public int AverageDeploySeconds { get; set; } - public int Points { get; set; } - public float X { get; set; } - public float Y { get; set; } - public float R { get; set; } - } +namespace Gameboard.Api; - public class ChallengeSpec : SpecDetail - { - public string Id { get; set; } - public string GameId { get; set; } - } +public class ExternalSpec +{ + public string ExternalId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Text { get; set; } + public GameEngineType GameEngineType { get; set; } +} - public class NewChallengeSpec : SpecDetail - { - public string GameId { get; set; } - } +public class SpecDetail : ExternalSpec +{ + public string Tag { get; set; } + public bool Disabled { get; set; } + public int AverageDeploySeconds { get; set; } + public int Points { get; set; } + public float X { get; set; } + public float Y { get; set; } + public float R { get; set; } +} - public class ChangedChallengeSpec : SpecDetail - { - public string Id { get; set; } +public class ChallengeSpec : SpecDetail +{ + public string Id { get; set; } + public string GameId { get; set; } +} - } +public class NewChallengeSpec : SpecDetail +{ + public string GameId { get; set; } +} - public class BoardSpec - { - public string Id { get; set; } - public string Tag { get; set; } - public string Name { get; set; } - public string Description { get; set; } - public bool Disabled { get; set; } - public int AverageDeploySeconds { get; set; } - public int Points { get; set; } - public float X { get; set; } - public float Y { get; set; } - public float R { get; set; } - } +public class ChangedChallengeSpec : SpecDetail +{ + public string Id { get; set; } +} - public class ChallengeSpecSummary - { - public string Id { get; set; } - public string Name { get; set; } - public string Description { get; set; } - public string Text { get; set; } - public string GameId { get; set; } - public string GameName { get; set; } - public string GameLogo { get; set; } - } +public class BoardSpec +{ + public string Id { get; set; } + public string Tag { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public bool Disabled { get; set; } + public int AverageDeploySeconds { get; set; } + public int Points { get; set; } + public float X { get; set; } + public float Y { get; set; } + public float R { get; set; } +} +public sealed class ChallengeSpecSummary +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string Description { get; set; } + public required string Text { get; set; } + public required string GameId { get; set; } + public required string GameName { get; set; } + public required string GameLogo { get; set; } + public required int AverageDeploySeconds { get; set; } } diff --git a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecController.cs b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecController.cs index 2f9dfdf0..91d0bf55 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecController.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecController.cs @@ -2,6 +2,7 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using System.Threading.Tasks; +using Gameboard.Api.Features.Practice; using Gameboard.Api.Services; using Gameboard.Api.Validators; using Microsoft.AspNetCore.Authorization; @@ -101,17 +102,5 @@ public async Task Sync([FromRoute] string id) { await ChallengeSpecService.Sync(id); } - - /// - /// Find challengespecs - /// - /// - /// - [HttpGet("/api/practice")] - [AllowAnonymous] - public async Task Browse([FromQuery] SearchFilter model) - { - return await ChallengeSpecService.Browse(model); - } } } diff --git a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecMapper.cs b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecMapper.cs index c06df689..e1588597 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecMapper.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecMapper.cs @@ -3,6 +3,7 @@ using Alloy.Api.Client; using AutoMapper; +using Gameboard.Api.Common; using TopoMojo.Api.Client; namespace Gameboard.Api.Services @@ -15,6 +16,7 @@ public ChallengeSpecMapper() CreateMap(); CreateMap(); CreateMap(); + CreateMap(); CreateMap(); CreateMap(); diff --git a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecService.cs b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecService.cs index a552cc95..c09b2220 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecService.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecService.cs @@ -14,14 +14,15 @@ namespace Gameboard.Api.Services public class ChallengeSpecService : _Service { IStore Store { get; } - GameEngineService GameEngine { get; } + IGameEngineService GameEngine { get; } - public ChallengeSpecService( + public ChallengeSpecService + ( ILogger logger, IMapper mapper, CoreOptions options, IStore store, - GameEngineService gameEngine + IGameEngineService gameEngine ) : base(logger, mapper, options) { Store = store; @@ -30,12 +31,13 @@ GameEngineService gameEngine public async Task AddOrUpdate(NewChallengeSpec model) { - var entity = await Store.List().FirstOrDefaultAsync(s => + var entity = await Store.List().FirstOrDefaultAsync + (s => s.ExternalId == model.ExternalId && s.GameId == model.GameId ); - if (entity is Data.ChallengeSpec) + if (entity is not null) { Mapper.Map(model, entity); await Store.Update(entity); @@ -89,33 +91,5 @@ public async Task Sync(string id) await Store.DbContext.SaveChangesAsync(); } - - internal async Task Browse(SearchFilter model) - { - var q = Store.List() - .Include(s => s.Game) - .Where(s => s.Game.PlayerMode == PlayerMode.Practice) - .AsNoTracking(); - - if (model.HasTerm) - { - string term = model.Term.ToLower(); - q = q.Where(s => - s.Id.Equals(term) || - s.Name.ToLower().Contains(term) || - s.Description.ToLower().Contains(term) || - s.Game.Name.ToLower().Contains(term) || - s.Text.ToLower().Contains(term) - ); - } - - q = q.OrderBy(s => s.Name); - q = q.Skip(model.Skip); - - if (model.Take > 0) - q = q.Take(model.Take); - - return await Mapper.ProjectTo(q).ToArrayAsync(); - } } } diff --git a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecValidator.cs b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecValidator.cs index 7c20eb24..2d2b593b 100644 --- a/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecValidator.cs +++ b/src/Gameboard.Api/Features/ChallengeSpec/ChallengeSpecValidator.cs @@ -3,17 +3,18 @@ using System.Threading.Tasks; using Gameboard.Api.Data.Abstractions; -using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Validators { public class ChallengeSpecValidator : IModelValidator { - private readonly IChallengeStore _store; + private readonly IGameStore _gameStore; + private readonly IStore _specStore; - public ChallengeSpecValidator(IChallengeStore store) + public ChallengeSpecValidator(IStore specStore, IGameStore gameStore) { - _store = store; + _gameStore = gameStore; + _specStore = specStore; } public Task Validate(object model) @@ -32,7 +33,7 @@ public Task Validate(object model) private async Task _validate(Entity model) { - if ((await Exists(model.Id)).Equals(false)) + if ((await _specStore.Exists(model.Id)).Equals(false)) throw new ResourceNotFound(model.Id); await Task.CompletedTask; @@ -40,34 +41,17 @@ private async Task _validate(Entity model) private async Task _validate(NewChallengeSpec model) { - if ((await GameExists(model.GameId)).Equals(false)) + if ((await _gameStore.Exists(model.GameId)).Equals(false)) throw new ResourceNotFound(model.GameId); await Task.CompletedTask; } private async Task _validate(ChangedChallengeSpec model) { - if ((await Exists(model.Id)).Equals(false)) + if ((await _specStore.Exists(model.Id)).Equals(false)) throw new ResourceNotFound(model.Id); await Task.CompletedTask; } - - private async Task Exists(string id) - { - return - id.NotEmpty() && - (await _store.Retrieve(id)) is Data.Challenge - ; - } - - private async Task GameExists(string id) - { - return - id.NotEmpty() && - (await _store.DbContext.Games.FindAsync(id)) is Data.Game - ; - } - } } diff --git a/src/Gameboard.Api/Features/Common/CommonModels.cs b/src/Gameboard.Api/Features/Common/CommonModels.cs deleted file mode 100644 index 3f95701d..00000000 --- a/src/Gameboard.Api/Features/Common/CommonModels.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace Gameboard.Api.Features.Common; - -public class SimpleEntity -{ - public string Id { get; set; } - public string Name { get; set; } -} - -public sealed class DateRange -{ - public DateTimeOffset Start { get; set; } - public DateTimeOffset End { get; set; } -} diff --git a/src/Gameboard.Api/Features/CubespaceScoreboard/CubespaceScoreboardController.cs b/src/Gameboard.Api/Features/CubespaceScoreboard/CubespaceScoreboardController.cs index e84d00b0..3da05085 100644 --- a/src/Gameboard.Api/Features/CubespaceScoreboard/CubespaceScoreboardController.cs +++ b/src/Gameboard.Api/Features/CubespaceScoreboard/CubespaceScoreboardController.cs @@ -1,27 +1,24 @@ using System.Threading.Tasks; using Gameboard.Api.Controllers; -using Gameboard.Api.Features.CubespaceScoreboard; using Gameboard.Api.Features.UnityGames; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; +namespace Gameboard.Api.Features.CubespaceScoreboard; + public class CubespaceScoreboardController : _Controller { private readonly ICubespaceScoreboardService _cubespaceScoreboardService; - private readonly IUnityGameService _unityGameService; - public CubespaceScoreboardController( + public CubespaceScoreboardController + ( IDistributedCache cache, ILogger logger, UnityGamesValidator validator, - ICubespaceScoreboardService cubespaceScoreboardService, - IUnityGameService unityGameService) : base(logger, cache, validator) - { - _cubespaceScoreboardService = cubespaceScoreboardService; - _unityGameService = unityGameService; - } + ICubespaceScoreboardService cubespaceScoreboardService + ) : base(logger, cache, validator) => (_cubespaceScoreboardService) = (cubespaceScoreboardService); [HttpPost("/api/cubespace/scoreboard")] [AllowAnonymous] @@ -36,4 +33,4 @@ public void InvalidateScoreboard() { _cubespaceScoreboardService.InvalidateScoreboardCache(); } -} \ No newline at end of file +} diff --git a/src/Gameboard.Api/Features/CubespaceScoreboard/CubespaceScoreboardService.cs b/src/Gameboard.Api/Features/CubespaceScoreboard/CubespaceScoreboardService.cs index da256e0a..e53c1f6c 100644 --- a/src/Gameboard.Api/Features/CubespaceScoreboard/CubespaceScoreboardService.cs +++ b/src/Gameboard.Api/Features/CubespaceScoreboard/CubespaceScoreboardService.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; +using Gameboard.Api.Data; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.CubespaceScoreboard; using Gameboard.Api.Features.UnityGames; @@ -306,4 +307,4 @@ private string LogFormat(object thing) { return JsonSerializer.Serialize(thing); } -} \ No newline at end of file +} diff --git a/src/Gameboard.Api/Features/EntityExtensions.cs b/src/Gameboard.Api/Features/EntityExtensions.cs deleted file mode 100644 index d6502409..00000000 --- a/src/Gameboard.Api/Features/EntityExtensions.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Linq; - -internal static class PlayerExtensions -{ - public static IQueryable WhereIsScoringPlayer(this IQueryable query) - => query.Where(p => p.Score > 0); -} diff --git a/src/Gameboard.Api/Features/ExternalGames/ExternalGamesModels.cs b/src/Gameboard.Api/Features/ExternalGames/ExternalGamesModels.cs index 3a4602d7..20c62028 100644 --- a/src/Gameboard.Api/Features/ExternalGames/ExternalGamesModels.cs +++ b/src/Gameboard.Api/Features/ExternalGames/ExternalGamesModels.cs @@ -1,4 +1,4 @@ -using Gameboard.Api.Features.Common; +using Gameboard.Api.Common; namespace Gameboard.Api.Features.ExternalGames; diff --git a/src/Gameboard.Api/Features/FeatureStartupExtensions.cs b/src/Gameboard.Api/Features/FeatureStartupExtensions.cs index fdeb2685..2089f95e 100644 --- a/src/Gameboard.Api/Features/FeatureStartupExtensions.cs +++ b/src/Gameboard.Api/Features/FeatureStartupExtensions.cs @@ -6,21 +6,12 @@ using System.Reflection; using AutoMapper; using Gameboard.Api; -using Gameboard.Api.Common; using Gameboard.Api.Data; using Gameboard.Api.Data.Abstractions; -using Gameboard.Api.Features.ApiKeys; -using Gameboard.Api.Features.ChallengeBonuses; -using Gameboard.Api.Features.CubespaceScoreboard; -using Gameboard.Api.Features.GameEngine; -using Gameboard.Api.Features.Games; -using Gameboard.Api.Features.Scores; -using Gameboard.Api.Features.Teams; +using Gameboard.Api.Features.Practice; using Gameboard.Api.Features.UnityGames; -using Gameboard.Api.Hubs; using Gameboard.Api.Services; using Gameboard.Api.Structure; -using Microsoft.AspNetCore.SignalR; namespace Microsoft.Extensions.DependencyInjection { @@ -28,33 +19,25 @@ public static class ServiceStartupExtensions { public static IServiceCollection AddGameboardServices(this IServiceCollection services, AppSettings settings) { + var jsonService = JsonService.WithGameboardSerializerOptions(); + // add special case services services .AddSingleton() .AddHttpContextAccessor() .AddScoped() + .AddSingleton(_ => settings.Core) + .AddSingleton(_ => settings.ApiKey) + .AddSingleton(f => jsonService) + .AddSingleton(opts => JsonService.GetJsonSerializerOptions()) + .AddSingleton(jsonService) + .AddConcretesFromNamespace("Gameboard.Api.Services") .AddConcretesFromNamespace("Gameboard.Api.Structure.Authorizers") - .AddConcretesFromNamespace("Gameboard.Api.Structure.Validators"); - - // Auto-discover from EntityService pattern - foreach (var t in Assembly - .GetExecutingAssembly() - .ExportedTypes - .Where(t => (t.Namespace == "Gameboard.Api.Services" || t.BaseType == typeof(_Service)) - && t.Name.EndsWith("Service") - && t.IsClass - && !t.IsAbstract - ) - ) - { - foreach (Type i in t.GetInterfaces()) - services.AddScoped(i, t); - services.AddScoped(t); - } - - // TODO: Ben -> fix this - services.AddHttpContextAccessor(); - services.AddUnboundServices(settings); + .AddConcretesFromNamespace("Gameboard.Api.Structure.Validators") + .AddScoped(typeof(IStore<>), typeof(Store<>)) + // so close to fixing this, but it's a very special snowflake of a binding + .AddScoped() + .AddInterfacesWithSingleImplementations(); foreach (var t in Assembly .GetExecutingAssembly() @@ -74,41 +57,6 @@ public static IServiceCollection AddGameboardServices(this IServiceCollection se return services; } - // TODO: Ben -> fix this (still on my list, but for now at least segregating into a method) - private static IServiceCollection AddUnboundServices(this IServiceCollection services, AppSettings settings) - => services - // singletons - .AddSingleton() - .AddSingleton(f => JsonService.WithGameboardSerializerOptions()) - .AddSingleton() - .AddSingleton() - // global-style services - .AddScoped() - .AddScoped() - .AddSingleton(_ => settings.Core) - .AddSingleton(_ => settings.ApiKey) - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient(typeof(IStore<>), typeof(Store<>)) - // feature services - .AddScoped() - .AddScoped() - .AddScoped, ChallengeBonusStore>() - .AddScoped, AppHub>() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped(); - public static IMapperConfigurationExpression AddGameboardMaps(this IMapperConfigurationExpression cfg) { cfg.AddMaps(Assembly.GetExecutingAssembly()); diff --git a/src/Gameboard.Api/Features/Feedback/FeedbackService.cs b/src/Gameboard.Api/Features/Feedback/FeedbackService.cs index 4f1b7062..bafe8976 100644 --- a/src/Gameboard.Api/Features/Feedback/FeedbackService.cs +++ b/src/Gameboard.Api/Features/Feedback/FeedbackService.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading.Tasks; using AutoMapper; +using Gameboard.Api.Data; using Gameboard.Api.Data.Abstractions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; @@ -87,7 +88,7 @@ public async Task List(FeedbackSearchParams model) { var q = Store.List(model.Term); - if (model.GameId.HasValue()) + if (model.GameId.NotEmpty()) q = q.Where(u => u.GameId == model.GameId); if (model.WantsGame) diff --git a/src/Gameboard.Api/Features/Game/Game.cs b/src/Gameboard.Api/Features/Game/Game.cs index 92cd2b30..f882a6f2 100644 --- a/src/Gameboard.Api/Features/Game/Game.cs +++ b/src/Gameboard.Api/Features/Game/Game.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.Identity.Client; namespace Gameboard.Api { @@ -69,9 +70,13 @@ public class ChangedGame : Game { } public class GameSearchFilter : SearchFilter { - public const string PastFilter = "past"; - public const string PresentFilter = "present"; - public const string FutureFilter = "future"; + private const string CompetitiveFilter = "competitive"; + private const string PracticeFilter = "practice"; + private const string PastFilter = "past"; + private const string PresentFilter = "present"; + private const string FutureFilter = "future"; + public bool WantsCompetitive => Filter.Contains(CompetitiveFilter); + public bool WantsPractice => Filter.Contains(PracticeFilter); public bool WantsPresent => Filter.Contains(PresentFilter); public bool WantsPast => Filter.Contains(PastFilter); public bool WantsFuture => Filter.Contains(FutureFilter); @@ -97,6 +102,18 @@ public class BoardGame public ICollection Prerequisites { get; set; } = new List(); } + public sealed class GameSearchQuery + { + public bool? PlayerMode { get; set; } + public string SearchTerm { get; set; } + } + + public class GameSearchResult + { + public required string Id { get; set; } + public required string Name { get; set; } + } + public class UploadedFile { public string Filename { get; set; } diff --git a/src/Gameboard.Api/Features/Game/GameController.cs b/src/Gameboard.Api/Features/Game/GameController.cs index 598e1dfb..d30741a3 100644 --- a/src/Gameboard.Api/Features/Game/GameController.cs +++ b/src/Gameboard.Api/Features/Game/GameController.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net.Http; using System.Threading.Tasks; using Gameboard.Api.Features.Games; using Gameboard.Api.Services; @@ -26,7 +25,6 @@ public class GameController : _Controller GameService GameService { get; } public CoreOptions Options { get; } public IHostEnvironment Env { get; } - private readonly IHttpClientFactory HttpClientFactory; private readonly IMediator _mediator; public GameController( @@ -36,14 +34,12 @@ public GameController( GameValidator validator, CoreOptions options, IMediator mediator, - IHostEnvironment env, - IHttpClientFactory factory + IHostEnvironment env ) : base(logger, cache, validator) { GameService = gameService; Options = options; Env = env; - HttpClientFactory = factory; _mediator = mediator; } @@ -114,7 +110,6 @@ public async Task Update([FromBody] ChangedGame model) public async Task Delete([FromRoute] string id) { await Validate(new Entity { Id = id }); - await GameService.Delete(id); } @@ -130,6 +125,13 @@ public async Task> List([FromQuery] GameSearchFilter model) return await GameService.List(model, Actor.IsDesigner || Actor.IsTester); } + [HttpGet("api/games/search")] + [AllowAnonymous] + public async Task> Search([FromQuery] GameSearchQuery model) + { + return await GameService.Search(model); + } + /// /// List games grouped by year and month /// @@ -145,9 +147,7 @@ public async Task ListGrouped([FromQuery] GameSearchFilter model) [HttpGet("/api/game/{gameId}/ready")] [Authorize] public async Task IsGameReady(string gameId) - { - return await _mediator.Send(new GetSyncStartStateQuery(gameId, Actor)); - } + => await _mediator.Send(new GetSyncStartStateQuery(gameId, Actor)); [HttpPost("/api/game/import")] [Authorize(AppConstants.DesignerPolicy)] diff --git a/src/Gameboard.Api/Features/Game/GameExtensions.cs b/src/Gameboard.Api/Features/Game/GameExtensions.cs new file mode 100644 index 00000000..2905cd24 --- /dev/null +++ b/src/Gameboard.Api/Features/Game/GameExtensions.cs @@ -0,0 +1,13 @@ +namespace Gameboard.Api.Features.Games; + +public static class GameExtensions +{ + public static bool IsTeamGame(this Game game) + => IsTeamGame(game.MinTeamSize); + + public static bool IsTeamGame(this Data.Game game) + => IsTeamGame(game.MinTeamSize); + + private static bool IsTeamGame(int minTeamSize) + => minTeamSize > 1; +} diff --git a/src/Gameboard.Api/Features/Game/GameMapper.cs b/src/Gameboard.Api/Features/Game/GameMapper.cs index ac39dae6..d413b082 100644 --- a/src/Gameboard.Api/Features/Game/GameMapper.cs +++ b/src/Gameboard.Api/Features/Game/GameMapper.cs @@ -2,6 +2,7 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using AutoMapper; +using Gameboard.Api.Common; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -21,29 +22,37 @@ public GameMapper() // Use BeforeMap for custom mapping since Deserialize() could error and prevent Game from loading // Need to not throw error if format is invalid. If Deserialize() could default to null on error, that would be ideal CreateMap() - .BeforeMap((src, dest) => { - try { + .BeforeMap((src, dest) => + { + try + { dest.FeedbackTemplate = yaml.Deserialize(src.FeedbackConfig ?? ""); - } catch { + } + catch + { dest.FeedbackTemplate = null; } }); CreateMap() - .BeforeMap((src, dest) => { - try { + .BeforeMap((src, dest) => + { + try + { dest.FeedbackTemplate = yaml.Deserialize(src.FeedbackConfig ?? ""); - } catch { + } + catch + { dest.FeedbackTemplate = null; } }); CreateMap(); - CreateMap(); - CreateMap(); + // FROM Data.Game + CreateMap(); } } } diff --git a/src/Gameboard.Api/Features/Game/GameService.cs b/src/Gameboard.Api/Features/Game/GameService.cs index ee9d0103..bc12e58d 100644 --- a/src/Gameboard.Api/Features/Game/GameService.cs +++ b/src/Gameboard.Api/Features/Game/GameService.cs @@ -12,7 +12,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using System.Collections.Generic; -using Gameboard.Api.Features.Common; +using Gameboard.Api.Common; using Gameboard.Api.Features.Games; namespace Gameboard.Api.Services; @@ -26,7 +26,7 @@ public interface IGameService Task HandleSyncStartStateChanged(string gameId, User actor); Task Import(GameSpecImport model); IQueryable BuildQuery(GameSearchFilter model = null, bool sudo = false); - Task> List(GameSearchFilter model, bool sudo); + Task> List(GameSearchFilter model = null, bool sudo = false); Task ListGrouped(GameSearchFilter model, bool sudo); Task ReRank(string id); Task Retrieve(string id, bool accessHidden = true); @@ -118,6 +118,12 @@ public async Task Delete(string id) if (model == null) return q; + if (model.WantsCompetitive) + q = q.Where(g => g.PlayerMode == PlayerMode.Competition); + + if (model.WantsPractice) + q = q.Where(g => g.PlayerMode == PlayerMode.Practice); + if (model.WantsPresent) q = q.Where(g => g.GameEnd > now && g.GameStart < now); @@ -140,6 +146,11 @@ public async Task Delete(string id) return q; } + public Task> Search(GameSearchQuery query) + { + throw new NotImplementedException(); + } + public async Task> List(GameSearchFilter model = null, bool sudo = false) { var games = await BuildQuery(model, sudo) @@ -153,11 +164,14 @@ public async Task ListGrouped(GameSearchFilter model, bool sudo) { DateTimeOffset now = DateTimeOffset.UtcNow; - var q = Store.List(model.Term); + var q = Store + .List(model.Term); if (!sudo) q = q.Where(g => g.IsPublished); + if (model.WantsCompetitive) + q = q.Where(g => g.PlayerMode == PlayerMode.Competition); if (model.WantsPresent) q = q.Where(g => g.GameEnd > now && g.GameStart < now); if (model.WantsFuture) diff --git a/src/Gameboard.Api/Features/Game/GameValidator.cs b/src/Gameboard.Api/Features/Game/GameValidator.cs index e0265ece..3ac695cf 100644 --- a/src/Gameboard.Api/Features/Game/GameValidator.cs +++ b/src/Gameboard.Api/Features/Game/GameValidator.cs @@ -37,7 +37,7 @@ private async Task Exists(string id) { return id.NotEmpty() && - (await _store.Retrieve(id)) is Data.Game + (await _store.Retrieve(id)) is not null ; } diff --git a/src/Gameboard.Api/Features/Game/StartGameCommand/StartGameCommandValidator.cs b/src/Gameboard.Api/Features/Game/StartGameCommand/StartGameCommandValidator.cs index cf0481d3..b3785ef5 100644 --- a/src/Gameboard.Api/Features/Game/StartGameCommand/StartGameCommandValidator.cs +++ b/src/Gameboard.Api/Features/Game/StartGameCommand/StartGameCommandValidator.cs @@ -2,7 +2,6 @@ using Gameboard.Api.Features.Teams; using Gameboard.Api.Services; using Gameboard.Api.Structure.MediatR; -using Gameboard.Api.Structure.MediatR.Authorizers; using Gameboard.Api.Structure.MediatR.Validators; namespace Gameboard.Api.Features.Games; @@ -11,8 +10,6 @@ internal class StartGameCommandValidator : IGameboardRequestValidator _gameExists; private readonly IGameService _gameService; - private readonly INowService _nowService; - private readonly UserRoleAuthorizer _roleAuthorizer; private readonly ITeamService _teamService; private readonly IValidatorService _validatorService; @@ -20,16 +17,12 @@ public StartGameCommandValidator ( EntityExistsValidator gameExists, IGameService gameService, - INowService nowService, - UserRoleAuthorizer roleAuthorizer, ITeamService teamService, IValidatorService validatorService ) { _gameExists = gameExists; _gameService = gameService; - _nowService = nowService; - _roleAuthorizer = roleAuthorizer; _teamService = teamService; _validatorService = validatorService; } diff --git a/src/Gameboard.Api/Features/Game/SyncStartModels.cs b/src/Gameboard.Api/Features/Game/SyncStartModels.cs index 3854125d..21176c3b 100644 --- a/src/Gameboard.Api/Features/Game/SyncStartModels.cs +++ b/src/Gameboard.Api/Features/Game/SyncStartModels.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using Gameboard.Api.Features.Common; +using Gameboard.Api.Common; namespace Gameboard.Api.Features.Games; diff --git a/src/Gameboard.Api/Features/GameEngine/Extensions/ChallengeExtensions.cs b/src/Gameboard.Api/Features/GameEngine/Extensions/ChallengeExtensions.cs new file mode 100644 index 00000000..2a978d1f --- /dev/null +++ b/src/Gameboard.Api/Features/GameEngine/Extensions/ChallengeExtensions.cs @@ -0,0 +1,17 @@ +using System; +using System.Text.Json; +using AutoMapper; +using TopoMojo.Api.Client; + +namespace Gameboard.Api.Features.GameEngine; + +internal static class GameEngineChallengeExtensions +{ + public static GameEngineGameState BuildGameEngineState(this Data.Challenge challenge, IMapper mapper, JsonSerializerOptions jsonSerializerOptions) + { + var state = mapper.Map(JsonSerializer.Deserialize(challenge.State, jsonSerializerOptions)); + state.Vms ??= Array.Empty(); + + return state; + } +} diff --git a/src/Gameboard.Api/Features/GameEngine/GameEngineController.cs b/src/Gameboard.Api/Features/GameEngine/GameEngineController.cs index c339fee8..18f44d52 100644 --- a/src/Gameboard.Api/Features/GameEngine/GameEngineController.cs +++ b/src/Gameboard.Api/Features/GameEngine/GameEngineController.cs @@ -4,7 +4,6 @@ using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; namespace Gameboard.Api.Features.GameEngine; @@ -12,17 +11,10 @@ namespace Gameboard.Api.Features.GameEngine; [Route("/api/gameEngine")] public class GameEngineController : ControllerBase { - private readonly GameEngineService _gameEngine; private readonly IMediator _mediator; - public GameEngineController - ( - GameEngineService gameEngineService, - ILogger logger, - IMediator mediator - ) + public GameEngineController(IMediator mediator) { - _gameEngine = gameEngineService; _mediator = mediator; } diff --git a/src/Gameboard.Api/Features/GameEngine/GameEngineMaps.cs b/src/Gameboard.Api/Features/GameEngine/GameEngineMaps.cs index 207ee603..b3cb81eb 100644 --- a/src/Gameboard.Api/Features/GameEngine/GameEngineMaps.cs +++ b/src/Gameboard.Api/Features/GameEngine/GameEngineMaps.cs @@ -1,5 +1,4 @@ using System.Linq; -using System.Text.Json; using AutoMapper; using Gameboard.Api.Services; @@ -13,14 +12,14 @@ public GameEngineMaps() var jsonService = JsonService.WithGameboardSerializerOptions(); // api-level maps - CreateMap() + CreateMap() .ForMember(gep => gep.SubjectId, o => o.MapFrom(p => p.Id)) .ForMember(gep => gep.SubjectName, o => o.MapFrom(p => p.ApprovedName)) .ForMember(gep => gep.GamespaceId, o => o.Ignore()) .ForMember(gep => gep.Permission, o => o.Ignore()); // engine-level maps - CreateMap(MemberList.Source) + CreateMap(MemberList.Source) .ForMember(p => p.Id, o => o.MapFrom(gep => gep.SubjectId)) .ForMember(p => p.ApprovedName, o => o.MapFrom(gep => gep.SubjectName)) .ForMember(p => p.Role, o => o.MapFrom(gep => gep.IsManager ? PlayerRole.Manager : PlayerRole.Member)) @@ -29,9 +28,9 @@ public GameEngineMaps() .ForSourceMember(gep => gep.Permission, o => o.DoNotValidate()) .ForSourceMember(gep => gep.GamespaceId, o => o.DoNotValidate()); - CreateMap() + CreateMap() .ForMember(c => c.EndTime, o => o.MapFrom(s => s.ExpirationTime)) - .ForMember(c => c.HasDeployedGamespace, o => o.MapFrom(s => s.HasDeployedGamespace)) + .ForMember(c => c.HasDeployedGamespace, o => o.MapFrom(s => s.Vms.Any() && s.IsActive)) .ForMember(c => c.LastScoreTime, o => o.MapFrom(s => s.Challenge.LastScoreTime)) .ForMember(c => c.ExternalId, o => o.MapFrom(s => s.Id)) .ForMember(c => c.PlayerId, o => o.MapFrom(p => p.Players.Where(p => p.IsManager).First().SubjectId)) @@ -49,6 +48,7 @@ public GameEngineMaps() .ForMember(c => c.Game, o => o.Ignore()) .ForMember(c => c.GraderKey, o => o.Ignore()) .ForMember(c => c.LastSyncTime, o => o.Ignore()) + .ForMember(c => c.PlayerMode, o => o.Ignore()) .ForMember(c => c.SpecId, o => o.Ignore()) .ForMember(c => c.Tag, o => o.Ignore()) .ForMember(c => c.TeamId, o => o.Ignore()) diff --git a/src/Gameboard.Api/Features/GameEngine/GetGameState/GetGameStateHandler.cs b/src/Gameboard.Api/Features/GameEngine/GetGameState/GetGameStateHandler.cs index f63362e6..18cf8432 100644 --- a/src/Gameboard.Api/Features/GameEngine/GetGameState/GetGameStateHandler.cs +++ b/src/Gameboard.Api/Features/GameEngine/GetGameState/GetGameStateHandler.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using Gameboard.Api.Structure.MediatR.Authorizers; using MediatR; -using Microsoft.AspNetCore.Http; namespace Gameboard.Api.Features.GameEngine; @@ -11,7 +10,6 @@ public record GetGameStateQuery(string TeamId) : IRequest> { - private readonly User _actor; private readonly IGameEngineStore _gameEngineStore; private readonly GetGameStateValidator _validator; private readonly UserRoleAuthorizer _roleAuthorizer; @@ -20,11 +18,9 @@ public GetGameStateHandler ( IGameEngineStore gameEngineStore, UserRoleAuthorizer roleAuthorizer, - GetGameStateValidator validator, - IHttpContextAccessor httpContextAccessor + GetGameStateValidator validator ) { - _actor = httpContextAccessor.HttpContext.User.ToActor(); _gameEngineStore = gameEngineStore; _roleAuthorizer = roleAuthorizer; _validator = validator; diff --git a/src/Gameboard.Api/Features/GameEngine/GetGameState/GetGameStateValidator.cs b/src/Gameboard.Api/Features/GameEngine/GetGameState/GetGameStateValidator.cs index 54a27fbc..f29df525 100644 --- a/src/Gameboard.Api/Features/GameEngine/GetGameState/GetGameStateValidator.cs +++ b/src/Gameboard.Api/Features/GameEngine/GetGameState/GetGameStateValidator.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Features.Teams; using Gameboard.Api.Structure.MediatR; using Microsoft.EntityFrameworkCore; diff --git a/src/Gameboard.Api/Features/GameEngine/GetSubmissions/GetSubmissionsRequestHandler.cs b/src/Gameboard.Api/Features/GameEngine/GetSubmissions/GetSubmissionsRequestHandler.cs index 655de123..42529ba2 100644 --- a/src/Gameboard.Api/Features/GameEngine/GetSubmissions/GetSubmissionsRequestHandler.cs +++ b/src/Gameboard.Api/Features/GameEngine/GetSubmissions/GetSubmissionsRequestHandler.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Data; using Gameboard.Api.Structure.MediatR.Authorizers; using MediatR; using Microsoft.AspNetCore.Http; diff --git a/src/Gameboard.Api/Features/GameEngine/Models/GameEngineModels.cs b/src/Gameboard.Api/Features/GameEngine/Models/GameEngineModels.cs index 431d4b4e..e29e59f0 100644 --- a/src/Gameboard.Api/Features/GameEngine/Models/GameEngineModels.cs +++ b/src/Gameboard.Api/Features/GameEngine/Models/GameEngineModels.cs @@ -6,12 +6,12 @@ namespace Gameboard.Api.Features.GameEngine; public class GameEngineChallengeRegistration { - public required Api.Data.Challenge Challenge { get; set; } - public required Api.Data.ChallengeSpec ChallengeSpec { get; set; } - public required Api.Data.Game Game { get; set; } + public required Data.Challenge Challenge { get; set; } + public required Data.ChallengeSpec ChallengeSpec { get; set; } + public required Data.Game Game { get; set; } public required string GraderKey { get; set; } public required string GraderUrl { get; set; } - public required Api.Data.Player Player { get; set; } + public required Data.Player Player { get; set; } public required int PlayerCount { get; set; } public int Variant { get; set; } } @@ -34,11 +34,6 @@ public class GameEngineGameState public DateTimeOffset ExpirationTime { get; set; } public IEnumerable Vms { get; set; } public GameEngineChallengeView Challenge { get; set; } - - public bool HasDeployedGamespace - { - get => Vms != null && Vms.Count() > 0; - } } public class GameEnginePlayer diff --git a/src/Gameboard.Api/Features/GameEngine/Services/CrucibleService.cs b/src/Gameboard.Api/Features/GameEngine/Services/CrucibleService.cs index f1738792..59842c63 100644 --- a/src/Gameboard.Api/Features/GameEngine/Services/CrucibleService.cs +++ b/src/Gameboard.Api/Features/GameEngine/Services/CrucibleService.cs @@ -11,10 +11,20 @@ using System.Linq; using Microsoft.EntityFrameworkCore; using Gameboard.Api.Services; +using Gameboard.Api.Data; namespace Gameboard.Api.Features.GameEngine; -public class CrucibleService : _Service +public interface ICrucibleService +{ + Task CompleteGamespace(Data.Challenge entity); + Task GradeChallenge(string challengeId, GameEngineSectionSubmission model); + Task ListSpecs(); + Task PreviewGamespace(string externalId); + Task RegisterGamespace(Data.ChallengeSpec spec, Data.Game game, Data.Player player, Data.Challenge entity); +} + +public class CrucibleService : _Service, ICrucibleService { IChallengeStore Store { get; } IAlloyApiClient Alloy { get; } diff --git a/src/Gameboard.Api/Features/GameEngine/Services/GameEngineService.cs b/src/Gameboard.Api/Features/GameEngine/Services/GameEngineService.cs index d4a51cdc..a2ad9c26 100644 --- a/src/Gameboard.Api/Features/GameEngine/Services/GameEngineService.cs +++ b/src/Gameboard.Api/Features/GameEngine/Services/GameEngineService.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Threading.Tasks; using AutoMapper; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using TopoMojo.Api.Client; using Alloy.Api.Client; @@ -13,31 +12,46 @@ namespace Gameboard.Api.Features.GameEngine; +public interface IGameEngineService +{ + Task> AuditChallenge(Data.Challenge entity); + Task CompleteGamespace(Data.Challenge entity); + Task DeleteGamespace(Data.Challenge entity); + Task GetChallengeState(GameEngineType gameEngineType, string stateJson); + Task ExtendSession(Data.Challenge entity, DateTimeOffset sessionEnd); + Task GetConsole(Data.Challenge entity, ConsoleRequest model, bool observer); + Task GetPreview(Data.ChallengeSpec spec); + Task GradeChallenge(Data.Challenge entity, GameEngineSectionSubmission model); + Task ListSpecs(SearchFilter model); + Task LoadGamespace(Data.Challenge entity); + Task RegisterGamespace(GameEngineChallengeRegistration registration); + Task RegradeChallenge(Data.Challenge entity); + Task StartGamespace(Data.Challenge entity); + Task StopGamespace(Data.Challenge entity); +} + public class GameEngineService : _Service, IGameEngineService { ITopoMojoApiClient Mojo { get; } - CrucibleService Crucible { get; } + ICrucibleService Crucible { get; } IAlloyApiClient Alloy { get; } - private IMemoryCache _localcache; - private ConsoleActorMap _actorMap; + private readonly IJsonService _jsonService; private readonly IGameEngineStore _store; public GameEngineService( + IJsonService jsonService, ILogger logger, IGameEngineStore store, IMapper mapper, CoreOptions options, ITopoMojoApiClient mojo, - IMemoryCache localcache, - ConsoleActorMap actorMap, IAlloyApiClient alloy, - CrucibleService crucible + ICrucibleService crucible ) : base(logger, mapper, options) { + _jsonService = jsonService; Mojo = mojo; - _localcache = localcache; - _actorMap = actorMap; _store = store; Alloy = alloy; Crucible = crucible; @@ -52,11 +66,11 @@ public async Task RegisterGamespace(GameEngineChallengeRegi { Players = new RegistrationPlayer[] { - new RegistrationPlayer - { - SubjectId = registration.Player.TeamId, - SubjectName = registration.Player.ApprovedName - } + new RegistrationPlayer + { + SubjectId = registration.Player.TeamId, + SubjectName = registration.Player.ApprovedName + } }, ResourceId = registration.ChallengeSpec.ExternalId, Variant = registration.Variant, @@ -77,6 +91,15 @@ public async Task RegisterGamespace(GameEngineChallengeRegi } } + public Task GetChallengeState(GameEngineType gameEngineType, string stateJson) + { + return gameEngineType switch + { + GameEngineType.TopoMojo => Task.FromResult(Mapper.Map(_jsonService.Deserialize(stateJson))), + _ => throw new NotImplementedException(), + }; + } + public async Task GetPreview(Data.ChallengeSpec spec) { switch (spec.GameEngineType) @@ -110,14 +133,11 @@ public async Task GradeChallenge(Data.Challenge entity, Gam public async Task RegradeChallenge(Data.Challenge entity) { - switch (entity.GameEngineType) + return entity.GameEngineType switch { - case GameEngineType.TopoMojo: - return Mapper.Map(await Mojo.RegradeChallengeAsync(entity.Id)); - - default: - throw new NotImplementedException(); - } + GameEngineType.TopoMojo => Mapper.Map(await Mojo.RegradeChallengeAsync(entity.Id)), + _ => throw new NotImplementedException(), + }; } public async Task GetConsole(Data.Challenge entity, ConsoleRequest model, bool observer) @@ -126,16 +146,13 @@ public async Task GetConsole(Data.Challenge entity, ConsoleReque { case ConsoleAction.Ticket: { - switch (entity.GameEngineType) + return entity.GameEngineType switch { - case GameEngineType.TopoMojo: - return Mapper.Map( - await Mojo.GetVmTicketAsync(model.Id) - ); - - default: - throw new NotImplementedException(); - } + GameEngineType.TopoMojo => Mapper.Map( + await Mojo.GetVmTicketAsync(model.Id) + ), + _ => throw new NotImplementedException(), + }; } case ConsoleAction.Reset: @@ -234,37 +251,29 @@ public async Task ListSpecs(SearchFilter model) public async Task LoadGamespace(Data.Challenge entity) { - switch (entity.GameEngineType) + return entity.GameEngineType switch { - case GameEngineType.TopoMojo: - return Mapper.Map(await Mojo.LoadGamespaceAsync(entity.Id)); - default: - throw new NotImplementedException(); - } + GameEngineType.TopoMojo => Mapper.Map(await Mojo.LoadGamespaceAsync(entity.Id)), + _ => throw new NotImplementedException(), + }; } public async Task StartGamespace(Data.Challenge entity) { - switch (entity.GameEngineType) + return entity.GameEngineType switch { - case GameEngineType.TopoMojo: - return Mapper.Map(await Mojo.StartGamespaceAsync(entity.Id)); - - default: - throw new NotImplementedException(); - } + GameEngineType.TopoMojo => Mapper.Map(await Mojo.StartGamespaceAsync(entity.Id)), + _ => throw new NotImplementedException(), + }; } public async Task StopGamespace(Data.Challenge entity) { - switch (entity.GameEngineType) + return entity.GameEngineType switch { - case GameEngineType.TopoMojo: - return Mapper.Map(await Mojo.StopGamespaceAsync(entity.Id)); - - default: - throw new NotImplementedException(); - } + GameEngineType.TopoMojo => Mapper.Map(await Mojo.StopGamespaceAsync(entity.Id)), + _ => throw new NotImplementedException(), + }; } public async Task DeleteGamespace(Data.Challenge entity) @@ -274,7 +283,6 @@ public async Task DeleteGamespace(Data.Challenge entity) case GameEngineType.TopoMojo: await Mojo.DeleteGamespaceAsync(entity.Id); break; - default: throw new NotImplementedException(); } @@ -299,17 +307,14 @@ public async Task CompleteGamespace(Data.Challenge entity) public Task ExtendSession(Data.Challenge entity, DateTimeOffset sessionEnd) { - switch (entity.GameEngineType) + return entity.GameEngineType switch { - case GameEngineType.TopoMojo: - return Mojo.UpdateGamespaceAsync(new ChangedGamespace - { - Id = entity.Id, - ExpirationTime = sessionEnd - }); - - default: - throw new NotImplementedException(); - } + GameEngineType.TopoMojo => Mojo.UpdateGamespaceAsync(new ChangedGamespace + { + Id = entity.Id, + ExpirationTime = sessionEnd + }), + _ => throw new NotImplementedException(), + }; } } diff --git a/src/Gameboard.Api/Features/GameEngine/Services/IGameEngineService.cs b/src/Gameboard.Api/Features/GameEngine/Services/IGameEngineService.cs deleted file mode 100644 index e69a39ab..00000000 --- a/src/Gameboard.Api/Features/GameEngine/Services/IGameEngineService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Gameboard.Api.Features.GameEngine; - -public interface IGameEngineService -{ - Task> AuditChallenge(Data.Challenge entity); - Task CompleteGamespace(Data.Challenge entity); - Task DeleteGamespace(Data.Challenge entity); - Task ExtendSession(Data.Challenge entity, DateTimeOffset sessionEnd); - Task GetConsole(Data.Challenge entity, ConsoleRequest model, bool observer); - Task GetPreview(Data.ChallengeSpec spec); - Task GradeChallenge(Data.Challenge entity, GameEngineSectionSubmission model); - Task ListSpecs(SearchFilter model); - Task LoadGamespace(Data.Challenge entity); - Task RegisterGamespace(GameEngineChallengeRegistration registration); - Task RegradeChallenge(Data.Challenge entity); - Task StartGamespace(Data.Challenge entity); - Task StopGamespace(Data.Challenge entity); -} diff --git a/src/Gameboard.Api/Features/Hubs/AppHub.cs b/src/Gameboard.Api/Features/Hubs/AppHub.cs index 43afbc31..966995f9 100644 --- a/src/Gameboard.Api/Features/Hubs/AppHub.cs +++ b/src/Gameboard.Api/Features/Hubs/AppHub.cs @@ -8,6 +8,7 @@ using AutoMapper; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.Games; +using Gameboard.Api.Features.Teams; using Gameboard.Api.Services; using MediatR; using Microsoft.AspNetCore.Authorization; diff --git a/src/Gameboard.Api/Features/Hubs/IAppHub.cs b/src/Gameboard.Api/Features/Hubs/IAppHub.cs index dcd8c90f..3d7ffeaf 100644 --- a/src/Gameboard.Api/Features/Hubs/IAppHub.cs +++ b/src/Gameboard.Api/Features/Hubs/IAppHub.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Gameboard.Api.Features.Games; +using Gameboard.Api.Features.Teams; namespace Gameboard.Api.Hubs { diff --git a/src/Gameboard.Api/Features/Hubs/InternalHubBus.cs b/src/Gameboard.Api/Features/Hubs/InternalHubBus.cs index 67ce6ec1..632c15d2 100644 --- a/src/Gameboard.Api/Features/Hubs/InternalHubBus.cs +++ b/src/Gameboard.Api/Features/Hubs/InternalHubBus.cs @@ -1,10 +1,10 @@ using System.Threading.Tasks; using AutoMapper; -using Gameboard.Api; -using Gameboard.Api.Features.Games; -using Gameboard.Api.Hubs; +using Gameboard.Api.Features.Teams; using Microsoft.AspNetCore.SignalR; +namespace Gameboard.Api.Hubs; + /// /// This is separate from AppHub because it encapsulates hub management functionality that we want to be available server side /// but not client side - every public method on AppHub is available to clients. diff --git a/src/Gameboard.Api/Features/Player/IPlayerStore.cs b/src/Gameboard.Api/Features/Player/IPlayerStore.cs index 2125cfb8..98eeeade 100644 --- a/src/Gameboard.Api/Features/Player/IPlayerStore.cs +++ b/src/Gameboard.Api/Features/Player/IPlayerStore.cs @@ -4,16 +4,14 @@ using System.Linq; using System.Threading.Tasks; -namespace Gameboard.Api.Data.Abstractions -{ +namespace Gameboard.Api.Data.Abstractions; - public interface IPlayerStore : IStore - { - Task DeleteTeam(string teamId); - Task GetUserEnrollments(string id); - IQueryable ListTeam(string id); - Task ListTeamByPlayer(string id); - Task ListTeamChallenges(string id); - Task LoadBoard(string id); - } +public interface IPlayerStore : IStore +{ + Task DeleteTeam(string teamId); + Task GetUserEnrollments(string id); + IQueryable ListTeam(string id); + Task ListTeamByPlayer(string id); + Task ListTeamChallenges(string id); + Task LoadBoard(string id); } diff --git a/src/Gameboard.Api/Features/Player/Player.cs b/src/Gameboard.Api/Features/Player/Player.cs index 0e49d1b2..f525d4e4 100644 --- a/src/Gameboard.Api/Features/Player/Player.cs +++ b/src/Gameboard.Api/Features/Player/Player.cs @@ -5,290 +5,210 @@ using System.Collections.Generic; using System.Linq; -namespace Gameboard.Api -{ - public class Player - { - public string Id { get; set; } - public string TeamId { get; set; } - public string UserId { get; set; } - public string UserName { get; set; } - public string UserApprovedName { get; set; } - public string GameId { get; set; } - public string GameName { get; set; } - public string ApprovedName { get; set; } - public string TeamName { get; set; } - public string Name { get; set; } - public string NameStatus { get; set; } - public string Sponsor { get; set; } - public string TeamSponsors { get; set; } - public PlayerRole Role { get; set; } - public DateTimeOffset SessionBegin { get; set; } - public DateTimeOffset SessionEnd { get; set; } - public int SessionMinutes { get; set; } - public int Rank { get; set; } - public int Score { get; set; } - public long Time { get; set; } - public int CorrectCount { get; set; } - public int PartialCount { get; set; } - public bool Advanced { get; set; } - public bool IsManager { get; set; } - public bool IsReady { get; set; } - public PlayerMode Mode { get; set; } - public string[] SponsorList => (TeamSponsors ?? Sponsor ?? "").Split("|"); - } - - public class NewPlayer - { - public string UserId { get; set; } - public string GameId { get; set; } - public string Name { get; set; } - public string Sponsor { get; set; } - } - - public class ChangedPlayer - { - public string Id { get; set; } - public string Name { get; set; } - public string NameStatus { get; set; } - public string ApprovedName { get; set; } - public string Sponsor { get; set; } - public PlayerRole Role { get; set; } - } - - public class PlayerReadyUpdate - { - public bool IsReady { get; set; } - } - - public class SessionStartRequest - { - public string PlayerId { get; set; } - } - - public class SessionChangeRequest - { - public string TeamId { get; set; } - public DateTimeOffset SessionEnd { get; set; } - } +namespace Gameboard.Api; - public class SelfChangedPlayer - { - public string Id { get; set; } - public string Name { get; set; } - } - - public class PlayerEnlistment - { - public string UserId { get; set; } - public string PlayerId { get; set; } - public string Code { get; set; } - } - - public class PlayerUnenrollRequest - { - public User Actor { get; set; } - public bool AsAdmin { get; set; } = false; - public required string PlayerId { get; set; } - } +public class Player +{ + public string Id { get; set; } + public string TeamId { get; set; } + public string UserId { get; set; } + public string UserName { get; set; } + public string UserApprovedName { get; set; } + public string GameId { get; set; } + public string GameName { get; set; } + public string ApprovedName { get; set; } + public string TeamName { get; set; } + public string Name { get; set; } + public string NameStatus { get; set; } + public string Sponsor { get; set; } + public string TeamSponsors { get; set; } + public PlayerRole Role { get; set; } + public DateTimeOffset SessionBegin { get; set; } + public DateTimeOffset SessionEnd { get; set; } + public int SessionMinutes { get; set; } + public int Rank { get; set; } + public int Score { get; set; } + public long Time { get; set; } + public int CorrectCount { get; set; } + public int PartialCount { get; set; } + public bool Advanced { get; set; } + public bool IsManager { get; set; } + public bool IsReady { get; set; } + public PlayerMode Mode { get; set; } + public string[] SponsorList => (TeamSponsors ?? Sponsor ?? "").Split("|"); +} - public class PromoteToManagerRequest - { - public User Actor { get; set; } - public bool AsAdmin { get; set; } - public string CurrentManagerPlayerId { get; set; } - public string NewManagerPlayerId { get; set; } - public string TeamId { get; set; } - } +public class NewPlayer +{ + public string UserId { get; set; } + public string GameId { get; set; } + public string Name { get; set; } + public string Sponsor { get; set; } +} - public class SessionResetRequest - { - public required bool IsManualReset { get; set; } = false; - public required bool UnenrollTeam { get; set; } = true; - } +public class ChangedPlayer +{ + public string Id { get; set; } + public string Name { get; set; } + public string NameStatus { get; set; } + public string ApprovedName { get; set; } + public string Sponsor { get; set; } + public PlayerRole Role { get; set; } +} - public class SessionResetCommandArgs - { - public required User ActingUser { get; set; } - public required bool IsManualReset { get; set; } = false; - public required string PlayerId { get; set; } - public required bool UnenrollTeam { get; set; } = true; - } +public class PlayerReadyUpdate +{ + public bool IsReady { get; set; } +} - public class Standing - { - public string TeamId { get; set; } - public string ApprovedName { get; set; } - public string Sponsor { get; set; } - public string TeamSponsors { get; set; } - public DateTimeOffset SessionBegin { get; set; } - public DateTimeOffset SessionEnd { get; set; } - public int Rank { get; set; } - public int Score { get; set; } - public long Time { get; set; } - public int CorrectCount { get; set; } - public int PartialCount { get; set; } - public bool Advanced { get; set; } - public string[] SponsorList => (TeamSponsors ?? Sponsor).Split("|"); - } +public class SessionStartRequest +{ + public string PlayerId { get; set; } +} - public class TeamInvitation - { - public string Code { get; set; } - } +public class SessionChangeRequest +{ + public string TeamId { get; set; } + public DateTimeOffset SessionEnd { get; set; } +} - public class TeamAdvancement - { - public string[] TeamIds { get; set; } - public string GameId { get; set; } - public bool WithScores { get; set; } - public string NextGameId { get; set; } - } +public class SelfChangedPlayer +{ + public string Id { get; set; } + public string Name { get; set; } +} - public class Team - { - public string TeamId { get; set; } - public string ApprovedName { get; set; } - public string GameId { get; set; } - public string Sponsor { get; set; } - public string TeamSponsors { get; set; } - public DateTimeOffset SessionBegin { get; set; } - public DateTimeOffset SessionEnd { get; set; } - public int Rank { get; set; } - public int Score { get; set; } - public long Time { get; set; } - public int CorrectCount { get; set; } - public int PartialCount { get; set; } - public bool Advanced { get; set; } - public ICollection Challenges { get; set; } = new List(); - public ICollection Members { get; set; } = new List(); - public string[] SponsorList => (TeamSponsors ?? Sponsor).Split("|"); - } +public class PlayerEnlistment +{ + public string UserId { get; set; } + public string PlayerId { get; set; } + public string Code { get; set; } +} - public class TeamSummary - { - public string Id { get; set; } - public string Name { get; set; } - public string Sponsor { get; set; } - public string TeamSponsors { get; set; } - public string[] Members { get; set; } - public string[] SponsorList => (TeamSponsors ?? Sponsor).Split("|"); - } +public class PlayerUnenrollRequest +{ + public User Actor { get; set; } + public bool AsAdmin { get; set; } = false; + public required string PlayerId { get; set; } +} - public class PlayerOverview - { - public string Id { get; set; } - public string TeamId { get; set; } - public string GameId { get; set; } - public string GameName { get; set; } - public string ApprovedName { get; set; } - } +public class SessionResetRequest +{ + public required bool IsManualReset { get; set; } = false; + public required bool UnenrollTeam { get; set; } = true; +} - public class PlayerDataFilter : SearchFilter - { +public class SessionResetCommandArgs +{ + public required User ActingUser { get; set; } + public required bool IsManualReset { get; set; } = false; + public required string PlayerId { get; set; } + public required bool UnenrollTeam { get; set; } = true; +} - 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"; - public const string NamePendingFilter = "pending"; - public const string NameDisallowedFilter = "disallowed"; - public const string SortRank = "rank"; - public const string SortTime = "time"; - public const string SortName = "name"; - public string tid { get; set; } - public string gid { get; set; } - public string uid { get; set; } - public string org { get; set; } - public string mode { get; set; } - public bool WantsActive => Filter.Contains(FilterActiveOnly); - 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(); - public bool WantsOrg => org.NotEmpty(); - public bool WantsCollapsed => Filter.Contains(FilterCollapseTeams); - public bool WantsPending => Filter.Contains(NamePendingFilter); - public bool WantsDisallowed => Filter.Contains(NameDisallowedFilter); - public bool WantsSortByTime => Sort == SortTime; - public bool WantsSortByRank => Sort == SortRank || string.IsNullOrEmpty(Sort); - public bool WantsSortByName => Sort == SortName; - public bool WantsMode => Enum.TryParse(mode, true, out _); - } +public class Standing +{ + public string TeamId { get; set; } + public string ApprovedName { get; set; } + public string Sponsor { get; set; } + public string TeamSponsors { get; set; } + public DateTimeOffset SessionBegin { get; set; } + public DateTimeOffset SessionEnd { get; set; } + public int Rank { get; set; } + public int Score { get; set; } + public long Time { get; set; } + public int CorrectCount { get; set; } + public int PartialCount { get; set; } + public bool Advanced { get; set; } + public string[] SponsorList => (TeamSponsors ?? Sponsor).Split("|"); +} - public class BoardPlayer - { - public string Id { get; set; } - public string TeamId { get; set; } - public string UserId { get; set; } - public string GameId { get; set; } - public string ApprovedName { get; set; } - public string Name { get; set; } - public string NameStatus { get; set; } - public string Sponsor { get; set; } - public PlayerRole Role { get; set; } - public PlayerMode Mode { get; set; } - public DateTimeOffset SessionBegin { get; set; } - public DateTimeOffset SessionEnd { get; set; } - public int SessionMinutes { get; set; } - public int Rank { get; set; } - public int Score { get; set; } - public long Time { get; set; } - public int CorrectCount { get; set; } - public int PartialCount { get; set; } - public BoardGame Game { get; set; } - public string ChallengeDocUrl { get; set; } - public ICollection Challenges { get; set; } = new List(); - public bool IsManager => Role == PlayerRole.Manager; - public bool IsPractice => Mode == PlayerMode.Practice; - } +public class PlayerOverview +{ + public string Id { get; set; } + public string TeamId { get; set; } + public string GameId { get; set; } + public string GameName { get; set; } + public string ApprovedName { get; set; } +} - public class TeamPlayer - { - public string Id { get; set; } - public string TeamId { get; set; } - public string Name { get; set; } - public string ApprovedName { get; set; } - public string UserId { get; set; } - public string UserName { get; set; } - public string UserApprovedName { get; set; } - public string UserNameStatus { get; set; } - public string Sponsor { get; set; } - public PlayerRole Role { get; set; } - public bool IsManager => Role == PlayerRole.Manager; - } +public class PlayerDataFilter : SearchFilter +{ - public class TeamState - { - public string TeamId { get; set; } - public Player ActingPlayer { get; set; } - public string ApprovedName { get; set; } - public string Name { get; set; } - public string NameStatus { get; set; } - public DateTimeOffset SessionBegin { get; set; } - public DateTimeOffset SessionEnd { get; set; } - public User Actor { get; set; } - } + 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"; + public const string NamePendingFilter = "pending"; + public const string NameDisallowedFilter = "disallowed"; + public const string SortRank = "rank"; + public const string SortTime = "time"; + public const string SortName = "name"; + public string tid { get; set; } + public string gid { get; set; } + public string uid { get; set; } + public string org { get; set; } + public string mode { get; set; } + public bool WantsActive => Filter.Contains(FilterActiveOnly); + 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(); + public bool WantsOrg => org.NotEmpty(); + public bool WantsCollapsed => Filter.Contains(FilterCollapseTeams); + public bool WantsPending => Filter.Contains(NamePendingFilter); + public bool WantsDisallowed => Filter.Contains(NameDisallowedFilter); + public bool WantsSortByTime => Sort == SortTime; + public bool WantsSortByRank => Sort == SortRank || string.IsNullOrEmpty(Sort); + public bool WantsSortByName => Sort == SortName; + public bool WantsMode => Enum.TryParse(mode, true, out _); +} - public class PlayerCertificate - { - public Game Game { get; set; } - public Player Player { get; set; } - public string Html { get; set; } - } +public class BoardPlayer +{ + public string Id { get; set; } + public string TeamId { get; set; } + public string UserId { get; set; } + public string GameId { get; set; } + public string ApprovedName { get; set; } + public string Name { get; set; } + public string NameStatus { get; set; } + public string Sponsor { get; set; } + public PlayerRole Role { get; set; } + public PlayerMode Mode { get; set; } + public DateTimeOffset SessionBegin { get; set; } + public DateTimeOffset SessionEnd { get; set; } + public int SessionMinutes { get; set; } + public int Rank { get; set; } + public int Score { get; set; } + public long Time { get; set; } + public int CorrectCount { get; set; } + public int PartialCount { get; set; } + public BoardGame Game { get; set; } + public string ChallengeDocUrl { get; set; } + public ICollection Challenges { get; set; } = new List(); + public bool IsManager => Role == PlayerRole.Manager; + public bool IsPractice => Mode == PlayerMode.Practice; +} - public class PlayerUpdatedViewModel - { - public required string Id { get; set; } - public required string ApprovedName { get; set; } - public required string PreUpdateName { get; set; } - public required string Name { get; set; } - public required string NameStatus { get; set; } - } +public class PlayerCertificate +{ + public required DateTimeOffset? PublishedOn { get; set; } + public Game Game { get; set; } + public Player Player { get; set; } + public string Html { get; set; } +} +public class PlayerUpdatedViewModel +{ + public required string Id { get; set; } + public required string ApprovedName { get; set; } + public required string PreUpdateName { get; set; } + public required string Name { get; set; } + public required string NameStatus { get; set; } } diff --git a/src/Gameboard.Api/Features/Player/PlayerController.cs b/src/Gameboard.Api/Features/Player/PlayerController.cs index ae33a606..0e946a33 100644 --- a/src/Gameboard.Api/Features/Player/PlayerController.cs +++ b/src/Gameboard.Api/Features/Player/PlayerController.cs @@ -3,10 +3,11 @@ // 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; using Gameboard.Api.Features.Games; -using Gameboard.Api.Features.Player; using Gameboard.Api.Features.Teams; using Gameboard.Api.Services; using Gameboard.Api.Validators; @@ -22,7 +23,6 @@ namespace Gameboard.Api.Controllers public class PlayerController : _Controller { PlayerService PlayerService { get; } - IInternalHubBus Hub { get; } IMapper Mapper { get; } IMediator Mediator { get; } ITeamService TeamService { get; set; } @@ -33,13 +33,11 @@ public PlayerController( PlayerValidator validator, IMediator mediator, PlayerService playerService, - IInternalHubBus hub, IMapper mapper, ITeamService teamService ) : base(logger, cache, validator) { PlayerService = playerService; - Hub = hub; Mapper = mapper; Mediator = mediator; TeamService = teamService; @@ -49,18 +47,20 @@ ITeamService teamService /// Enrolls a user in a game. /// /// + /// /// A player record which represents an instance of the user playing a given game. [HttpPost("api/player")] [Authorize] - public async Task Enroll([FromBody] NewPlayer model) + public async Task Enroll([FromBody] NewPlayer model, CancellationToken cancellationToken) { - AuthorizeAny( + AuthorizeAny + ( () => Actor.IsRegistrar, () => model.UserId == Actor.Id ); await Validate(model); - return await PlayerService.Enroll(model, Actor); + return await PlayerService.Enroll(model, Actor, cancellationToken); } /// @@ -133,10 +133,11 @@ public async Task ResetSession([FromRoute] string playerId, [FromBody] S /// Change player session /// /// + /// /// [HttpPut("api/team/session")] [Authorize] - public async Task UpdateSession([FromBody] SessionChangeRequest model) + public async Task UpdateSession([FromBody] SessionChangeRequest model, CancellationToken cancellationToken) { await Validate(model); @@ -145,7 +146,7 @@ public async Task UpdateSession([FromBody] SessionChangeRequest model) () => IsSelf(model.TeamId).Result ); - await PlayerService.AdjustSessionEnd(model, Actor); + await PlayerService.AdjustSessionEnd(model, Actor, cancellationToken); } /// @@ -232,7 +233,8 @@ public async Task Scores([FromQuery] PlayerDataFilter model) [Authorize] public async Task> GetTeamChallenges([FromRoute] string id) { - AuthorizeAny( + AuthorizeAny + ( () => Actor.IsAdmin, () => Actor.IsDirector, () => Actor.IsObserver @@ -284,10 +286,7 @@ public async Task> ObserveTeams([FromRoute] string id) public async Task GetBoard([FromRoute] string id) { await Validate(new Entity { Id = id }); - - AuthorizeAny( - () => IsSelf(id).Result - ); + AuthorizeAny(() => IsSelf(id).Result); return await PlayerService.LoadBoard(id); } @@ -343,7 +342,8 @@ public async Task Enlist([FromBody] PlayerEnlistment model) [Authorize] public async Task PromoteToManager(string teamId, string playerId, [FromBody] PromoteToManagerRequest promoteRequest) { - AuthorizeAny( + AuthorizeAny + ( () => Actor.IsRegistrar, () => PlayerService.Retrieve(promoteRequest.CurrentManagerPlayerId).Result.UserId == Actor.Id ); diff --git a/src/Gameboard.Api/Features/Player/PlayerMapper.cs b/src/Gameboard.Api/Features/Player/PlayerMapper.cs index 78b3adb7..6240f569 100644 --- a/src/Gameboard.Api/Features/Player/PlayerMapper.cs +++ b/src/Gameboard.Api/Features/Player/PlayerMapper.cs @@ -3,7 +3,8 @@ using System.Collections.Generic; using AutoMapper; -using Gameboard.Api.Features.Player; +using Gameboard.Api.Common; +using Gameboard.Api.Features.Teams; namespace Gameboard.Api.Services; @@ -17,6 +18,8 @@ public PlayerMapper() CreateMap(); CreateMap(); CreateMap(); + CreateMap() + .ForMember(d => d.Name, opts => opts.MapFrom(p => p.ApprovedName)); CreateMap(); CreateMap(); CreateMap(); diff --git a/src/Gameboard.Api/Features/Player/PlayerService.cs b/src/Gameboard.Api/Features/Player/PlayerService.cs index cdf0b2c0..2b366a59 100644 --- a/src/Gameboard.Api/Features/Player/PlayerService.cs +++ b/src/Gameboard.Api/Features/Player/PlayerService.cs @@ -4,14 +4,17 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; 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; using Gameboard.Api.Features.Teams; -using MediatR; +using Gameboard.Api.Hubs; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; @@ -19,61 +22,62 @@ namespace Gameboard.Api.Services; public class PlayerService { + private readonly IPracticeChallengeScoringListener _practiceChallengeScoringListener; + private readonly TimeSpan _idmapExpiration = new(0, 30, 0); + private readonly INowService _now; + private readonly IPracticeService _practiceService; + CoreOptions CoreOptions { get; } ChallengeService ChallengeService { get; set; } IPlayerStore Store { get; } IGameService GameService { get; } IGameStore GameStore { get; } - IGameHubBus GameHubBus { get; set; } IGuidService GuidService { get; } - IMediator MediatorBus { get; } IInternalHubBus HubBus { get; } ITeamService TeamService { get; } - IUserStore UserStore { get; } IMapper Mapper { get; } IMemoryCache LocalCache { get; } - TimeSpan _idmapExpiration = new TimeSpan(0, 30, 0); - GameEngineService GameEngine { get; } + IGameEngineService GameEngine { get; } public PlayerService( - CoreOptions coreOptions, ChallengeService challengeService, + CoreOptions coreOptions, IGuidService guidService, - IMediator mediator, + INowService now, IPlayerStore store, - IUserStore userStore, - IGameHubBus gameHubBus, IGameService gameService, IGameStore gameStore, IInternalHubBus hubBus, + IPracticeChallengeScoringListener practiceChallengeScoringListener, + IPracticeService practiceService, ITeamService teamService, IMapper mapper, IMemoryCache localCache, - GameEngineService gameEngine + IGameEngineService gameEngine ) { - CoreOptions = coreOptions; ChallengeService = challengeService; + CoreOptions = coreOptions; GameService = gameService; GuidService = guidService; - MediatorBus = mediator; - GameHubBus = gameHubBus; + _practiceChallengeScoringListener = practiceChallengeScoringListener; + _practiceService = practiceService; + _now = now; HubBus = hubBus; Store = store; GameStore = gameStore; TeamService = teamService; - UserStore = userStore; Mapper = mapper; LocalCache = localCache; GameEngine = gameEngine; } - public async Task Enroll(NewPlayer model, User actor) + public async Task Enroll(NewPlayer model, User actor, CancellationToken cancellationToken) { var game = await GameStore.Retrieve(model.GameId); if (game.IsPracticeMode) - return await RegisterPracticeSession(model); + return await RegisterPracticeSession(model, cancellationToken); if (!actor.IsRegistrar && !game.RegistrationActive) throw new RegistrationIsClosed(model.GameId); @@ -169,9 +173,8 @@ public async Task ResetSession(SessionResetCommandArgs args) .Include(p => p.Game) .SingleAsync(p => p.Id == args.PlayerId); - // unlike unenroll, we archive the entire team's challenges, but only if this reset is manual and we're not unenrolling them - if (args.IsManualReset && !args.UnenrollTeam) - await ChallengeService.ArchiveTeamChallenges(player.TeamId); + // always archive challenges + await ChallengeService.ArchiveTeamChallenges(player.TeamId); // delete the entire team if requested if (args.UnenrollTeam) @@ -268,65 +271,65 @@ public async Task StartSession(SessionStartRequest model, User actor, bo await Store.DbContext.SaveChangesAsync(); } - var asViewModel = Mapper.Map(player); + var asViewModel = Mapper.Map(player); await HubBus.SendTeamSessionStarted(asViewModel, actor); return asViewModel; } - public async Task AdjustSessionEnd(SessionChangeRequest model, User actor) - { - var team = await Store.ListTeam(model.TeamId).ToArrayAsync(); - var sudo = actor.IsRegistrar; - - var manager = team.FirstOrDefault(p => - p.Role == PlayerRole.Manager - ); - - if (sudo.Equals(false) && manager.IsCompetition) - throw new ActionForbidden(); - - // auto increment for practice sessions - if (manager.IsPractice) - { - DateTimeOffset now = DateTimeOffset.UtcNow; - // end session now or extend by configured amount - model.SessionEnd = model.SessionEnd.Year == 1 - ? DateTimeOffset.UtcNow - : DateTimeOffset.UtcNow.AddMinutes(manager.SessionMinutes) - ; - if (CoreOptions.MaxPracticeSessionMinutes > 0) - { - var maxTime = manager.SessionBegin.AddMinutes(CoreOptions.MaxPracticeSessionMinutes); - if (model.SessionEnd > maxTime) - model.SessionEnd = maxTime; - } - } - - foreach (var player in team) - player.SessionEnd = model.SessionEnd; - - await Store.Update(team); - - // push gamespace extension - var changes = await Store.DbContext.Challenges - .Where(c => c.TeamId == manager.TeamId) - .Select(c => GameEngine.ExtendSession(c, model.SessionEnd)) - .ToArrayAsync(); - - await Task.WhenAll(changes); - - var mappedManager = Mapper.Map(manager); - await HubBus.SendTeamUpdated(mappedManager, actor); - return mappedManager; - } + // public async Task AdjustSessionEnd(SessionChangeRequest model, User actor, CancellationToken cancellationToken) + // { + // var team = await Store.ListTeam(model.TeamId).ToArrayAsync(cancellationToken); + // var sudo = actor.IsRegistrar; + + // var manager = team.FirstOrDefault(p => p.Role == PlayerRole.Manager); + + // if (sudo.Equals(false) && manager.IsCompetition) + // throw new ActionForbidden(); + + // // auto increment for practice sessions + // if (manager.IsPractice) + // { + // DateTimeOffset now = DateTimeOffset.UtcNow; + // var settings = await _practiceService.GetSettings(cancellationToken); + + // // end session now or extend by one hour (hard value for now, added to practice settings later) + // model.SessionEnd = model.SessionEnd.Year == 1 + // ? DateTimeOffset.UtcNow + // : DateTimeOffset.UtcNow.AddMinutes(60) + // ; + // if (settings.MaxPracticeSessionLengthMinutes.HasValue) + // { + // var maxTime = manager.SessionBegin.AddMinutes(settings.MaxPracticeSessionLengthMinutes.Value); + // if (model.SessionEnd > maxTime) + // model.SessionEnd = maxTime; + // } + // } + + // foreach (var player in team) + // player.SessionEnd = model.SessionEnd; + + // await Store.Update(team); + + // // push gamespace extension + // var changes = await Store.DbContext.Challenges + // .Where(c => c.TeamId == manager.TeamId) + // .Select(c => GameEngine.ExtendSession(c, model.SessionEnd)) + // .ToArrayAsync(); + + // await Task.WhenAll(changes); + + // var mappedManager = Mapper.Map(manager); + // await HubBus.SendTeamUpdated(mappedManager, actor); + // return mappedManager; + // } public async Task List(PlayerDataFilter model, bool sudo = false) { if (!sudo && !model.WantsGame && !model.WantsTeam) - return new Player[] { }; + return Array.Empty(); - var q = _List(model); + var q = BuildListQuery(model); return await Mapper.ProjectTo(q).ToArrayAsync(); } @@ -334,7 +337,7 @@ public async Task List(PlayerDataFilter model, bool sudo = false) public async Task Standings(PlayerDataFilter model) { if (model.gid.IsEmpty()) - return new Standing[] { }; + return Array.Empty(); model.Filter = model.Filter .Append(PlayerDataFilter.FilterScoredOnly) @@ -343,12 +346,12 @@ public async Task Standings(PlayerDataFilter model) model.mode = PlayerMode.Competition.ToString(); - var q = _List(model); + var q = BuildListQuery(model); return await Mapper.ProjectTo(q).ToArrayAsync(); } - private IQueryable _List(PlayerDataFilter model) + private IQueryable BuildListQuery(PlayerDataFilter model) { var ts = DateTimeOffset.UtcNow; @@ -397,8 +400,6 @@ public async Task Standings(PlayerDataFilter model) if (model.WantsScored) q = q.WhereIsScoringPlayer(); - - if (model.Term.NotEmpty()) { string term = model.Term.ToLower(); @@ -486,15 +487,13 @@ public async Task Enlist(PlayerEnlistment model, User actor) var player = await Store.DbSet.FirstOrDefaultAsync(p => p.Id == model.PlayerId); - if (player == null) + if (player is null) { throw new ResourceNotFound(model.PlayerId); } if (player.GameId != manager.GameId) - { throw new NotYetRegistered(player.Id, manager.GameId); - } if (manager is not Data.Player) throw new InvalidInvitationCode(model.Code, "Couldn't find the manager record."); @@ -671,11 +670,15 @@ public async Task AdvanceTeams(TeamAdvancement model) await Store.Update(allteams); } + public Task AdjustSessionEnd(SessionChangeRequest model, User actor, CancellationToken cancellationToken) + => _practiceChallengeScoringListener.AdjustSessionEnd(model, actor, cancellationToken); + public async Task MakeCertificate(string id) { var player = await Store.List() .Include(p => p.Game) .Include(p => p.User) + .ThenInclude(u => u.PublishedCompetitiveCertificates) .FirstOrDefaultAsync(p => p.Id == id); var playerCount = await Store.DbSet @@ -699,7 +702,9 @@ public async Task> MakeCertificates(string uid) var completedSessions = await Store.List() .Include(p => p.Game) .Include(p => p.User) - .Where( + .ThenInclude(u => u.PublishedCompetitiveCertificates) + .Where + ( p => p.UserId == uid && p.SessionEnd > DateTimeOffset.MinValue && p.Game.GameEnd < now && @@ -724,7 +729,7 @@ public async Task> MakeCertificates(string uid) )).ToArray(); } - private Api.PlayerCertificate CertificateFromTemplate(Data.Player player, int playerCount, int teamCount) + private PlayerCertificate CertificateFromTemplate(Data.Player player, int playerCount, int teamCount) { string certificateHTML = player.Game.CertificateTemplate; if (certificateHTML.IsEmpty()) @@ -745,18 +750,24 @@ private Api.PlayerCertificate CertificateFromTemplate(Data.Player player, int pl return new Api.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) + private async Task RegisterPracticeSession(NewPlayer model, CancellationToken cancellationToken) { + // load practice settings + var settings = await _practiceService.GetSettings(cancellationToken); + // check for existing sessions + var nowStamp = _now.Get(); + var players = await Store.DbContext.Players.Where(p => p.UserId == model.UserId && p.Mode == PlayerMode.Practice && - p.SessionEnd > DateTimeOffset.UtcNow + p.SessionEnd > nowStamp ).ToArrayAsync(); if (players.Any(p => p.GameId == model.GameId)) @@ -775,21 +786,20 @@ private async Task RegisterPracticeSession(NewPlayer model) throw new GamespaceLimitReached(); // don't exceed global configured limit - if (CoreOptions.MaxPracticeSessions > 0) + if (settings.MaxConcurrentPracticeSessions.HasValue) { int count = await Store.DbSet.CountAsync(p => p.Mode == PlayerMode.Practice && - p.SessionEnd > DateTimeOffset.UtcNow - ); + p.SessionEnd > nowStamp, cancellationToken); - if (count >= CoreOptions.MaxPracticeSessions) - throw new PracticeSessionLimitReached(model.UserId, count, CoreOptions.MaxPracticeSessions); + if (count >= settings.MaxConcurrentPracticeSessions.Value) + throw new PracticeSessionLimitReached(model.UserId, count, settings.MaxConcurrentPracticeSessions.Value); } - var entity = await InitializePlayer(model, CoreOptions.PracticeSessionMinutes); + var entity = await InitializePlayer(model, settings.DefaultPracticeSessionLengthMinutes); // start session - entity.SessionBegin = DateTimeOffset.UtcNow; + entity.SessionBegin = nowStamp; entity.SessionEnd = entity.SessionBegin.AddMinutes(entity.SessionMinutes); entity.Mode = PlayerMode.Practice; @@ -809,6 +819,7 @@ private async Task RegisterPracticeSession(NewPlayer model) entity.Name = user.ApprovedName; entity.Sponsor = user.Sponsor; entity.SessionMinutes = duration; + entity.WhenCreated = _now.Get(); return entity; } diff --git a/src/Gameboard.Api/Features/Player/PlayerStore.cs b/src/Gameboard.Api/Features/Player/PlayerStore.cs index 6c39d5a7..d6dc9e83 100644 --- a/src/Gameboard.Api/Features/Player/PlayerStore.cs +++ b/src/Gameboard.Api/Features/Player/PlayerStore.cs @@ -9,7 +9,7 @@ namespace Gameboard.Api.Data { - public class PlayerStore : Store, IPlayerStore + public class PlayerStore : Store, IPlayerStore { public PlayerStore(IGuidService guids, GameboardDbContext dbContext) : base(guids, dbContext) { } diff --git a/src/Gameboard.Api/Features/Player/PlayerValidator.cs b/src/Gameboard.Api/Features/Player/PlayerValidator.cs index 29469671..426340e3 100644 --- a/src/Gameboard.Api/Features/Player/PlayerValidator.cs +++ b/src/Gameboard.Api/Features/Player/PlayerValidator.cs @@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.Player; +using Gameboard.Api.Features.Teams; namespace Gameboard.Api.Validators { @@ -87,7 +88,7 @@ private async Task _validate(SessionChangeRequest model) { DateTimeOffset ts = DateTimeOffset.UtcNow; bool active = await _store.DbSet.AnyAsync(p => p.TeamId == model.TeamId && p.SessionEnd > ts); - if (active.Equals(false)) + if (model.SessionEnd > DateTimeOffset.MinValue && active.Equals(false)) throw new SessionNotAdjustable(); await Task.CompletedTask; diff --git a/src/Gameboard.Api/Features/Practice/GetPracticeModeSettings.cs b/src/Gameboard.Api/Features/Practice/GetPracticeModeSettings.cs new file mode 100644 index 00000000..37cf9161 --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/GetPracticeModeSettings.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Data; +using Gameboard.Api.Structure.MediatR.Authorizers; +using MediatR; + +namespace Gameboard.Api.Features.Practice; + +public record GetPracticeModeSettingsQuery(User ActingUser) : IRequest; + +internal class GetPracticeModeSettingsHandler : IRequestHandler +{ + private readonly IStore _store; + + public GetPracticeModeSettingsHandler(UserRoleAuthorizer roleAuthorizer, IStore store) + { + _store = store; + } + + public Task Handle(GetPracticeModeSettingsQuery request, CancellationToken cancellationToken) + { + return _store.FirstOrDefaultAsync(cancellationToken); + } +} diff --git a/src/Gameboard.Api/Features/Practice/PracticeChallengeScoringListener.cs b/src/Gameboard.Api/Features/Practice/PracticeChallengeScoringListener.cs new file mode 100644 index 00000000..dc15fbbe --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/PracticeChallengeScoringListener.cs @@ -0,0 +1,117 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Features.GameEngine; +using Gameboard.Api.Hubs; +using Gameboard.Api.Services; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Practice; + +public class ChallengeScoredEventArgs +{ + public required Data.Challenge Challenge { get; set; } +} + +public interface IPracticeChallengeScoringListener +{ + Task NotifyChallengeScored(Data.Challenge challenge, CancellationToken cancellationToken); + Task NotifyAttemptsExhausted(Data.Challenge challenge, CancellationToken cancellationToken); + Task AdjustSessionEnd(SessionChangeRequest model, User actor, CancellationToken cancellationToken); +} + +internal class PracticeChallengeScoringListener : IPracticeChallengeScoringListener +{ + private IActingUserService _actingUser; + private IGameEngineService _gameEngine; + private IInternalHubBus _hubBus; + private IMapper _mapper; + private IPlayerStore _playerStore; + private IPracticeService _practiceService; + + public PracticeChallengeScoringListener + ( + IActingUserService actingUser, + IGameEngineService gameEngine, + IInternalHubBus hubBus, + IMapper mapper, + IPlayerStore playerStore, + IPracticeService practiceService + ) + { + _actingUser = actingUser; + _gameEngine = gameEngine; + _hubBus = hubBus; + _mapper = mapper; + _playerStore = playerStore; + _practiceService = practiceService; + } + + public Task NotifyChallengeScored(Data.Challenge challenge, CancellationToken cancellationToken) + { + return AdjustSessionEnd(new SessionChangeRequest + { + TeamId = challenge.TeamId, + SessionEnd = DateTimeOffset.MinValue + }, _actingUser.Get(), cancellationToken); + } + + public Task NotifyAttemptsExhausted(Data.Challenge challenge, CancellationToken cancellationToken) + { + return AdjustSessionEnd(new SessionChangeRequest + { + TeamId = challenge.TeamId, + SessionEnd = DateTimeOffset.MinValue + }, _actingUser.Get(), cancellationToken); + } + + public async Task AdjustSessionEnd(SessionChangeRequest model, User actor, CancellationToken cancellationToken) + { + var team = await _playerStore.ListTeam(model.TeamId).ToArrayAsync(cancellationToken); + var sudo = actor.IsRegistrar; + + var manager = team.FirstOrDefault(p => p.Role == PlayerRole.Manager); + + if (sudo.Equals(false) && manager.IsCompetition) + throw new ActionForbidden(); + + // auto increment for practice sessions + if (manager.IsPractice) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var settings = await _practiceService.GetSettings(cancellationToken); + + // end session now or extend by one hour (hard value for now, added to practice settings later) + model.SessionEnd = model.SessionEnd.Year == 1 + ? DateTimeOffset.UtcNow + : DateTimeOffset.UtcNow.AddMinutes(60) + ; + if (settings.MaxPracticeSessionLengthMinutes.HasValue) + { + var maxTime = manager.SessionBegin.AddMinutes(settings.MaxPracticeSessionLengthMinutes.Value); + if (model.SessionEnd > maxTime) + model.SessionEnd = maxTime; + } + } + + foreach (var player in team) + player.SessionEnd = model.SessionEnd; + + await _playerStore.Update(team); + + // push gamespace extension + var changes = await _playerStore.DbContext.Challenges + .Where(c => c.TeamId == manager.TeamId) + .Select(c => _gameEngine.ExtendSession(c, model.SessionEnd)) + .ToArrayAsync(); + + await Task.WhenAll(changes); + + var mappedManager = _mapper.Map(manager); + await _hubBus.SendTeamUpdated(mappedManager, actor); + return mappedManager; + } +} diff --git a/src/Gameboard.Api/Features/Practice/PracticeController.cs b/src/Gameboard.Api/Features/Practice/PracticeController.cs new file mode 100644 index 00000000..48984e3c --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/PracticeController.cs @@ -0,0 +1,61 @@ +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; + +namespace Gameboard.Api.Features.Practice; + +[Authorize] +[Route("/api/practice")] +public class PracticeController : ControllerBase +{ + private readonly IActingUserService _actingUserService; + private readonly IHtmlToImageService _htmlToPdfService; + private readonly IMediator _mediator; + + public PracticeController + ( + IActingUserService actingUserService, + IHtmlToImageService htmlToPdfService, + IMediator mediator + ) + { + _actingUserService = actingUserService; + _htmlToPdfService = htmlToPdfService; + _mediator = mediator; + } + + /// + /// Search challenges within games that have been set to Practice mode. + /// + /// + /// + [HttpGet] + [AllowAnonymous] + public Task Browse([FromQuery] SearchFilter model) + => _mediator.Send(new SearchPracticeChallengesQuery(model)); + + [HttpGet] + [Route("challenges")] + public Task> ListChallenges() + => Task.FromResult(Array.Empty().AsEnumerable()); + + [HttpGet] + [Route("settings")] + public Task GetSettings() + => _mediator.Send(new GetPracticeModeSettingsQuery(_actingUserService.Get())); + + [HttpPut] + [Route("settings")] + public Task UpdateSettings([FromBody] UpdatePracticeModeSettings settings) + => _mediator.Send(new UpdatePracticeModeSettingsCommand(settings, _actingUserService.Get())); +} diff --git a/src/Gameboard.Api/Features/Practice/PracticeMaps.cs b/src/Gameboard.Api/Features/Practice/PracticeMaps.cs new file mode 100644 index 00000000..305cd8d1 --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/PracticeMaps.cs @@ -0,0 +1,13 @@ +using AutoMapper; +using Gameboard.Api.Data; + +namespace Gameboard.Api.Features.Practice; + +internal class PracticeMaps : Profile +{ + public PracticeMaps() + { + CreateMap(); + CreateMap(); + } +} diff --git a/src/Gameboard.Api/Features/Practice/PracticeModels.cs b/src/Gameboard.Api/Features/Practice/PracticeModels.cs new file mode 100644 index 00000000..fecb5ece --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/PracticeModels.cs @@ -0,0 +1,17 @@ +using Gameboard.Api.Common; + +namespace Gameboard.Api.Features.Practice; + +public sealed class SearchPracticeChallengesResult +{ + public required PagedEnumerable Results { get; set; } +} + +public sealed class UpdatePracticeModeSettings +{ + public required string CertificateHtmlTemplate { get; set; } + public required int DefaultPracticeSessionLengthMinutes { get; set; } + public required string IntroTextMarkdown { get; set; } + public int? MaxConcurrentPracticeSessions { get; set; } + public int? MaxPracticeSessionLengthMinutes { get; set; } +} diff --git a/src/Gameboard.Api/Features/Practice/PracticeService.cs b/src/Gameboard.Api/Features/Practice/PracticeService.cs new file mode 100644 index 00000000..b0f909fd --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/PracticeService.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Data; +using Gameboard.Api.Services; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Practice; + +public interface IPracticeService +{ + Task GetCanDeployChallenge(string userId, string challengeSpecId, CancellationToken cancellationToken); + Task GetSettings(CancellationToken cancellationToken); +} + +public enum CanPlayPracticeChallengeResult +{ + AlreadyPlayingThisChallenge, + TooManyActivePracticeSessions, + Yes +} + +internal class PracticeService : IPracticeService +{ + private readonly INowService _now; + private readonly IStore _store; + + public PracticeService(INowService now, IStore store) + { + _now = now; + _store = store; + } + + public async Task GetCanDeployChallenge(string userId, string challengeSpecId, CancellationToken cancellationToken) + { + // get settings + var settings = await GetSettings(cancellationToken); + + // ensure the global practice session count isn't maxed + if (settings.MaxConcurrentPracticeSessions.HasValue) + { + var activeSessionUsers = await GetActiveSessionUsers(); + if (activeSessionUsers.Count() >= settings.MaxConcurrentPracticeSessions.Value && !activeSessionUsers.Contains(userId)) + return CanPlayPracticeChallengeResult.TooManyActivePracticeSessions; + } + + return CanPlayPracticeChallengeResult.Yes; + } + + public Task GetSettings(CancellationToken cancellationToken) + => _store.SingleOrDefaultAsync(cancellationToken); + + private async Task> GetActiveSessionUsers() + => await GetActivePracticeSessionsQueryBase() + .Select(p => p.UserId) + .ToArrayAsync(); + + private IQueryable GetActivePracticeSessionsQueryBase() + => _store + .List() + .Where(p => p.SessionEnd > _now.Get()) + .Where(p => p.Mode == PlayerMode.Practice); + +} diff --git a/src/Gameboard.Api/Features/Practice/SearchPracticeChallenges.cs b/src/Gameboard.Api/Features/Practice/SearchPracticeChallenges.cs new file mode 100644 index 00000000..e0fdf777 --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/SearchPracticeChallenges.cs @@ -0,0 +1,64 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using Gameboard.Api.Common; +using Gameboard.Api.Data; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Practice; + +public record SearchPracticeChallengesQuery(SearchFilter Filter) : IRequest; + +internal class SearchPracticeChallengesHandler : IRequestHandler +{ + private readonly IMapper _mapper; + private readonly IPagingService _pagingService; + private readonly IStore _store; + + public SearchPracticeChallengesHandler(IMapper mapper, IPagingService pagingService, IStore store) + { + _mapper = mapper; + _pagingService = pagingService; + _store = store; + } + + public async Task Handle(SearchPracticeChallengesQuery request, CancellationToken cancellationToken) + { + var q = _store + .List() + .Include(s => s.Game) + .Where(s => s.Game.PlayerMode == PlayerMode.Practice); + + if (request.Filter.HasTerm) + { + var term = request.Filter.Term.ToLower(); + q = q.Where(s => + s.Id.Equals(term) || + s.Name.ToLower().Contains(term) || + s.Description.ToLower().Contains(term) || + s.Game.Name.ToLower().Contains(term) || + s.Text.ToLower().Contains(term) + ); + } + + q = q.OrderBy(s => s.Name); + var results = await _mapper.ProjectTo(q).ToArrayAsync(cancellationToken); + + // resolve paging arguments + var pageSize = request.Filter.Take > 0 ? request.Filter.Take : 100; + var pageNumber = request.Filter.Skip / pageSize; + + var pagedResults = _pagingService.Page(results, new PagingArgs + { + PageNumber = pageNumber, + PageSize = pageSize + }); + + return new SearchPracticeChallengesResult + { + Results = pagedResults + }; + } +} diff --git a/src/Gameboard.Api/Features/Practice/StartPracticeChallenge.cs b/src/Gameboard.Api/Features/Practice/StartPracticeChallenge.cs new file mode 100644 index 00000000..ddd366a5 --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/StartPracticeChallenge.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Gameboard.Api.Features.Practice; + +public record StartPracticeChallengeCommand(string ChallengeSpecId, User ActingUser) : IRequest; + +internal class StartPracticeChallengeHandler : IRequestHandler +{ + + public Task Handle(StartPracticeChallengeCommand request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} diff --git a/src/Gameboard.Api/Features/Practice/UpdatePracticeModeSettings.cs b/src/Gameboard.Api/Features/Practice/UpdatePracticeModeSettings.cs new file mode 100644 index 00000000..2a2e041d --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/UpdatePracticeModeSettings.cs @@ -0,0 +1,104 @@ +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using Gameboard.Api.Data; +using Gameboard.Api.Services; +using Gameboard.Api.Structure; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Authorizers; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; + +namespace Gameboard.Api.Features.Practice; + +internal class PracticeModeSettingsInvalid : GameboardValidationException +{ + public PracticeModeSettingsInvalid(string settingName, string settingValue, string description) + : base($"""Practice Area setting "{settingName}" with value "{settingValue}" is invalid. {description}""") { } +} + +internal class UpdatePracticeModeSettingsValidator : IGameboardRequestValidator +{ + private readonly UserRoleAuthorizer _roleAuthorizer; + private readonly EntityExistsValidator _userExists; + private readonly IValidatorService _validatorService; + + public UpdatePracticeModeSettingsValidator + ( + UserRoleAuthorizer roleAuthorizer, + EntityExistsValidator userExists, + IValidatorService validatorService + ) + { + _roleAuthorizer = roleAuthorizer; + _userExists = userExists; + _validatorService = validatorService; + } + + public async Task Validate(UpdatePracticeModeSettingsCommand request) + { + _roleAuthorizer.AllowedRoles = new UserRole[] { UserRole.Admin }; + _roleAuthorizer.Authorize(); + + _validatorService.AddValidator((request, context) => + { + if (request.Settings.MaxConcurrentPracticeSessions.HasValue && request.Settings.MaxConcurrentPracticeSessions <= 0) + context.AddValidationException(new PracticeModeSettingsInvalid(nameof(PracticeModeSettings.MaxConcurrentPracticeSessions), request.Settings.MaxConcurrentPracticeSessions.Value.ToString(), "Max concurrent practice sessions must be either null or non-negative.")); + + return Task.CompletedTask; + }); + + _validatorService.AddValidator((request, context) => + { + if (request.Settings.MaxPracticeSessionLengthMinutes.HasValue && request.Settings.MaxPracticeSessionLengthMinutes <= 0) + context.AddValidationException(new PracticeModeSettingsInvalid(nameof(PracticeModeSettings.MaxPracticeSessionLengthMinutes), request.Settings.MaxPracticeSessionLengthMinutes.Value.ToString(), "Max practice session length must be either null or non-negative.")); + + return Task.CompletedTask; + }); + + _validatorService.AddValidator(_userExists.UseProperty(r => r.ActingUser.Id)); + + await _validatorService.Validate(request); + } +} + +public record UpdatePracticeModeSettingsCommand(UpdatePracticeModeSettings Settings, User ActingUser) : IRequest; + +internal class UpdatePracticeModeSettingsHandler : IRequestHandler +{ + private readonly IMapper _mapper; + private readonly INowService _now; + private readonly IStore _store; + private readonly UpdatePracticeModeSettingsValidator _validator; + + public UpdatePracticeModeSettingsHandler + ( + IMapper mapper, + INowService now, + IStore store, + UpdatePracticeModeSettingsValidator validator + ) + { + _mapper = mapper; + _now = now; + _store = store; + _validator = validator; + } + + public async Task Handle(UpdatePracticeModeSettingsCommand request, CancellationToken cancellationToken) + { + await _validator.Validate(request); + + var currentSettings = await _store.FirstOrDefaultAsync(cancellationToken); + var updatedSettings = _mapper.Map(request.Settings); + updatedSettings.Id = currentSettings.Id; + updatedSettings.UpdatedOn = _now.Get(); + updatedSettings.UpdatedByUserId = request.ActingUser.Id; + + // force a value for default session length, becaues it's required + if (updatedSettings.DefaultPracticeSessionLengthMinutes <= 0) + updatedSettings.DefaultPracticeSessionLengthMinutes = 60; + + await _store.Update(updatedSettings, cancellationToken); + } +} diff --git a/src/Gameboard.Api/Features/Report/IReportStore.cs b/src/Gameboard.Api/Features/Report/IReportStore.cs deleted file mode 100644 index 5cec915d..00000000 --- a/src/Gameboard.Api/Features/Report/IReportStore.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Gameboard.Api.Services -{ - internal interface IReportStore - { - - } -} diff --git a/src/Gameboard.Api/Features/Report/ReportModels.cs b/src/Gameboard.Api/Features/Report/ReportModels.cs deleted file mode 100644 index 04b675e7..00000000 --- a/src/Gameboard.Api/Features/Report/ReportModels.cs +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright 2021 Carnegie Mellon University. All Rights Reserved. -// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. - -using System; - -namespace Gameboard.Api -{ - public class SponsorReport - { - public string Title { get; set; } = "Sponsor Report"; - public DateTime Timestamp { get; set; } - public SponsorStat[] Stats { get; set; } - } - - public class GameSponsorReport - { - public string Title { get; set; } = "Game Sponsor Report"; - public DateTime Timestamp { get; set; } - public GameSponsorStat[] Stats { get; set; } - } - - public class UserReport - { - public string Title { get; set; } = "User Report"; - public DateTime Timestamp { get; set; } - public int EnrolledUserCount { get; set; } - public int UnenrolledUserCount { get; set; } - } - - public class PlayerReport - { - public string Title { get; set; } = "Player Report"; - public DateTime Timestamp { get; set; } - public PlayerStat[] Stats { get; set; } - } - - public class PlayerStat - { - public string GameId { get; set; } - public string GameName { get; set; } - public int PlayerCount { get; set; } - public int SessionPlayerCount { get; set; } - } - - public class SponsorStat - { - public string Id { get; set; } - public string Name { get; set; } - public string Logo { get; set; } - public int Count { get; set; } - public int TeamCount { get; set; } - } - - public class GameSponsorStat - { - public string GameId { get; set; } - public string GameName { get; set; } - public SponsorStat[] Stats { get; set; } - } - - public class ChallengeReport - { - public string Title { get; set; } = "Challenge Report"; - public DateTimeOffset Timestamp { get; set; } - public ChallengeStat[] Stats { get; set; } - } - - public class ChallengeStat - { - public string Id { get; set; } - public string Name { get; set; } - public string Tag { get; set; } - public int Points { get; set; } - public int SuccessCount { get; set; } - public int PartialCount { get; set; } - public string AverageTime { get; set; } - public int AttemptCount { get; set; } - public int AverageScore { get; set; } - } - - public class ChallengeDetailReport - { - public string Title { get; set; } = "Challenge Detail Report"; - public DateTime Timestamp { get; set; } - public Part[] Parts { get; set; } - public int AttemptCount { get; set; } - public string ChallengeId { get; set; } - } - - #region Ticket Reports - public class TicketDetail { - public int Key { get; set; } - public string Summary { get; set; } - public string Description { get; set; } - public string Challenge { get; set; } - public string GameSession { get; set; } - public string Team { get; set; } - public string Assignee { get; set; } - public string Requester { get; set; } - public string Creator { get; set; } - public DateTimeOffset Created { get; set; } - public DateTimeOffset LastUpdated { get; set; } - public string Label { get; set; } - public string Status { get; set; } - } - - /*public class TicketDetailReport - { - public string Title { get; set; } = "Ticket Detail Report"; - public DateTime Timestamp { get; set; } - public TicketDetail[] Details { get; set; } - }*/ - #endregion - - public class ParticipationReport - { - public string Key { get; set; } = "Participation"; - public DateTime Timestamp { get; set; } - public ParticipationStat[] Stats { get; set; } - } - - public class ParticipationStat - { - public string Key { get; set; } - public int GameCount { get; set; } - public int PlayerCount { get; set; } - public int SessionPlayerCount { get; set; } - public int TeamCount { get; set; } - public int SessionTeamCount { get; set; } - public int ChallengesDeployedCount { get; set; } - } - - public class SeriesReport : ParticipationReport - { - public SeriesReport() { - Key = "Series"; - } - } - - public class TrackReport : ParticipationReport - { - public TrackReport() { - Key = "Track"; - } - } - - public class SeasonReport : ParticipationReport - { - public SeasonReport() { - Key = "Season"; - } - } - - public class DivisionReport : ParticipationReport - { - public DivisionReport() { - Key = "Division"; - } - } - - public class ModeReport : ParticipationReport - { - public ModeReport() { - Key = "Mode"; - } - } - - public class CorrelationReport - { - public string Title { get; set; } = "Correlation Report"; - public DateTime Timestamp { get; set; } - public CorrelationStat[] Stats { get; set; } - } - - public class CorrelationStat - { - public int GameCount { get; set; } - public int UserCount { get; set; } - } - - public class Part - { - public string Text { get; set; } - public int SolveCount { get; set; } - public int AttemptCount { get; set; } - public float Weight { get; set; } - } - - public class ChallengeStatsExport - { - public string GameName { get; set; } - public string ChallengeName { get; set; } - public string Tag { get; set; } - public string Points { get; set; } - public string Attempts { get; set; } - public string Complete { get; set; } - public string Partial { get; set; } - public string AvgTime { get; set; } - public string AvgScore { get; set; } - } - - public class ChallengeDetailsExport - { - public string GameName { get; set; } - public string ChallengeName { get; set; } - public string Tag { get; set; } - public string Question { get; set; } - public string Points { get; set; } - public string Solves { get; set; } - } - - public class TicketDetailsExport { - public string Key { get; set; } - public string Summary { get; set; } - public string Description { get; set; } - public string Challenge { get; set; } - public string GameSession { get; set; } - public string Team { get; set; } - public string Assignee { get; set; } - public string Requester { get; set; } - public string Creator { get; set; } - public string Created { get; set; } - public string LastUpdated { get; set; } - public string Label { get; set; } - public string Status { get; set; } - } -} diff --git a/src/Gameboard.Api/Features/Report/ReportStore.cs b/src/Gameboard.Api/Features/Report/ReportStore.cs deleted file mode 100644 index 70b4ae1b..00000000 --- a/src/Gameboard.Api/Features/Report/ReportStore.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Gameboard.Api.Controllers -{ - -} diff --git a/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportExportQuery.cs b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportExportQuery.cs new file mode 100644 index 00000000..060acdfa --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportExportQuery.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using MediatR; + +namespace Gameboard.Api.Features.Reports; + +public record ChallengesReportExportQuery(GetChallengesReportQueryArgs Parameters) : IRequest>; + +public class ChallengesReportExportQueryHandler : IRequestHandler> +{ + private readonly IMapper _mapper; + private readonly IReportsService _reportsService; + + public ChallengesReportExportQueryHandler(IMapper mapper, IReportsService reportsService) + { + _mapper = mapper; + _reportsService = reportsService; + } + + public async Task> Handle(ChallengesReportExportQuery request, CancellationToken cancellationToken) + { + var results = await _reportsService.GetChallengesReportRecords(request.Parameters); + return _mapper.Map>(results); + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportModels.cs b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportModels.cs new file mode 100644 index 00000000..8033945d --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportModels.cs @@ -0,0 +1,105 @@ +using System; +using Gameboard.Api.Common; + +namespace Gameboard.Api.Features.Reports; + +public class GetChallengesReportQueryArgs +{ + public string ChallengeSpecId { get; set; } + public string Competition { get; set; } + public string GameId { get; set; } + public DateTimeOffset RegistrationStart { get; set; } + public DateTimeOffset RegistrationEnd { get; set; } + public string TrackName { get; set; } +} + +public class PlayerTime +{ + public required SimpleEntity Player { get; set; } + public TimeSpan Time { get; set; } +} + +internal class ChallengesReportPlayerEngagement +{ + public required SimpleEntity Player { get; set; } + public required SimpleEntity Game { get; set; } + public required SimpleEntity Challenge { get; set; } + public required bool IsRegisteredForGame { get; set; } + public required bool IsChallengeStarted { get; set; } + public required bool IsChallengeDeployed { get; set; } +} + +internal class ChallengesReportMeanChallengeStats +{ + public required double? MeanCompleteSolveTimeMs { get; set; } + public required double? MeanScore { get; set; } +} + +internal class ChallengesReportPlayer +{ + public required SimpleEntity Player { get; set; } + public required Nullable SolveTimeMs { get; set; } + public required DateTimeOffset StartTime { get; set; } + public required DateTimeOffset EndTime { get; set; } + public required ChallengeResult Result { get; set; } + public required int Score { get; set; } +} + +internal class ChallengesReportChallenge +{ + public required SimpleEntity Challenge { get; set; } + public required SimpleEntity Game { get; set; } + public required string SpecId { get; set; } + public required ChallengesReportPlayer Player { get; set; } + public required int TicketCount { get; set; } +} + +internal class ChallengesReportSpec +{ + public required string Id { get; set; } + public required SimpleEntity Game { get; set; } + public required string Name { get; set; } + public required double MaxPoints { get; set; } +} + +public class ChallengesReportPlayerSolve +{ + public required SimpleEntity Player { get; set; } + public required double SolveTimeMs { get; set; } +} + +public class ChallengesReportRecord +{ + public required SimpleEntity ChallengeSpec { get; set; } + public required SimpleEntity Challenge { get; set; } + public required SimpleEntity Game { get; set; } + public required int PlayersEligible { get; set; } + public required int PlayersStarted { get; set; } + public required int PlayersWithPartialSolve { get; set; } + public required int PlayersWithCompleteSolve { get; set; } + public required ChallengesReportPlayerSolve FastestSolve { get; set; } + public required double MaxPossibleScore { get; set; } + public required double? MeanScore { get; set; } + public required double? MeanCompleteSolveTimeMs { get; set; } + public required int TicketCount { get; set; } +} + +public class ChallengesReportCsvRecord +{ + public required string ChallengeSpecId { get; set; } + public required string ChallengeSpecName { get; set; } + public required string GameId { get; set; } + public required string GameName { get; set; } + public required int PlayersEligible { get; set; } + public required int PlayersStarted { get; set; } + public required int PlayersWithPartialSolve { get; set; } + public required int PlayersWithCompleteSolve { get; set; } + public required string FastestSolvePlayerId { get; set; } + public required string FastestSolvePlayerName { get; set; } + public required string FastestSolveTimeMs { get; set; } + public required double MaxPossibleScore { get; set; } + public required double? MeanScore { get; set; } + public required double? MeanCompleteSolveTimeMs { get; set; } + public required int TicketCount { get; set; } +} + diff --git a/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportQuery.cs b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportQuery.cs new file mode 100644 index 00000000..50d024dd --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportQuery.cs @@ -0,0 +1,37 @@ +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Services; +using MediatR; + +namespace Gameboard.Api.Features.Reports; + +public record ChallengesReportQuery(GetChallengesReportQueryArgs Args) : IRequest>; + +public class ChallengeReportQueryHandler : IRequestHandler> +{ + private readonly INowService _now; + private readonly IReportsService _reportsService; + + public ChallengeReportQueryHandler(INowService now, IReportsService reportsService) + { + _now = now; + _reportsService = reportsService; + } + + public async Task> Handle(ChallengesReportQuery request, CancellationToken cancellationToken) + { + var results = await _reportsService.GetChallengesReportRecords(request.Args); + + return new ReportResults + { + MetaData = new ReportMetaData + { + Key = ReportKey.Challenges, + Title = "Challenge Report", + RunAt = _now.Get() + }, + Paging = null, + Records = results + }; + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReport.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReport.cs new file mode 100644 index 00000000..3e4ce9f4 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReport.cs @@ -0,0 +1,47 @@ +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common; +using Gameboard.Api.Services; +using MediatR; + +namespace Gameboard.Api.Features.Reports; + +public record EnrollmentReportQuery(EnrollmentReportParameters Parameters, PagingArgs PagingArgs) : IRequest>; + +internal class EnrollmentReportQueryHandler : IRequestHandler> +{ + private readonly IEnrollmentReportService _enrollmentReportService; + private readonly INowService _now; + private readonly IPagingService _pagingService; + + public EnrollmentReportQueryHandler + ( + IEnrollmentReportService enrollmentReportService, + INowService now, + IPagingService pagingService + ) + { + _enrollmentReportService = enrollmentReportService; + _now = now; + _pagingService = pagingService; + } + + public async Task> Handle(EnrollmentReportQuery request, CancellationToken cancellationToken) + { + var rawResults = await _enrollmentReportService.GetRawResults(request.Parameters, cancellationToken); + var paged = _pagingService.Page(rawResults.Records, request.PagingArgs); + + return new ReportResults + { + MetaData = new ReportMetaData + { + Title = "Enrollment Report", + RunAt = _now.Get(), + Key = ReportKey.Enrollment + }, + OverallStats = rawResults.StatSummary, + Records = paged.Items, + Paging = paged.Paging + }; + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportExport.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportExport.cs new file mode 100644 index 00000000..4ff57caa --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportExport.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Gameboard.Api.Features.Reports; + +public record EnrollmentReportExportQuery(EnrollmentReportParameters Parameters) : IRequest>; + +internal class EnrollmentReportExportHandler : IRequestHandler> +{ + private readonly IEnrollmentReportService _enrollmentReportService; + + public EnrollmentReportExportHandler(IEnrollmentReportService enrollmentReportService) + { + _enrollmentReportService = enrollmentReportService; + } + + public async Task> Handle(EnrollmentReportExportQuery request, CancellationToken cancellationToken) + { + // ignore paging parameters - for file export, we don't page + var results = await _enrollmentReportService.GetRawResults(request.Parameters, cancellationToken); + + return results.Records.Select(r => new EnrollmentReportCsvRecord + { + // user + UserId = r.User.Id, + UserName = r.User.Name, + + // player + PlayerId = r.Player.Id, + PlayerName = r.Player.Name, + PlayerEnrollDate = r.Player.EnrollDate, + PlayerSponsor = r.Player.Sponsor.Name, + + // game + GameId = r.Game.Id, + GameName = r.Game.Name, + IsTeamGame = r.Game.IsTeamGame, + Series = r.Game.Series, + Season = r.Game.Season, + Track = r.Game.Track, + + // session + PlayStart = r.PlayTime.Start, + PlayEnd = r.PlayTime.End, + PlayDurationInSeconds = r.PlayTime.DurationMs != null ? Math.Round(new Decimal((double)r.PlayTime.DurationMs) / 1000, 2) : null, + + // team + TeamId = r.Team?.Id, + TeamName = r.Team?.Name, + CaptainPlayerId = r.Team?.CurrentCaptain?.Id, + CaptainPlayerName = r.Team?.CurrentCaptain?.Name, + TeamSponsors = r.Team?.Sponsors?.Count() > 0 ? string.Join(", ", r.Team.Sponsors.Select(s => s.Name)) : null, + + // challenges + Challenges = string.Join(", ", r.Challenges.Select(c => $"{c.Name} ({c.SpecId[..5]})")), + FirstDeployDate = r.Challenges.Min(c => c.DeployDate), + FirstStartDate = r.Challenges.Min(c => c.StartDate), + LastEndDate = r.Challenges.Min(c => c.EndDate), + MinDurationInSeconds = r.Challenges.All(c => c.DurationMs == null) ? + null : + Math.Round((double)r.Challenges.Min(c => c.DurationMs) / 1000, 2), + MaxDurationInSeconds = r.Challenges.All(c => c.DurationMs == null) ? + null : + Math.Round((double)r.Challenges.Max(c => c.DurationMs) / 1000, 2), + ChallengeScores = string.Join(",", r.Challenges.Select(c => $"{c.Score}/{c.MaxPossiblePoints}")), + + // challenge/game performance summary + ChallengesAttempted = r.Challenges.Count(), + ChallengesPartiallySolvedCount = r.Challenges.Where(c => c.Result == ChallengeResult.Partial).Count(), + ChallengesCompletelySolvedCount = r.Challenges.Where(c => c.Result == ChallengeResult.Success).Count(), + Score = r.Score + }); + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportLineChart.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportLineChart.cs new file mode 100644 index 00000000..bc0f8f99 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportLineChart.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common; +using Gameboard.Api.Data; +using Gameboard.Api.Structure.MediatR.Authorizers; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Reports; + +public record EnrollmentReportLineChartQuery(EnrollmentReportParameters Parameters) : IRequest>; + +internal class EnrollmentReportLineChartHandler : IRequestHandler> +{ + private readonly UserRoleAuthorizer _authorizer; + private readonly IEnrollmentReportService _reportService; + private readonly EnrollmentReportValidator _validator; + + public EnrollmentReportLineChartHandler + ( + UserRoleAuthorizer authorizer, + IEnrollmentReportService reportService, + EnrollmentReportValidator validator + ) + { + _authorizer = authorizer; + _reportService = reportService; + _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 _validator.Validate(request.Parameters); + + // pull base query but select only what we need + var query = await _reportService.GetBaseQuery(request.Parameters, cancellationToken); + var results = await query.Select(p => new EnrollmentReportLineChartPlayer + { + Id = p.Id, + Name = p.ApprovedName, + EnrollDate = p.WhenCreated, + Game = new SimpleEntity { Id = p.GameId, Name = p.Game.Id }, + }) + .WhereDateIsNotEmpty(p => p.EnrollDate) + .OrderBy(p => p.EnrollDate) + .ToListAsync(cancellationToken); + + // grouping stuff + var totalEnrolledPlayerCount = 0; + var retVal = new Dictionary(); + + foreach (var grouping in results.GroupBy(p => new DateTimeOffset(p.EnrollDate.Year, p.EnrollDate.Month, p.EnrollDate.Day, 0, 0, 0, p.EnrollDate.Offset))) + { + totalEnrolledPlayerCount += grouping.Count(); + retVal[grouping.Key] = new EnrollmentReportLineChartGroup + { + Players = grouping, + TotalCount = totalEnrolledPlayerCount + }; + } + + return retVal; + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportModels.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportModels.cs new file mode 100644 index 00000000..d565d1c3 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportModels.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using Gameboard.Api.Common; + +namespace Gameboard.Api.Features.Reports; + +public class EnrollmentReportParameters +{ + public DateTimeOffset? EnrollDateStart { get; set; } + public DateTimeOffset? EnrollDateEnd { get; set; } + public string Games { get; set; } + public string Seasons { get; set; } + public string Series { get; set; } + public string Sponsors { get; set; } + public string Tracks { get; set; } +} + +public class EnrollmentReportRecord +{ + public required SimpleEntity User { get; set; } + public required EnrollmentReportPlayerViewModel Player { get; set; } + public required ReportGameViewModel Game { get; set; } + public required EnrollmentReportPlayTimeViewModel PlayTime { get; set; } + public required EnrollmentReportTeamViewModel Team { get; set; } + + // performance data + public required IEnumerable Challenges { get; set; } + public required int ChallengesPartiallySolvedCount { get; set; } + public required int ChallengesCompletelySolvedCount { get; set; } + public required double Score { get; set; } +} + +public sealed class EnrollmentReportStatSummary +{ + public required int DistinctGameCount { get; set; } + public required int DistinctPlayerCount { get; set; } + public required int DistinctSponsorCount { get; set; } + public required EnrollmentReportStatSummarySponsorPlayerCount SponsorWithMostPlayers { get; set; } + public required int DistinctTeamCount { get; set; } +} + +public sealed class EnrollmentReportStatSummarySponsorPlayerCount +{ + public required ReportSponsorViewModel Sponsor { get; set; } + public required int DistinctPlayerCount { get; set; } +} + +public sealed class EnrollmentReportRawResults +{ + public required IEnumerable Records { get; set; } + public required EnrollmentReportStatSummary StatSummary { get; set; } +} + +public class EnrollmentReportPlayerViewModel +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required DateTimeOffset? EnrollDate { get; set; } + public required ReportSponsorViewModel Sponsor { get; set; } +} + +public class EnrollmentReportPlayTimeViewModel +{ + public required DateTimeOffset? Start { get; set; } + public required DateTimeOffset? End { get; set; } + public required double? DurationMs { get; set; } +} + +public class EnrollmentReportTeamViewModel +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required SimpleEntity CurrentCaptain { get; set; } + public required IEnumerable Sponsors { get; set; } +} + +public class EnrollmentReportChallengeViewModel +{ + public required string Name { get; set; } + public required string SpecId { get; set; } + public required DateTimeOffset? DeployDate { get; set; } + public required DateTimeOffset? StartDate { get; set; } + public required DateTimeOffset? EndDate { get; set; } + public required double? DurationMs { get; set; } + public required IEnumerable ManualChallengeBonuses { get; set; } + public required double? Score { get; set; } + public required double? MaxPossiblePoints { get; set; } + public required ChallengeResult Result { get; set; } +} + +public class EnrollmentReportManualChallengeBonus +{ + public required string Description { get; set; } + public required double Points { get; set; } +} + +public sealed class EnrollmentReportLineChartGroup +{ + public required IEnumerable Players { get; set; } + public required int TotalCount { get; set; } +} + +public sealed class EnrollmentReportLineChartPlayer +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required DateTimeOffset EnrollDate { get; set; } + public required SimpleEntity Game { get; set; } +} + +// this class isn't sent down with the report data, it's just used as an intermediate +// while querying to minimize the amount we pull back from the db +internal class EnrollmentReportChallengeQueryData +{ + public required string SpecId { get; set; } + public required string Name { get; set; } + public required DateTimeOffset WhenCreated { get; set; } + public required DateTimeOffset StartTime { get; set; } + public required DateTimeOffset EndTime { get; set; } + public required IEnumerable ManualChallengeBonuses { get; set; } + public required double? Score { get; set; } + public required double MaxPossiblePoints { get; set; } +} + +public class EnrollmentReportCsvRecord +{ + // user + public required string UserId { get; set; } + public required string UserName { get; set; } + + // player + public required string PlayerId { get; set; } + public required string PlayerName { get; set; } + public required DateTimeOffset? PlayerEnrollDate { get; set; } + public required string PlayerSponsor { get; set; } + + // game + public required string GameId { get; set; } + public required string GameName { get; set; } + public required bool IsTeamGame { get; set; } + public required string Series { get; set; } + public required string Season { get; set; } + public required string Track { get; set; } + + // session + public required DateTimeOffset? PlayStart { get; set; } + public required DateTimeOffset? PlayEnd { get; set; } + public required decimal? PlayDurationInSeconds { get; set; } + + // team data + public required string TeamId { get; set; } + public required string TeamName { get; set; } + public required string CaptainPlayerId { get; set; } + public required string CaptainPlayerName { get; set; } + public required string TeamSponsors { get; set; } + + // challenges (denormalizing fields) + public required string Challenges { get; set; } + public required DateTimeOffset? FirstDeployDate { get; set; } + public required DateTimeOffset? FirstStartDate { get; set; } + public required DateTimeOffset? LastEndDate { get; set; } + public required double? MinDurationInSeconds { get; set; } + public required double? MaxDurationInSeconds { get; set; } + public required string ChallengeScores { get; set; } + + // challenge / game performance summary + public required int ChallengesAttempted { get; set; } + public required int ChallengesPartiallySolvedCount { get; set; } + public required int ChallengesCompletelySolvedCount { get; set; } + public required double Score { get; set; } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs new file mode 100644 index 00000000..32526778 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Data; +using Gameboard.Api.Common; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Reports; + +public interface IEnrollmentReportService +{ + Task> GetBaseQuery(EnrollmentReportParameters parameters, CancellationToken cancellationToken); + Task GetRawResults(EnrollmentReportParameters parameters, CancellationToken cancellationToken); +} + +internal class EnrollmentReportService : IEnrollmentReportService +{ + private readonly IReportsService _reportsService; + private readonly IStore _store; + + public EnrollmentReportService + ( + IReportsService reportsService, + IStore store + ) + { + _reportsService = reportsService; + _store = store; + } + + public async Task> GetBaseQuery(EnrollmentReportParameters parameters, CancellationToken cancellationToken) + { + // parse multiselect criteria + var gamesCriteria = _reportsService.ParseMultiSelectCriteria(parameters.Games); + var seasonCriteria = _reportsService.ParseMultiSelectCriteria(parameters.Seasons); + var seriesCriteria = _reportsService.ParseMultiSelectCriteria(parameters.Series); + var sponsorCriteria = _reportsService.ParseMultiSelectCriteria(parameters.Sponsors); + var trackCriteria = _reportsService.ParseMultiSelectCriteria(parameters.Tracks); + DateTimeOffset? enrollDateStart = parameters.EnrollDateStart.HasValue ? parameters.EnrollDateStart.Value.ToUniversalTime() : null; + 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 + var query = _store + .List() + .Include(p => p.Game) + .Include(p => p.User) + .Include(p => p.Challenges) + .ThenInclude(c => c.AwardedManualBonuses) + .Where(p => p.Game.PlayerMode == PlayerMode.Competition); + + if (enrollDateStart != null) + query = query + .WhereDateIsNotEmpty(p => p.WhenCreated) + .Where(p => p.WhenCreated >= enrollDateStart); + + if (enrollDateEnd != null) + query = query + .WhereDateIsNotEmpty(p => p.WhenCreated) + .Where(p => p.WhenCreated <= enrollDateEnd); + + if (gamesCriteria.Any()) + query = query.Where(p => gamesCriteria.Contains(p.GameId)); + + if (seasonCriteria.Any()) + query = query.Where(p => seasonCriteria.Contains(p.Game.Season.ToLower())); + + if (seriesCriteria.Any()) + query = query.Where(p => seriesCriteria.Contains(p.Game.Competition.ToLower())); + + if (trackCriteria.Any()) + query = query.Where(p => trackCriteria.Contains(p.Game.Track.ToLower())); + + if (sponsorCriteria.Any()) + { + var sponsors = await _store + .List() + .Where(s => sponsorCriteria.Contains(s.Id)) + .Select(s => s.Logo) + .ToArrayAsync(cancellationToken); + + query = query.Where(p => sponsors.Contains(p.Sponsor)); + } + + return query; + } + + public async Task GetRawResults(EnrollmentReportParameters parameters, CancellationToken cancellationToken) + { + // load query + var query = await GetBaseQuery(parameters, cancellationToken); + + // finalize query - we have to do the rest "client" (application server) side + var players = await query.ToListAsync(cancellationToken); + + // look up sponsors to build the result set + var sponsors = await _store + .List() + .Select(s => new ReportSponsorViewModel + { + Id = s.Id, + Name = s.Name, + LogoFileName = s.Logo + }) + .ToArrayAsync(cancellationToken); + + // This is pretty messy. Here's why: + // + // Teams are not first-class entities in the data model as of now. There's a teamId + // on the player record which is always populated (even if the game is not a team game) + // and there is another on the Challenge entity (which is also always populated). These + // are not foreign keys and can't be the bases of join-like structures in EF. + // + // Additionally, the semantics of who owns a challenge vary between individual and team games. + // As of now, when a team starts a challenge, a nearly-random (.First()) player is chosen and + // assigned as the owner of the challenge. For the purposes of this report, this means that if + // we strictly look at individual player registrations and report their challenges and performance, + // we won't get the whole story if their challenges are owned by a teammate. + // + // To accommodate this, we just group all players by team id, create a dictionary of challenges + // owned by any player on the team (by TeamId), and report the team's challenges for every player + // on the team. + var teamIds = players + .Select(p => p.TeamId) + .Distinct() + .ToArray(); + + var teamAndChallengeData = await _store + .List() + .Include(p => p.Challenges) + .Include(p => p.Game) + .Include(p => p.User) + .Where(p => teamIds.Contains(p.TeamId)) + .Select(p => new + { + p.Id, + p.TeamId, + Name = p.ApprovedName, + p.Role, + p.Score, + p.Sponsor, + Challenges = p.Challenges.Select(c => new EnrollmentReportChallengeQueryData + { + SpecId = c.SpecId, + Name = c.Name, + WhenCreated = c.WhenCreated, + StartTime = c.StartTime, + EndTime = c.EndTime, + ManualChallengeBonuses = c.AwardedManualBonuses.Select(b => new EnrollmentReportManualChallengeBonus + { + Description = b.Description, + Points = b.PointValue + }), + Score = c.Score, + MaxPossiblePoints = c.Points + }) + }) + .GroupBy(p => p.TeamId) + .ToDictionaryAsync(g => g.Key, g => g.ToList(), cancellationToken); + + // transform the player records into enrollment report records + var records = players.Select(p => + { + var playerTeamChallengeData = teamAndChallengeData[p.TeamId]; + var captain = playerTeamChallengeData.FirstOrDefault(p => p.Role == PlayerRole.Manager); + var playerTeamSponsorLogos = playerTeamChallengeData.Select(p => p.Sponsor); + var challenges = teamAndChallengeData[p.TeamId] + .SelectMany(c => ChallengeDataToViewModel(c.Challenges)) + .DistinctBy(c => c.SpecId) + .ToArray(); + + return new EnrollmentReportRecord + { + User = new SimpleEntity { Id = p.UserId, Name = p.User.Name }, + Player = new EnrollmentReportPlayerViewModel + { + Id = p.Id, + Name = p.ApprovedName, + EnrollDate = p.WhenCreated.HasValue() ? p.WhenCreated : null, + Sponsor = sponsors.FirstOrDefault(s => s.LogoFileName == p.Sponsor) + }, + Game = new ReportGameViewModel + { + Id = p.GameId, + Name = p.Game.Name, + IsTeamGame = p.Game.MinTeamSize > 1, + Series = p.Game.Competition, + Season = p.Game.Season, + Track = p.Game.Track + }, + Team = new EnrollmentReportTeamViewModel + { + Id = p.TeamId, + Name = captain?.Name ?? p.Name, + CurrentCaptain = new SimpleEntity { Id = captain?.Id ?? p.Id, Name = captain?.Name ?? p.Name }, + Sponsors = sponsors.Where(s => playerTeamSponsorLogos.Contains(s.LogoFileName)).ToArray() + }, + PlayTime = new EnrollmentReportPlayTimeViewModel + { + Start = p.SessionBegin.HasValue() ? p.SessionBegin : null, + DurationMs = p.Time > 0 ? p.Time : null, + End = (p.SessionBegin.HasValue() && p.Time > 0) ? p.SessionBegin.AddMilliseconds(p.Time) : null + }, + Challenges = challenges, + ChallengesPartiallySolvedCount = challenges.Where(c => c.Result == ChallengeResult.Partial).Count(), + ChallengesCompletelySolvedCount = challenges.Where(c => c.Result == ChallengeResult.Success).Count(), + Score = p.Score + }; + }); + + var usersBySponsor = records + .Where(r => r.Player.Sponsor is not null) + .Select(r => new + { + SponsorId = r.Player.Sponsor.Id, + UserId = r.User.Id + }) + .GroupBy(r => r.SponsorId) + .OrderByDescending(g => g.Count()) + .ToDictionary(g => g.Key, g => g.Count()); + + EnrollmentReportStatSummarySponsorPlayerCount sponsorWithMostPlayers = null; + + if (usersBySponsor.Any()) + { + var sponsor = sponsors.FirstOrDefault(s => s.Id == usersBySponsor.First().Key); + + if (sponsor is not null) + { + sponsorWithMostPlayers = new() + { + Sponsor = sponsor, + DistinctPlayerCount = usersBySponsor[sponsor.Id] + }; + } + } + + var statSummary = new EnrollmentReportStatSummary + { + DistinctGameCount = records.Select(r => r.Game.Id).Distinct().Count(), + DistinctPlayerCount = records.Select(r => r.User.Id).Distinct().Count(), + DistinctSponsorCount = usersBySponsor.Keys.Count(), + SponsorWithMostPlayers = sponsorWithMostPlayers, + DistinctTeamCount = records.Select(p => p.Team.Id).Distinct().Count() + }; + + return new() + { + StatSummary = statSummary, + Records = records + }; + } + + private IEnumerable ChallengeDataToViewModel(IEnumerable challengeData) + => challengeData.Select(c => new EnrollmentReportChallengeViewModel + { + Name = c.Name, + SpecId = c.SpecId, + DeployDate = c.WhenCreated, + StartDate = c.StartTime, + EndDate = c.EndTime, + DurationMs = c.StartTime.HasValue() && c.EndTime.HasValue() ? c.EndTime.Subtract(c.StartTime).TotalMilliseconds : null, + Result = ChallengeExtensions.GetResult(c.Score, c.MaxPossiblePoints), + ManualChallengeBonuses = c.ManualChallengeBonuses, + Score = c.Score, + MaxPossiblePoints = c.MaxPossiblePoints + }); +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportValidator.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportValidator.cs new file mode 100644 index 00000000..c18bd9d8 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportValidator.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Validators; + +namespace Gameboard.Api.Features.Reports; + +internal class EnrollmentReportValidator : IGameboardRequestValidator +{ + private readonly IValidatorService _validatorService; + + public EnrollmentReportValidator(IValidatorService validatorService) + { + _validatorService = validatorService; + } + + public async Task Validate(EnrollmentReportParameters request) + { + var startEndDateValidator = StartEndDateValidator.Configure(opt => + { + opt.StartDateProperty = p => p.EnrollDateStart; + opt.EndDateProperty = p => p.EnrollDateEnd; + }); + + _validatorService.AddValidator(startEndDateValidator); + + await _validatorService.Validate(request); + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/GetMetaData/GetMetaData.cs b/src/Gameboard.Api/Features/Reports/Queries/GetMetaData/GetMetaData.cs new file mode 100644 index 00000000..d803ce80 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/GetMetaData/GetMetaData.cs @@ -0,0 +1,42 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR.Authorizers; +using MediatR; + +namespace Gameboard.Api.Features.Reports; + +public record GetMetaDataQuery(string ReportKey) : IRequest; + +internal class GetMetaDataHandler : IRequestHandler +{ + private readonly INowService _now; + private readonly IReportsService _reportsService; + private readonly UserRoleAuthorizer _roleAuthorizer; + + public GetMetaDataHandler + ( + INowService now, + IReportsService reportsService, + UserRoleAuthorizer roleAuthorizer + ) + => (_now, _reportsService, _roleAuthorizer) = (now, reportsService, roleAuthorizer); + + public async Task Handle(GetMetaDataQuery request, CancellationToken cancellationToken) + { + _roleAuthorizer.AllowedRoles = new UserRole[] { UserRole.Admin }; + _roleAuthorizer.Authorize(); + + var reports = await _reportsService.List(); + var report = reports.FirstOrDefault(r => r.Key == request.ReportKey) ?? throw new ResourceNotFound(request.ReportKey); + + return new ReportMetaData + { + Key = request.ReportKey, + Title = report.Name, + ParametersSummary = null, + RunAt = _now.Get() + }; + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayerReportQuery.cs b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayerReportQuery.cs new file mode 100644 index 00000000..2dfad930 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayerReportQuery.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Common; +using Gameboard.Api.Services; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Reports; + +public record PlayersReportQuery(PlayersReportQueryParameters Parameters) : IRequest>; + +internal class GetPlayersReportQueryHandler : IRequestHandler> +{ + private readonly IMapper _mapper; + private readonly INowService _nowService; + private readonly IPlayersReportService _reportService; + private readonly IStore _sponsorStore; + + public GetPlayersReportQueryHandler + ( + IMapper mapper, + INowService now, + IPlayersReportService reportService, + IStore sponsorStore + ) + { + _mapper = mapper; + _nowService = now; + _reportService = reportService; + _sponsorStore = sponsorStore; + } + + public async Task> Handle(PlayersReportQuery request, CancellationToken cancellationToken) + { + var query = _reportService.GetPlayersReportBaseQuery(request.Parameters); + return await TransformQueryToResults(query); + } + + internal async Task> TransformQueryToResults(IQueryable query) + { + var users = await query + .GroupBy(p => p.UserId) + .ToDictionaryAsync(p => p.Key, p => p.ToList()); + + var sponsors = await _sponsorStore.ListWithNoTracking().ToArrayAsync(); + + var records = users.Select(u => + { + var playerRecords = u.Value; + var games = playerRecords.Select(p => p.Game); + var challenges = playerRecords.SelectMany(c => c.Challenges).ToArray(); + + return new PlayersReportRecord + { + User = new SimpleEntity { Id = u.Key, Name = playerRecords.First().User.Name }, + Sponsors = playerRecords + .Select(p => sponsors.FirstOrDefault(s => s.Logo == p.Sponsor)) + .Select(s => new PlayersReportSponsor + { + Id = s.Id, + Name = s.Name, + LogoUri = s.Logo + }) + .ToArray(), + + Games = new PlayersReportGamesAndChallengesSummary + { + Enrolled = _mapper.Map>(games), + Deployed = + _mapper.Map>( + playerRecords + .Where(p => p.Challenges.Any(c => c.StartTime.HasValue())).DistinctBy(c => c.GameId) + .DistinctBy(p => p.GameId) + ), + ScoredPartial = _mapper.Map> + ( + playerRecords + .Where(g => g.Challenges.Any(c => c.Score > 0 && c.Score < c.Points)) + .DistinctBy(p => p.GameId) + ), + ScoredComplete = _mapper.Map> + ( + playerRecords + .Where(p => p.Challenges.Any(c => c.Score >= c.Points)) + .DistinctBy(c => c.GameId) + ) + }, + Challenges = new PlayersReportGamesAndChallengesSummary + { + Enrolled = _mapper.Map>(challenges), + Deployed = _mapper.Map>(challenges.Where(c => c.StartTime.HasValue())), + ScoredPartial = _mapper.Map>(challenges.Where(c => c.Score > 0 && c.Score < c.Points)), + ScoredComplete = _mapper.Map>(challenges.Where(c => c.Score >= c.Points)) + }, + CompetitionsPlayed = games.Select(g => g.Competition).Distinct(), + TracksPlayed = games.Select(g => g.Track).Distinct() + }; + }).ToArray(); + + return new ReportResults + { + MetaData = new ReportMetaData + { + Key = ReportKey.Players, + Title = "Players Report", + RunAt = _nowService.Get(), + }, + Paging = null, + Records = records + }; + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportExportQuery.cs b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportExportQuery.cs new file mode 100644 index 00000000..0d45a03e --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportExportQuery.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Reports; + +public record PlayersReportExportQuery(PlayersReportQueryParameters Parameters) : IRequest>; + +public class PlayersReportExportHandler : IRequestHandler> +{ + private readonly IPlayersReportService _reportsService; + + public PlayersReportExportHandler(IPlayersReportService reportsService) + { + _reportsService = reportsService; + } + + public async Task> Handle(PlayersReportExportQuery request, CancellationToken cancellationToken) + { + var query = _reportsService.GetPlayersReportBaseQuery(request.Parameters); + + return await query.Select(p => new PlayersReportExportRecord + { + Id = p.User.Id, + Name = p.User.ApprovedName, + SponsorName = p.Sponsor, + Competition = p.Game.Competition, + Track = p.Game.Track, + GameId = p.GameId, + GameName = p.Game.Name, + ChallengeSummary = string.Join(", ", p.Challenges.Select(c => $"{c.Name} ({c.Score}/{c.Points})")), + PlayerId = p.Id, + PlayerName = p.ApprovedName, + MaxPossibleScore = p.Game.Specs.Sum(s => s.Points), + Score = p.Score + }).ToArrayAsync(); + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportModels.cs b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportModels.cs new file mode 100644 index 00000000..6fb4bdb8 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportModels.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using Gameboard.Api.Common; + +namespace Gameboard.Api.Features.Reports; + +public sealed class PlayersReportTrackModifier +{ + public static string CompetedInThisTrack { get => "include"; } + public static string CompetedInOnlyThisTrack { get => "include-only"; } + public static string DidntCompeteInThisTrack { get => "exclude"; } +} + +public sealed class PlayersReportQueryParameters +{ + public string ChallengeSpecId { get; set; } + public string Series { get; set; } + public string GameId { get; set; } + public string SponsorId { get; set; } + public ReportDateRange SessionStartWindow { get; set; } + public string TrackName { get; set; } + public string TrackModifier { get; set; } +} + +public sealed class PlayersReportSponsor +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string LogoUri { get; set; } +} + +public sealed class PlayersReportGamesAndChallengesSummary +{ + public required IEnumerable Deployed { get; set; } + public required IEnumerable Enrolled { get; set; } + public required IEnumerable ScoredPartial { get; set; } + public required IEnumerable ScoredComplete { get; set; } +} + +public sealed class PlayersReportRecord +{ + public required SimpleEntity User { get; set; } + public required IEnumerable Sponsors { get; set; } + public required PlayersReportGamesAndChallengesSummary Challenges { get; set; } + public required PlayersReportGamesAndChallengesSummary Games { get; set; } + public required IEnumerable TracksPlayed { get; set; } + public required IEnumerable CompetitionsPlayed { get; set; } +} + +public sealed class PlayersReportCsvRecordChallenge +{ + public required string ChallengeId { get; set; } + public required string ChallengeName { get; set; } + public required double ChallengeScore { get; set; } +} + +public sealed class PlayersReportExportRecord +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string SponsorName { get; set; } + public required string Competition { get; set; } + public required string Track { get; set; } + public required string GameId { get; set; } + public required string GameName { get; set; } + public required string ChallengeSummary { get; set; } + public required string PlayerId { get; set; } + public required string PlayerName { get; set; } + public required double MaxPossibleScore { get; set; } + public required double Score { get; set; } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportService.cs new file mode 100644 index 00000000..acf4c018 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/PlayersReport/PlayersReportService.cs @@ -0,0 +1,63 @@ +using System.Linq; +using Gameboard.Api.Data.Abstractions; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Reports; + +public interface IPlayersReportService +{ + IQueryable GetPlayersReportBaseQuery(PlayersReportQueryParameters parameters); +} + +internal class PlayersReportService : IPlayersReportService +{ + private readonly IPlayerStore _playerStore; + + public PlayersReportService(IPlayerStore playerStore) + { + _playerStore = playerStore; + } + + public IQueryable GetPlayersReportBaseQuery(PlayersReportQueryParameters parameters) + { + // compute these first so we can preserve server-side evaluation + var hasGameId = parameters.GameId.NotEmpty(); + var hasSeries = parameters.Series.NotEmpty(); + var hasSessionStartBegin = parameters.SessionStartWindow != null && parameters.SessionStartWindow.HasDateStartValue; + var hasSessionStartEnd = parameters.SessionStartWindow != null && parameters.SessionStartWindow.HasDateEndValue; + var hasSpecId = parameters.ChallengeSpecId.NotEmpty(); + var hasTrack = parameters.TrackName.NotEmpty(); + + // hack alert + // for some reason, the Sponsor column in User is the image file, not the sponsor id. to preserve this as an iqueryable (for now) + // coerce the value to match + var sponsorId = parameters.SponsorId.NotEmpty() ? $"{parameters.SponsorId}.jpg" : null; + + var baseQuery = _playerStore + .ListWithNoTracking() + .Include(p => p.User) + .Include(p => p.Challenges) + .Include(p => p.Game) + .ThenInclude + ( + g => g.Challenges + .Where(c => !hasSpecId || c.SpecId == parameters.ChallengeSpecId) + .Where(c => !hasSessionStartBegin || c.StartTime >= parameters.SessionStartWindow.DateStart) + .Where(c => !hasSessionStartEnd || c.EndTime <= parameters.SessionStartWindow.DateEnd) + ) + .Where(p => p.Game.PlayerMode == PlayerMode.Competition) + .Where(p => !hasGameId || p.GameId == parameters.GameId) + // note that the database uses the term "competition", but it's "series" in the UI + .Where(p => !hasSeries || p.Game.Competition == parameters.Series) + // i'm SURE there's a better way to structure this + .Where(p => !hasTrack || parameters.TrackModifier != PlayersReportTrackModifier.CompetedInThisTrack || p.Game.Track == parameters.TrackName) + .Where(p => !hasTrack || parameters.TrackModifier != PlayersReportTrackModifier.DidntCompeteInThisTrack || p.Game.Track != parameters.TrackName) + + // the "competed only in this track" thing is NYI + //.Where(u => hasTrack && parameters.TrackModifier == PlayersReportTrackModifier.CompetedInOnlyThisTrack ? u.Enrollments.GroupBy(p => p.Game.Track).Count() == 1 : true) + .AsQueryable() + .Where(u => sponsorId == null || u.Sponsor == sponsorId); + + return baseQuery; + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReport.cs b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReport.cs new file mode 100644 index 00000000..d64db580 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReport.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common; +using MediatR; + +namespace Gameboard.Api.Features.Reports; + +public record PracticeModeReportQuery(PracticeModeReportParameters Parameters, User ActingUser, PagingArgs PagingArgs) : IRequest>; + +internal class PracticeModeReportHandler : IRequestHandler> +{ + private readonly IPracticeModeReportService _practiceModeReportService; + private readonly IReportsService _reportsService; + + public PracticeModeReportHandler + ( + IPracticeModeReportService practiceModeReportService, + IReportsService reportsService + ) + => (_practiceModeReportService, _reportsService) = (practiceModeReportService, reportsService); + + public async Task> Handle(PracticeModeReportQuery request, CancellationToken cancellationToken) + { + if (request.Parameters.Grouping == PracticeModeReportGrouping.Challenge) + { + var results = await _practiceModeReportService.GetResultsByChallenge(request.Parameters, cancellationToken); + return _reportsService.BuildResults(new ReportRawResults + { + OverallStats = results.OverallStats, + PagingArgs = request.PagingArgs, + ParameterSummary = null, + Records = results.Records, + ReportKey = ReportKey.PracticeArea, + Title = "Practice Area Report (Grouped By Challenge)" + }); + } + else if (request.Parameters.Grouping == PracticeModeReportGrouping.Player) + { + var results = await _practiceModeReportService.GetResultsByUser(request.Parameters, cancellationToken); + return _reportsService.BuildResults(new ReportRawResults + { + OverallStats = results.OverallStats, + PagingArgs = request.PagingArgs, + ParameterSummary = null, + Records = results.Records, + ReportKey = ReportKey.PracticeArea, + Title = "Practice Area Report (Grouped By Player)", + }); + } + else if (request.Parameters.Grouping == PracticeModeReportGrouping.PlayerModePerformance) + { + var results = await _practiceModeReportService.GetResultsByPlayerModePerformance(request.Parameters, cancellationToken); + return _reportsService.BuildResults(new ReportRawResults + { + OverallStats = results.OverallStats, + PagingArgs = request.PagingArgs, + ParameterSummary = null, + Records = results.Records, + ReportKey = ReportKey.PracticeArea, + Title = "Practice Area Report (Grouped By Player Mode Performance)" + }); + } + + throw new ArgumentException(message: $"""Grouping value "{request.Parameters.Grouping}" is unsupported.""", nameof(request.Parameters.Grouping)); + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportCsvExport.cs b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportCsvExport.cs new file mode 100644 index 00000000..c0724b0a --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportCsvExport.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Gameboard.Api.Features.Reports; + +public record PracticeModeReportCsvExportQuery(PracticeModeReportParameters Parameters, User ActingUser) : IReportQuery, IRequest>; + +internal class PracticeModeReportCsvExportHandler : IRequestHandler> +{ + private readonly IPracticeModeReportService _practiceModeReportService; + private readonly ReportsQueryValidator _validator; + + public PracticeModeReportCsvExportHandler(IPracticeModeReportService practiceModeReportService, ReportsQueryValidator validator) + { + _practiceModeReportService = practiceModeReportService; + _validator = validator; + } + + public async Task> Handle(PracticeModeReportCsvExportQuery request, CancellationToken cancellationToken) + { + await _validator.Validate(request); + return await _practiceModeReportService.GetCsvExport(request.Parameters, cancellationToken); + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportModels.cs b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportModels.cs new file mode 100644 index 00000000..91fed278 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportModels.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Gameboard.Api.Common; + +namespace Gameboard.Api.Features.Reports; + +public class PracticeModeReportGrouping +{ + public static readonly string Challenge = "challenge"; + public static readonly string Player = "player"; + public static readonly string PlayerModePerformance = "player-mode-performance"; +} + +public sealed class PracticeModeReportOverallStats +{ + public int ChallengeCount { get; set; } + public int PlayerCount { get; set; } + public int SponsorCount { get; set; } + public int AttemptCount { get; set; } +} + +[JsonDerivedType(typeof(PracticeModeByChallengeReportRecord), typeDiscriminator: "byChallenge")] +[JsonDerivedType(typeof(PracticeModeByUserReportRecord), typeDiscriminator: "byUser")] +[JsonDerivedType(typeof(PracticeModeReportByPlayerModePerformanceRecord), typeDiscriminator: "byPlayerModePerformance")] +public interface IPracticeModeReportRecord { } + +public sealed class PracticeModeReportResults +{ + public PracticeModeReportOverallStats OverallStats { get; set; } + public IEnumerable Records { get; set; } +} + +public sealed class PracticeModeReportParameters +{ + public DateTimeOffset? PracticeDateStart { get; set; } + public DateTimeOffset? PracticeDateEnd { get; set; } + public string Games { get; set; } + public string Seasons { get; set; } + public string Series { get; set; } + public string Tracks { get; set; } + public string Sponsors { get; set; } + public string Grouping { get; set; } +} + +public sealed class PracticeModeByUserReportRecord : IPracticeModeReportRecord +{ + public required PracticeModeReportUser User { get; set; } + public required PracticeModeByUserReportChallenge Challenge { get; set; } + public required IEnumerable Attempts { get; set; } +} + +public sealed class PracticeModeReportUser +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required ReportSponsorViewModel Sponsor { get; set; } + public required bool HasScoringAttempt { get; set; } +} + +public sealed class PracticeModeByUserReportChallenge +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required ReportGameViewModel Game { get; set; } + public required double MaxPossibleScore { get; set; } +} + +public sealed class PracticeModeReportAttempt +{ + public required SimpleEntity Player { get; set; } + public ReportTeamViewModel Team { get; set; } + public required ReportSponsorViewModel Sponsor { get; set; } + public required DateTimeOffset Start { get; set; } + public required DateTimeOffset End { get; set; } + public required double DurationMs { get; set; } + public required ChallengeResult Result { get; set; } + public required double Score { get; set; } + public required int PartiallyCorrectCount { get; set; } + public required int FullyCorrectCount { get; set; } +} + +public sealed class PracticeModeUserCompetitiveSummary +{ + public required double AvgCompetitivePointsPct { get; set; } + public required int CompetitiveChallengesPlayed { get; set; } + public required int CompetitiveGamesPlayed { get; set; } + public required DateTimeOffset LastCompetitiveChallengeDate { get; set; } +} + +public sealed class PracticeModeByChallengeReportRecord : IPracticeModeReportRecord +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required ReportGameViewModel Game { get; set; } + public required double MaxPossibleScore { get; set; } + public required double AvgScore { get; set; } + public required string Description { get; set; } + public required string Text { get; set; } + public required IEnumerable SponsorsPlayed { get; set; } + public required PracticeModeReportByChallengePerformance OverallPerformance { get; set; } + public required IEnumerable PerformanceBySponsor { get; set; } +} + +public sealed class PracticeModeReportByChallengePerformance +{ + public required IEnumerable Players { get; set; } + public required int TotalAttempts { get; set; } + public required decimal? ScoreHigh { get; set; } + public required decimal? ScoreAvg { get; set; } + public required int CompleteSolves { get; set; } + public required decimal? PercentageCompleteSolved { get; set; } + public required int PartialSolves { get; set; } + public required decimal? PercentagePartiallySolved { get; set; } + public required int ZeroScoreSolves { get; set; } + public required decimal? PercentageZeroScoreSolved { get; set; } +} + +public sealed class PracticeModeReportByChallengePerformanceBySponsor +{ + public required ReportSponsorViewModel Sponsor { get; set; } + public required PracticeModeReportByChallengePerformance Performance { get; set; } +} + +public sealed class PracticeModeReportByPlayerModePerformanceRecord : IPracticeModeReportRecord +{ + public required SimpleEntity Player { get; set; } + public required ReportSponsorViewModel Sponsor { get; set; } + public required PracticeModeReportByPlayerModePerformanceRecordModeSummary PracticeStats { get; set; } + public required PracticeModeReportByPlayerModePerformanceRecordModeSummary CompetitiveStats { get; set; } +} + +public sealed class PracticeModeReportByPlayerModePerformanceRecordModeSummary +{ + public required DateTimeOffset? LastAttemptDate { get; set; } + public required int TotalChallengesPlayed { get; set; } + public required decimal ZeroScoreSolves { get; set; } + public required decimal PartialSolves { get; set; } + public required decimal CompleteSolves { get; set; } + public required double AvgPctAvailablePointsScored { get; set; } + public required decimal AvgScorePercentile { get; set; } +} + +internal sealed class PracticeModeReportByPlayerModePerformanceChallengeScore +{ + public required string ChallengeId { get; set; } + public required string ChallengeSpecId { get; set; } + public required bool IsPractice { get; set; } + public required double Score { get; set; } +} + +public sealed class PracticeModeReportPlayerModeSummary +{ + public required PracticeModeReportUser Player { get; set; } + public required IEnumerable Challenges { get; set; } +} + +public sealed class PracticeModeReportPlayerModeSummaryChallenge +{ + public required SimpleEntity ChallengeSpec { get; set; } + public required ReportGameViewModel Game { get; set; } + public required decimal Score { get; set; } + public required decimal MaxPossibleScore { get; set; } + public required ChallengeResult Result { get; set; } + public required double PctAvailablePointsScored { get; set; } + public required decimal? ScorePercentile { get; set; } +} + +public sealed class PracticeModeReportCsvRecord +{ + public required string UserId { get; set; } + public required string UserName { get; set; } + public required string ChallengeId { get; set; } + public required string ChallengeName { get; set; } + public required string ChallengeSpecId { get; set; } + public required string GameId { get; set; } + public required string GameName { get; set; } + public required string PlayerId { get; set; } + public required string PlayerName { get; set; } + public required string SponsorId { get; set; } + public required string SponsorName { get; set; } + public required string TeamId { get; set; } + public required string TeamName { get; set; } + public required double Score { get; set; } + public required double MaxPossibleScore { get; set; } + public required double PctMaxPointsScored { get; set; } + public required double? ScorePercentile { get; set; } + public required DateTimeOffset? SessionStart { get; set; } + public required DateTimeOffset? SessionEnd { get; set; } + public required ChallengeResult ChallengeResult { get; set; } + public required long DurationMs { get; set; } +} + diff --git a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportPlayerModeSummary.cs b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportPlayerModeSummary.cs new file mode 100644 index 00000000..17ebcb4d --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportPlayerModeSummary.cs @@ -0,0 +1,38 @@ +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Authorizers; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; + +namespace Gameboard.Api.Features.Reports; + +public record PracticeModeReportPlayerModeSummaryQuery(string UserId, bool IsPractice, User ActingUser) : IRequest; + +internal class PracticeModeReportPlayerModeSummaryHandler : IRequestHandler +{ + private readonly IPracticeModeReportService _reportService; + private readonly EntityExistsValidator _userExists; + private readonly UserRoleAuthorizer _userRoleAuthorizer; + private readonly IValidatorService _validatorService; + + public PracticeModeReportPlayerModeSummaryHandler + ( + IPracticeModeReportService reportService, + EntityExistsValidator userExists, + UserRoleAuthorizer userRoleAuthorizer, + IValidatorService validatorService + ) => (_reportService, _userExists, _userRoleAuthorizer, _validatorService) = (reportService, userExists, userRoleAuthorizer, validatorService); + + public async Task Handle(PracticeModeReportPlayerModeSummaryQuery request, CancellationToken cancellationToken) + { + _userRoleAuthorizer.AllowedRoles = new UserRole[] { UserRole.Admin, UserRole.Director, UserRole.Support }; + _userRoleAuthorizer.Authorize(); + + _validatorService.AddValidator(_userExists.UseProperty(r => r.UserId)); + await _validatorService.Validate(request); + + var result = await _reportService.GetPlayerModePerformanceSummary(request.UserId, request.IsPractice, cancellationToken); + return result; + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportService.cs new file mode 100644 index 00000000..94ba2f3d --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportService.cs @@ -0,0 +1,511 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Games; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Reports; + +public interface IPracticeModeReportService +{ + Task GetResultsByChallenge(PracticeModeReportParameters parameters, CancellationToken cancellationToken); + Task GetResultsByUser(PracticeModeReportParameters parameters, CancellationToken cancellationToken); + Task GetResultsByPlayerModePerformance(PracticeModeReportParameters parameters, CancellationToken cancellationToken); + Task> GetCsvExport(PracticeModeReportParameters parameters, CancellationToken cancellationToken); + Task GetPlayerModePerformanceSummary(string userId, bool isPractice, CancellationToken cancellationToken); +} + +internal class PracticeModeReportService : IPracticeModeReportService +{ + private readonly IReportsService _reportsService; + private readonly IStore _store; + + public PracticeModeReportService(IReportsService reportsService, IStore store) + => (_reportsService, _store) = (reportsService, store); + + private sealed class PracticeModeReportUngroupedResults + { + public required PracticeModeReportOverallStats OverallStats { get; set; } + public required IEnumerable Challenges { get; set; } + public required IDictionary Specs { get; set; } + public required IEnumerable Sponsors { get; set; } + } + + private async Task BuildUngroupedResults(PracticeModeReportParameters parameters, bool includeCompetitive, CancellationToken cancellationToken) + { + // load sponsors - we need them for the report data and they can't be joined + var sponsors = await _store + .List() + .Select(s => new ReportSponsorViewModel + { + Id = s.Id, + Name = s.Name, + LogoFileName = s.Logo + }) + .ToArrayAsync(cancellationToken); + + // process parameters + DateTimeOffset? startDate = parameters.PracticeDateStart.HasValue ? parameters.PracticeDateStart.Value.ToUniversalTime() : null; + DateTimeOffset? endDate = parameters.PracticeDateEnd.HasValue ? parameters.PracticeDateEnd.Value.ToUniversalTime() : null; + var gameIds = _reportsService.ParseMultiSelectCriteria(parameters.Games); + var sponsorIds = _reportsService.ParseMultiSelectCriteria(parameters.Sponsors); + var seasons = _reportsService.ParseMultiSelectCriteria(parameters.Seasons); + var series = _reportsService.ParseMultiSelectCriteria(parameters.Series); + var tracks = _reportsService.ParseMultiSelectCriteria(parameters.Tracks); + + var query = _store + .List() + .Include(c => c.Game) + .Include(c => c.Player) + .ThenInclude(p => p.User) + .Where(c => includeCompetitive || c.PlayerMode == PlayerMode.Practice); + + if (startDate is not null) + { + query = query + .WhereDateIsNotEmpty(c => c.StartTime) + .Where(c => c.StartTime >= startDate); + } + + if (endDate is not null) + { + query = query + .WhereDateIsNotEmpty(c => c.EndTime) + .Where(c => c.EndTime <= endDate); + } + + if (parameters.Seasons.IsNotEmpty()) + query = query.Where(c => parameters.Seasons.Contains(c.Game.Season)); + + if (parameters.Series.IsNotEmpty()) + query = query.Where(c => parameters.Series.Contains(c.Game.Competition)); + + if (parameters.Tracks.IsNotEmpty()) + query = query.Where(c => parameters.Tracks.Contains(c.Game.Track)); + + if (parameters.Games is not null && parameters.Games.Any()) + query = query.Where(c => parameters.Games.Contains(c.GameId)); + + if (parameters.Sponsors is not null && parameters.Sponsors.Any()) + { + var sponsorLogos = sponsors + .Where(s => parameters.Sponsors.Contains(s.Id)) + .Select(s => s.LogoFileName); + + query = query.Where(c => sponsorLogos.Contains(c.Player.Sponsor)); + } + + // we have to constrain the query results by eliminating challenges that have a specId + // which points at a nonexistent spec. (This is possible due to the non-FK relationship + // between challenge and spec and the fact that specs are deletable) + // + // so load all spec ids and add a clause which excludes challenges with orphaned specIds + var allSpecIds = await _store.List().Select(s => s.Id).ToArrayAsync(cancellationToken); + query = query.Where(c => allSpecIds.Contains(c.SpecId)); + + // query for the raw results + var challenges = await query.ToListAsync(cancellationToken); + + // also load challenge spec data for these challenges (spec can't be joined) + var specs = await _store + .List() + .Include(s => s.Game) + .Where(s => challenges.Select(c => c.SpecId).Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => s, cancellationToken); + + return new PracticeModeReportUngroupedResults + { + Challenges = challenges, + OverallStats = new() + { + AttemptCount = challenges.Count, + ChallengeCount = challenges + .Select(c => c.SpecId) + .Distinct() + .Count(), + PlayerCount = challenges + .Select(c => c.Player.UserId) + .Distinct() + .Count(), + SponsorCount = challenges + .Select(c => c.Player.Sponsor) + .Distinct() + .Count() + }, + Specs = specs, + Sponsors = sponsors ?? Array.Empty() + }; + } + + private PracticeModeReportByChallengePerformance BuildChallengePerformance(IEnumerable attempts, ReportSponsorViewModel sponsor = null) + { + // precompute totals so we don't have to do it more than once per spec + Func sponsorConstraint = (attempts => true); + if (sponsor is not null) + sponsorConstraint = (attempt) => attempt.Player.Sponsor == sponsor.LogoFileName; + + var totalAttempts = attempts.Where(sponsorConstraint).Count(); + var completeSolves = attempts + .Where(sponsorConstraint) + .Where(a => a.Result == ChallengeResult.Success).Count(); + var partialSolves = attempts + .Where(sponsorConstraint) + .Where(a => a.Result == ChallengeResult.Partial).Count(); + var zeroScoreSolves = attempts + .Where(sponsorConstraint) + .Where(a => a.Result == ChallengeResult.None).Count(); + + return new PracticeModeReportByChallengePerformance + { + Players = (sponsor is null ? attempts : attempts.Where(sponsorConstraint)). + Select + ( + a => new SimpleEntity { Id = a.Player.UserId, Name = a.Player.ApprovedName } + ) + .DistinctBy(e => e.Id) + .ToArray(), + TotalAttempts = totalAttempts, + CompleteSolves = completeSolves, + ScoreHigh = new decimal(attempts.Select(a => a.Score).Max()), + ScoreAvg = new decimal(attempts.Select(a => a.Score).Average()), + PercentageCompleteSolved = totalAttempts > 0 ? decimal.Divide(completeSolves, totalAttempts) : null, + PartialSolves = partialSolves, + PercentagePartiallySolved = totalAttempts > 0 ? decimal.Divide(partialSolves, totalAttempts) : null, + ZeroScoreSolves = zeroScoreSolves, + PercentageZeroScoreSolved = totalAttempts > 0 ? decimal.Divide(zeroScoreSolves, totalAttempts) : null + }; + } + + public async Task GetResultsByChallenge(PracticeModeReportParameters parameters, CancellationToken cancellationToken) + { + // the "false" argument here excludes competitive records (this grouping only looks at practice challenges) + var ungroupedResults = await BuildUngroupedResults(parameters, false, cancellationToken); + + var records = ungroupedResults + .Challenges + .GroupBy(c => c.SpecId) + .Select(g => + { + var attempts = g.ToList(); + var spec = ungroupedResults.Specs[g.Key]; + + var sponsorLogosPlayed = attempts.Select(a => a.Player.Sponsor).Distinct(); + var sponsorsPlayed = ungroupedResults + .Sponsors + .Where(s => sponsorLogosPlayed.Contains(s.LogoFileName)); + + // overall results across all sponsors + var performanceOverall = BuildChallengePerformance(attempts); + + // performance by sponsor + var performanceBySponsor = sponsorsPlayed.Select(s => new PracticeModeReportByChallengePerformanceBySponsor + { + Sponsor = s, + Performance = BuildChallengePerformance(attempts, s) + }); + + return new PracticeModeByChallengeReportRecord + { + Id = spec.Id, + Name = spec.Name, + Game = new ReportGameViewModel + { + Id = spec.GameId, + Name = spec.Game.Name, + IsTeamGame = spec.Game.IsTeamGame(), + Series = spec.Game.Competition, + Season = spec.Game.Season, + Track = spec.Game.Track + }, + MaxPossibleScore = spec.Points, + AvgScore = attempts.Select(a => a.Score).Average(), + Description = spec.Description, + Text = spec.Text, + SponsorsPlayed = sponsorsPlayed, + OverallPerformance = performanceOverall, + PerformanceBySponsor = performanceBySponsor + }; + }); + + return new() + { + OverallStats = ungroupedResults.OverallStats, + Records = records + }; + } + + public async Task GetResultsByUser(PracticeModeReportParameters parameters, CancellationToken cancellationToken) + { + // the "false" argument here excludes competitive records (this grouping only looks at practice challenges) + var ungroupedResults = await BuildUngroupedResults(parameters, false, cancellationToken); + + // resolve userIds, teams, and challengeIds + var challengeIds = ungroupedResults.Challenges.Select(c => c.Id); + var userIds = ungroupedResults.Challenges.Select(c => c.Player.UserId); + var teams = await _reportsService.GetTeamsByPlayerIds(ungroupedResults.Challenges.Select(c => c.PlayerId), cancellationToken); + + // for the group-by-players version of the report, we group on player and challenge spec, + // and we also need each user's competitive performance for reporting + var groupByPlayerAndChallengeSpec = ungroupedResults.Challenges.GroupBy(d => new { d.Player.UserId, d.SpecId }); + + // alias the specs dictionary for convenience later + var specs = ungroupedResults.Specs; + + // translate to records + var records = groupByPlayerAndChallengeSpec.Select(c => new PracticeModeByUserReportRecord + { + User = new PracticeModeReportUser + { + Id = c.Key.UserId, + Name = c.Select(c => c.Player.ApprovedName).First(), + // report their sponsor as the most recent attempt that has a sponsor + // (which they all should, but still) + Sponsor = ungroupedResults.Sponsors + .FirstOrDefault + (s => + s.LogoFileName == c + .OrderByDescending(c => c.StartTime) + .FirstOrDefault(c => c.Player.Sponsor is not null)?.Player?.Sponsor + ), + HasScoringAttempt = c.Any(c => c.Score > 0) + }, + Challenge = new PracticeModeByUserReportChallenge + { + Id = c.Key.SpecId, + Name = specs[c.Key.SpecId].Name, + Game = new ReportGameViewModel + { + Id = specs[c.Key.SpecId].Game.Id, + Name = specs[c.Key.SpecId].Game.Name, + IsTeamGame = specs[c.Key.SpecId].Game.IsTeamGame(), + Series = specs[c.Key.SpecId].Game.Competition, + Season = specs[c.Key.SpecId].Game.Season, + Track = specs[c.Key.SpecId].Game.Track + }, + MaxPossibleScore = specs[c.Key.SpecId].Points + }, + Attempts = c.ToList().Select(attempt => new PracticeModeReportAttempt + { + Player = new SimpleEntity { Id = attempt.PlayerId, Name = attempt.Player.ApprovedName }, + Team = teams.ContainsKey(attempt.Player.TeamId) ? teams[attempt.Player.TeamId] : null, + Sponsor = ungroupedResults.Sponsors.FirstOrDefault(s => s.LogoFileName == attempt.Player.Sponsor), + Start = attempt.StartTime, + End = attempt.EndTime, + DurationMs = attempt.Player.Time, + Result = attempt.Result, + Score = attempt.Score, + PartiallyCorrectCount = attempt.Player.PartialCount, + FullyCorrectCount = attempt.Player.CorrectCount + }) + }); + + return new() + { + OverallStats = ungroupedResults.OverallStats, + Records = records + }; + } + + public async Task GetResultsByPlayerModePerformance(PracticeModeReportParameters parameters, CancellationToken cancellationToken) + { + // the "true" argument here includes competitive records, because we're comparing practice vs competitive performance here + var ungroupedResults = await BuildUngroupedResults(parameters, true, cancellationToken); + var allSpecRawScores = await GetSpecRawScores(ungroupedResults.Challenges.Select(c => c.SpecId).ToArray()); + + return new() + { + OverallStats = ungroupedResults.OverallStats, + Records = ungroupedResults + .Challenges + .GroupBy(r => r.Player.UserId) + .Select(g => + { + return new PracticeModeReportByPlayerModePerformanceRecord + { + // this is really more of a user entity than a player entity, but the report uses "player" to refer to users on purpose + Player = new SimpleEntity { Id = g.Key, Name = g.First().Player?.User?.ApprovedName ?? g.First().Player?.ApprovedName }, + Sponsor = ungroupedResults.Sponsors.FirstOrDefault(s => s.LogoFileName == g.FirstOrDefault()?.Player?.User?.Sponsor), + PracticeStats = CalculateByPlayerPerformanceModeSummary(true, g.ToList(), allSpecRawScores), + CompetitiveStats = CalculateByPlayerPerformanceModeSummary(false, g.ToList(), allSpecRawScores) + }; + }) + }; + } + + // this feels really gross, but i'm going to do this as a separate query, because it needs kind of unrelated information. will discuss + // the possibility of making the prac/comp thing its own report + public async Task GetPlayerModePerformanceSummary(string userId, bool isPractice, CancellationToken cancellationToken) + { + // have to grab the specIds to ensure that no challenges are coming back with orphaned specIds :( + var specIds = await _store.List().Select(s => s.Id).ToArrayAsync(cancellationToken); + var challenges = await _store + .List() + .Include(c => c.Game) + .Include(c => c.Player) + .ThenInclude(p => p.User) + .Where(c => c.Player.UserId == userId) + .Where(c => c.PlayerMode == (isPractice ? PlayerMode.Practice : PlayerMode.Competition)) + .Where(c => specIds.Contains(c.SpecId)) + .ToListAsync(cancellationToken); + + // these will all be the same + var user = challenges.First().Player.User; + var sponsor = await _store.List() + .Select(s => new ReportSponsorViewModel + { + Id = s.Id, + Name = s.Name, + LogoFileName = s.Logo + }) + .SingleAsync(s => s.LogoFileName == user.Sponsor, cancellationToken); + + // pull the scores for challenge specs this player played in this mode + var rawScores = (await GetSpecRawScores(challenges.Select(c => c.SpecId).ToArray())).Where(s => s.IsPractice == isPractice); + + return new() + { + Player = new() + { + Id = user.Id, + Name = user.ApprovedName, + Sponsor = sponsor, + HasScoringAttempt = challenges.Any(c => c.Score > 0) + }, + Challenges = challenges.Select(c => new PracticeModeReportPlayerModeSummaryChallenge + { + ChallengeSpec = new SimpleEntity { Id = c.SpecId, Name = c.Name }, + Game = new ReportGameViewModel + { + Id = c.GameId, + Name = c.Game.Name, + IsTeamGame = c.Game.IsTeamGame(), + Series = c.Game.Competition, + Season = c.Game.Season, + Track = c.Game.Track + }, + MaxPossibleScore = c.Points, + PctAvailablePointsScored = c.GetPercentMaxPointsScored(), + Result = c.Result, + Score = new decimal(c.Score), + ScorePercentile = CalculatePlayerChallengePercentile(c.Id, c.SpecId, c.Score, c.Game.IsPracticeMode, rawScores) + }) + }; + } + + public async Task> GetCsvExport(PracticeModeReportParameters parameters, CancellationToken cancellationToken) + { + var ungroupedResults = await BuildUngroupedResults(parameters, false, cancellationToken); + var teams = await _reportsService.GetTeamsByPlayerIds(ungroupedResults.Challenges.Select(c => c.PlayerId), cancellationToken); + var rawScores = await GetSpecRawScores(ungroupedResults.Specs.Values.Select(s => s.Id).ToArray()); + + return ungroupedResults.Challenges.Select(c => + { + var sponsor = ungroupedResults + .Sponsors + .FirstOrDefault(s => s.LogoFileName == c.Player.Sponsor); + + return new PracticeModeReportCsvRecord + { + ChallengeId = c.Id, + ChallengeName = c.Name, + ChallengeSpecId = c.SpecId, + GameId = c.GameId, + GameName = c.Game.Name, + PlayerId = c.PlayerId, + PlayerName = c.Player.ApprovedName, + SponsorId = sponsor?.Id, + SponsorName = sponsor?.Name, + TeamId = c.Player.TeamId, + TeamName = teams.ContainsKey(c.Player.TeamId) ? teams[c.Player.TeamId].Name : null, + UserId = c.Player.UserId, + UserName = c.Player.User.ApprovedName, + DurationMs = c.Duration, + ChallengeResult = c.Result, + Score = c.Score, + MaxPossibleScore = c.Points, + PctMaxPointsScored = c.GetPercentMaxPointsScored(), + ScorePercentile = (double?)CalculatePlayerChallengePercentile(c.Id, c.SpecId, c.Score, c.Game.IsPracticeMode, rawScores), + SessionStart = c.StartTime.HasValue() ? c.StartTime : null, + SessionEnd = c.EndTime.HasValue() ? c.EndTime : null + }; + }); + } + + private PracticeModeReportByPlayerModePerformanceRecordModeSummary CalculateByPlayerPerformanceModeSummary(bool isPractice, IEnumerable challenges, IEnumerable percentileTable) + { + var modeChallenges = challenges.Where(c => isPractice == (c.PlayerMode == PlayerMode.Practice)); + var modePercentiles = percentileTable.Where(p => p.IsPractice == isPractice); + PracticeModeReportByPlayerModePerformanceRecordModeSummary modeStats = null; + + if (modeChallenges.Any()) + { + var scoringChallenges = modeChallenges.Where(c => c.Points > 0); + + modeStats = new PracticeModeReportByPlayerModePerformanceRecordModeSummary + { + LastAttemptDate = modeChallenges + .OrderByDescending(c => c.Player.SessionBegin) + .Select(c => c.Player.SessionBegin) + .FirstOrDefault(), + TotalChallengesPlayed = modeChallenges.Count(), + ZeroScoreSolves = modeChallenges.Where(c => c.Result == ChallengeResult.None).Count(), + PartialSolves = modeChallenges.Where(c => c.Result == ChallengeResult.Partial).Count(), + CompleteSolves = modeChallenges.Where(c => c.Result == ChallengeResult.Success).Count(), + AvgPctAvailablePointsScored = scoringChallenges.Count() == 0 ? 0 : scoringChallenges.Average(c => c.GetPercentMaxPointsScored()), + AvgScorePercentile = modeChallenges + .Select + ( + c => CalculatePlayerChallengePercentile(c.Id, c.SpecId, c.Score, isPractice, modePercentiles) + ) + .DefaultIfEmpty() + .Average() + }; + } + + return modeStats; + } + + private decimal CalculatePlayerChallengePercentile(string challengeId, string specId, double score, bool isPractice, IEnumerable percentileTable) + { + Func isOtherChallengeRecord = p => + p.IsPractice == isPractice && + p.ChallengeSpecId == specId && + p.ChallengeId != challengeId; + + var denominator = percentileTable + .Where(p => isOtherChallengeRecord(p)) + .Count(); + + if (denominator == 0) + return 100; + + var numerator = percentileTable + .Where(p => isOtherChallengeRecord(p) && p.Score < score) + .Count(); + + return decimal.Divide(numerator, denominator) * 100; + } + + private async Task> GetSpecRawScores(IList specIds) + { + return await _store + .List() + .Include(c => c.Game) + .GroupBy(c => new { c.Id, c.SpecId, IsPractice = c.PlayerMode == PlayerMode.Practice }) + .Select(g => new PracticeModeReportByPlayerModePerformanceChallengeScore + { + ChallengeId = g.Key.Id, + ChallengeSpecId = g.Key.SpecId, + IsPractice = g.Key.IsPractice, + Score = g + .Select(c => c.Score) + .Max() + }) + .Where(k => specIds.Contains(k.ChallengeSpecId)) + .ToListAsync(); + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/ReportsCommonViewModels.cs b/src/Gameboard.Api/Features/Reports/Queries/ReportsCommonViewModels.cs new file mode 100644 index 00000000..ce46735c --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/ReportsCommonViewModels.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using Gameboard.Api.Common; + +namespace Gameboard.Api.Features.Reports; + +public sealed class ReportGameViewModel +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required bool IsTeamGame { get; set; } + public required string Series { get; set; } + public required string Season { get; set; } + public required string Track { get; set; } +} + +public sealed class ReportSponsorViewModel +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string LogoFileName { get; set; } +} + +public sealed class ReportTeamViewModel +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required IEnumerable Sponsors { get; set; } + public required IEnumerable Players { get; set; } + public required SimpleEntity Captain { get; set; } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReport.cs b/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReport.cs new file mode 100644 index 00000000..4c051d5e --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReport.cs @@ -0,0 +1,41 @@ +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common; +using MediatR; + +namespace Gameboard.Api.Features.Reports; + +public record SupportReportQuery(SupportReportParameters Parameters, PagingArgs PagingArgs, User ActingUser) : IRequest>, IReportQuery; + +internal class SupportReportQueryHandler : IRequestHandler> +{ + private readonly IReportsService _reportsService; + private readonly ISupportReportService _service; + private readonly ReportsQueryValidator _validator; + + public SupportReportQueryHandler + ( + IReportsService reportsService, + ISupportReportService service, + ReportsQueryValidator validator + ) + { + _reportsService = reportsService; + _service = service; + _validator = validator; + } + + public async Task> Handle(SupportReportQuery request, CancellationToken cancellationToken) + { + await _validator.Validate(request); + + return _reportsService.BuildResults(new ReportRawResults + { + PagingArgs = request.PagingArgs, + ParameterSummary = null, + Records = await _service.QueryRecords(request.Parameters), + ReportKey = ReportKey.Support, + Title = "Support Report" + }); + } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReportExport.cs b/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReportExport.cs new file mode 100644 index 00000000..0894b6ca --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReportExport.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using MediatR; + +namespace Gameboard.Api.Features.Reports; + +public record SupportReportExportQuery(SupportReportParameters Parameters) : IRequest>; + +internal class SupportReportExportQueryHandler : IRequestHandler> +{ + private readonly IMapper _mapper; + private readonly ISupportReportService _service; + + public SupportReportExportQueryHandler(IMapper mapper, ISupportReportService service) + { + _mapper = mapper; + _service = service; + } + + public async Task> Handle(SupportReportExportQuery request, CancellationToken cancellationToken) + => _mapper.Map>(await _service.QueryRecords(request.Parameters)); +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReportModels.cs b/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReportModels.cs new file mode 100644 index 00000000..172080c5 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReportModels.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using Gameboard.Api.Common; + +namespace Gameboard.Api.Features.Reports; + +public enum SupportReportTicketWindow +{ + BusinessHours, + EveningHours, + OffHours +} + +public enum SupportReportLabelsModifier +{ + HasAll, + HasAny +} + +public class SupportReportParameters +{ + public string ChallengeSpecId { get; set; } + public string GameId { get; set; } + public string Labels { get; set; } + public double? MinutesSinceOpen { get; set; } + public double? MinutesSinceUpdate { get; set; } + public DateTimeOffset? OpenedDateStart { get; set; } + public DateTimeOffset? OpenedDateEnd { get; set; } + public SupportReportTicketWindow? OpenedWindow { get; set; } + public string Statuses { get; set; } +} + +public class SupportReportRecord +{ + public required int Key { get; set; } + public required string PrefixedKey { get; set; } + public required DateTimeOffset CreatedOn { get; set; } + public required DateTimeOffset? UpdatedOn { get; set; } + public required SimpleEntity AssignedTo { get; set; } + public required SimpleEntity CreatedBy { get; set; } + public required SimpleEntity UpdatedBy { get; set; } + public required SimpleEntity RequestedBy { get; set; } + public required SimpleEntity Game { get; set; } + public required SimpleEntity Challenge { get; set; } + public required IEnumerable AttachmentUris { get; set; } + public required IEnumerable Labels { get; set; } + public required string Summary { get; set; } + public required string Status { get; set; } + public required int ActivityCount { get; set; } +} + +public class SupportReportExportRecord +{ + public required string PrefixedKey { get; set; } + public DateTimeOffset CreatedOn { get; set; } + public DateTimeOffset UpdatedOn { get; set; } + public string AssignedTo { get; set; } + public string CreatedBy { get; set; } + public string UpdatedBy { get; set; } + public string RequestedBy { get; set; } + public string Game { get; set; } + public string Attachments { get; set; } + public string Labels { get; set; } + public string Summary { get; set; } + public string Status { get; set; } + public int ActivityCount { get; set; } +} diff --git a/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReportService.cs new file mode 100644 index 00000000..5c514a34 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/Queries/SupportReport/SupportReportService.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AutoMapper; +using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Common; +using Gameboard.Api.Services; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Reports; + +public interface ISupportReportService +{ + SupportReportTicketWindow GetTicketDateSupportWindow(DateTimeOffset ticketDate); + Task> QueryRecords(SupportReportParameters parameters); +} + +internal class SupportReportService : ISupportReportService +{ + private readonly IJsonService _jsonService; + private readonly IMapper _mapper; + private readonly INowService _now; + private readonly IReportsService _reportsService; + private readonly TicketService _ticketService; + private readonly ITicketStore _ticketStore; + + public SupportReportService + ( + IJsonService jsonService, + IMapper mapper, + INowService now, + IReportsService reportsService, + TicketService ticketService, + ITicketStore ticketStore + ) + { + _jsonService = jsonService; + _mapper = mapper; + _now = now; + _reportsService = reportsService; + _ticketService = ticketService; + _ticketStore = ticketStore; + } + + public async Task> QueryRecords(SupportReportParameters parameters) + { + // format parameters + DateTimeOffset? openedDateStart = parameters.OpenedDateStart.HasValue ? parameters.OpenedDateStart.Value.ToUniversalTime() : null; + DateTimeOffset? openedDateEnd = parameters.OpenedDateEnd.HasValue ? parameters.OpenedDateEnd.Value.ToUniversalTime() : null; + var labels = _reportsService.ParseMultiSelectCriteria(parameters.Labels); + var statuses = _reportsService.ParseMultiSelectCriteria(parameters.Statuses); + + var query = _ticketStore + .ListWithNoTracking() + .Include(t => t.Assignee) + .Include(t => t.Challenge) + .Include(t => t.Creator) + .Include(t => t.Player) + .ThenInclude(p => p.Game) + .Include(t => t.Requester) + .Include(t => t.Activity.OrderBy(a => a.Timestamp)) + .Where(t => true); + + if (parameters.ChallengeSpecId.NotEmpty()) + query = query.Where(t => t.Challenge != null && t.Challenge.SpecId == parameters.ChallengeSpecId); + + if (parameters.GameId.NotEmpty()) + query = query.Where(t => t.Player != null && t.Player.GameId == parameters.GameId); + + if (openedDateStart is not null) + query = query + .Where(t => t.Created >= openedDateStart); + + if (openedDateEnd != null) + query = query + .Where(t => t.Created <= openedDateEnd); + + var rightNow = _now.Get(); + if (parameters.MinutesSinceOpen is not null) + { + var openSince = rightNow.Subtract(TimeSpan.FromMinutes(parameters.MinutesSinceOpen.Value)); + query = query.Where(t => t.Created <= openSince); + } + + if (parameters.MinutesSinceUpdate is not null) + { + var notUpdatedSince = rightNow.Subtract(TimeSpan.FromMinutes(parameters.MinutesSinceUpdate.Value)); + query = query + .Where(t => t.LastUpdated <= notUpdatedSince); + } + + if (statuses.IsNotEmpty()) + query = query.Where(t => statuses.Contains(t.Status.ToLower())); + + var results = await query + .OrderBy(t => t.Created) + .ToListAsync(); + + // client side processing + IEnumerable records = results.Select(t => new SupportReportRecord + { + Key = t.Key, + PrefixedKey = _ticketService.TransformTicketKey(t.Key), + CreatedOn = t.Created, + UpdatedOn = t.LastUpdated, + Summary = t.Summary, + Status = t.Status, + AssignedTo = _mapper.Map(t.Assignee), + CreatedBy = _mapper.Map(t.Creator), + UpdatedBy = t.Activity.Count() > 0 ? _mapper.Map(t.Activity.OrderBy(a => a.Timestamp).Last().User) : null, + RequestedBy = _mapper.Map(t.Requester), + Game = t.Player == null ? null : _mapper.Map(t.Player.Game), + Challenge = _mapper.Map(t.Challenge), + AttachmentUris = _jsonService.Deserialize>(t.Attachments), + Labels = _ticketService.TransformTicketLabels(t.Label), + ActivityCount = t.Activity.Count() + }); + + // we have to do labels in .net land, because they're stored as space-delimited values in a single column + if (labels.IsNotEmpty()) + records = records.Where(r => labels.Any(l => r.Labels.Any(r => r == l))); + + if (parameters.OpenedWindow != null) + records = records.Where(r => GetTicketDateSupportWindow(r.CreatedOn) == parameters.OpenedWindow); + + return records; + } + + public SupportReportTicketWindow GetTicketDateSupportWindow(DateTimeOffset ticketDate) + { + var localizedHours = ticketDate.ToLocalTime().Hour; + + if (localizedHours < 8) + return SupportReportTicketWindow.OffHours; + + if (localizedHours < 17) + return SupportReportTicketWindow.BusinessHours; + + return SupportReportTicketWindow.EveningHours; + } +} diff --git a/src/Gameboard.Api/Features/Reports/ReportConditionExtensions.cs b/src/Gameboard.Api/Features/Reports/ReportConditionExtensions.cs new file mode 100644 index 00000000..3a95fdcd --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/ReportConditionExtensions.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; + +namespace Gameboard.Api.Features.Reports; + +internal static class ReportConditionExtensions +{ + public static IQueryable WhereMeetsCriteria(this IQueryable queryable, Func propertyValue, IEnumerable> criteria) + { + foreach (var criterion in criteria) + { + queryable = queryable.Where(q => criterion(propertyValue(q))); + } + + return queryable; + } + + public static IQueryable WithConditions(this IQueryable queryable, params Expression>[] conditions) + { + return queryable.WithConditions(conditions.AsEnumerable()); + } + + public static IQueryable WithConditions(this IQueryable queryable, IEnumerable>> conditions) + { + foreach (var condition in conditions) + queryable = queryable.Where(condition).AsQueryable(); + + return queryable; + } +} diff --git a/src/Gameboard.Api/Features/Reports/ReportModels.cs b/src/Gameboard.Api/Features/Reports/ReportModels.cs new file mode 100644 index 00000000..e6752d92 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/ReportModels.cs @@ -0,0 +1,227 @@ +// Copyright 2021 Carnegie Mellon University. All Rights Reserved. +// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. + +using System; + +namespace Gameboard.Api; + +public class SponsorReport +{ + public string Title { get; set; } = "Sponsor Report"; + public DateTime Timestamp { get; set; } + public SponsorStat[] Stats { get; set; } +} + +public class GameSponsorReport +{ + public string Title { get; set; } = "Game Sponsor Report"; + public DateTime Timestamp { get; set; } + public GameSponsorStat[] Stats { get; set; } +} + +public class UserReport +{ + public string Title { get; set; } = "User Report"; + public DateTime Timestamp { get; set; } + public int EnrolledUserCount { get; set; } + public int UnenrolledUserCount { get; set; } +} + +public class PlayerReport +{ + public string Title { get; set; } = "Player Report"; + public DateTime Timestamp { get; set; } + public PlayerStat[] Stats { get; set; } +} + +public class PlayerStat +{ + public string GameId { get; set; } + public string GameName { get; set; } + public int PlayerCount { get; set; } + public int SessionPlayerCount { get; set; } +} + +public class SponsorStat +{ + public string Id { get; set; } + public string Name { get; set; } + public string Logo { get; set; } + public int Count { get; set; } + public int TeamCount { get; set; } +} + +public class GameSponsorStat +{ + public string GameId { get; set; } + public string GameName { get; set; } + public SponsorStat[] Stats { get; set; } +} + +public class ChallengeReport +{ + public string Title { get; set; } = "Challenge Report"; + public DateTimeOffset Timestamp { get; set; } + public ChallengeStat[] Stats { get; set; } +} + +public class ChallengeStat +{ + public string Id { get; set; } + public string Name { get; set; } + public string Tag { get; set; } + public int Points { get; set; } + public int SuccessCount { get; set; } + public int PartialCount { get; set; } + public string AverageTime { get; set; } + public int AttemptCount { get; set; } + public int AverageScore { get; set; } +} + +public class ChallengeDetailReport +{ + public string Title { get; set; } = "Challenge Detail Report"; + public DateTime Timestamp { get; set; } + public Part[] Parts { get; set; } + public int AttemptCount { get; set; } + public string ChallengeId { get; set; } +} + +#region Ticket Reports +public class TicketDetail +{ + public int Key { get; set; } + public string Summary { get; set; } + public string Description { get; set; } + public string Challenge { get; set; } + public string GameSession { get; set; } + public string Team { get; set; } + public string Assignee { get; set; } + public string Requester { get; set; } + public string Creator { get; set; } + public DateTimeOffset Created { get; set; } + public DateTimeOffset LastUpdated { get; set; } + public string Label { get; set; } + public string Status { get; set; } +} + +#endregion + +public class ParticipationReportV1 +{ + public string Key { get; set; } = "Participation"; + public DateTime Timestamp { get; set; } + public ParticipationStat[] Stats { get; set; } +} + +public class ParticipationStat +{ + public string Key { get; set; } + public int GameCount { get; set; } + public int PlayerCount { get; set; } + public int SessionPlayerCount { get; set; } + public int TeamCount { get; set; } + public int SessionTeamCount { get; set; } + public int ChallengesDeployedCount { get; set; } +} + +public class SeriesReport : ParticipationReportV1 +{ + public SeriesReport() + { + Key = "Series"; + } +} + +public class TrackReport : ParticipationReportV1 +{ + public TrackReport() + { + Key = "Track"; + } +} + +public class SeasonReport : ParticipationReportV1 +{ + public SeasonReport() + { + Key = "Season"; + } +} + +public class DivisionReport : ParticipationReportV1 +{ + public DivisionReport() + { + Key = "Division"; + } +} + +public class ModeReport : ParticipationReportV1 +{ + public ModeReport() + { + Key = "Mode"; + } +} + +public class CorrelationReport +{ + public string Title { get; set; } = "Correlation Report"; + public DateTime Timestamp { get; set; } + public CorrelationStat[] Stats { get; set; } +} + +public class CorrelationStat +{ + public int GameCount { get; set; } + public int UserCount { get; set; } +} + +public class Part +{ + public string Text { get; set; } + public int SolveCount { get; set; } + public int AttemptCount { get; set; } + public float Weight { get; set; } +} + +public class ChallengeStatsExport +{ + public string GameName { get; set; } + public string ChallengeName { get; set; } + public string Tag { get; set; } + public string Points { get; set; } + public string Attempts { get; set; } + public string Complete { get; set; } + public string Partial { get; set; } + public string AvgTime { get; set; } + public string AvgScore { get; set; } +} + +public class ChallengeDetailsExport +{ + public string GameName { get; set; } + public string ChallengeName { get; set; } + public string Tag { get; set; } + public string Question { get; set; } + public string Points { get; set; } + public string Solves { get; set; } +} + +public class TicketDetailsExport +{ + public string Key { get; set; } + public string Summary { get; set; } + public string Description { get; set; } + public string Challenge { get; set; } + public string GameSession { get; set; } + public string Team { get; set; } + public string Assignee { get; set; } + public string Requester { get; set; } + public string Creator { get; set; } + public string Created { get; set; } + public string LastUpdated { get; set; } + public string Label { get; set; } + public string Status { get; set; } +} diff --git a/src/Gameboard.Api/Features/Report/ReportService.cs b/src/Gameboard.Api/Features/Reports/ReportService.cs similarity index 99% rename from src/Gameboard.Api/Features/Report/ReportService.cs rename to src/Gameboard.Api/Features/Reports/ReportService.cs index ca8ce369..8e7d7676 100644 --- a/src/Gameboard.Api/Features/Report/ReportService.cs +++ b/src/Gameboard.Api/Features/Reports/ReportService.cs @@ -8,11 +8,10 @@ using Gameboard.Api.Data.Abstractions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using TopoMojo.Api.Client; namespace Gameboard.Api.Services { - public class ReportService : _Service + public class ReportServiceLegacy : _Service { GameboardDbContext Store { get; } ITicketStore TicketStore { get; } @@ -21,8 +20,8 @@ public class ReportService : _Service string blankName = "N/A"; - public ReportService( - ILogger logger, + public ReportServiceLegacy( + ILogger logger, IMapper mapper, CoreOptions options, Defaults defaults, diff --git a/src/Gameboard.Api/Features/Reports/ReportsController.cs b/src/Gameboard.Api/Features/Reports/ReportsController.cs new file mode 100644 index 00000000..dda391f6 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/ReportsController.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Gameboard.Api.Common; +using Gameboard.Api.Services; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Gameboard.Api.Features.Reports; + +[Authorize] +[Route("/api/reports")] +public class ReportsController : ControllerBase +{ + private readonly User _actingUser; + private readonly IMediator _mediator; + private readonly IReportsService _service; + + public ReportsController + ( + IActingUserService actingUserService, + IMediator mediator, + IReportsService service + ) + { + _actingUser = actingUserService.Get(); + _mediator = mediator; + _service = service; + } + + [HttpGet] + public async Task> List() + => await _service.List(); + + [HttpGet("challenges-report")] + public Task> GetChallengeReport([FromQuery] GetChallengesReportQueryArgs args) + => _mediator.Send(new ChallengesReportQuery(args)); + + [HttpGet("enrollment")] + public Task> GetEnrollmentReport([FromQuery] EnrollmentReportParameters parameters, [FromQuery] PagingArgs paging) + => _mediator.Send(new EnrollmentReportQuery(parameters, paging)); + + [HttpGet("enrollment/trend")] + public Task> GetEnrollmentReportLineChart([FromQuery] EnrollmentReportParameters parameters) + => _mediator.Send(new EnrollmentReportLineChartQuery(parameters)); + + [HttpGet("practice-area")] + public async Task> GetPracticeModeReport([FromQuery] PracticeModeReportParameters parameters, [FromQuery] PagingArgs paging) + => await _mediator.Send(new PracticeModeReportQuery(parameters, _actingUser, paging)); + + [HttpGet("practice-area/user/{id}/summary")] + public async Task GetPracticeModeReportPlayerModeSummary([FromRoute] string id, [FromQuery] bool isPractice) + => await _mediator.Send(new PracticeModeReportPlayerModeSummaryQuery(id, isPractice, _actingUser)); + + [HttpGet("players-report")] + public async Task> GetPlayersReport([FromQuery] PlayersReportQueryParameters reportParams) + => await _mediator.Send(new PlayersReportQuery(reportParams)); + + [HttpGet("support")] + public async Task> GetSupportReport([FromQuery] SupportReportParameters reportParams, [FromQuery] PagingArgs pagingArgs) + => await _mediator.Send(new SupportReportQuery(reportParams, pagingArgs, _actingUser)); + + [HttpGet("metaData")] + public async Task GetReportMetaData([FromQuery] string reportKey) + => await _mediator.Send(new GetMetaDataQuery(reportKey)); + + [HttpGet("parameter/challenge-specs/{gameId?}")] + public Task> GetChallengeSpecs(string gameId = null) + => _service.ListChallengeSpecs(gameId); + + [HttpGet("parameter/games")] + public Task> GetGames() + => _service.ListGames(); + + [HttpGet("parameter/seasons")] + public Task> GetSeasons() + => _service.ListSeasons(); + + [HttpGet("parameter/sponsors")] + public Task> GetSponsors() + => _service.ListSponsors(); + + [HttpGet("parameter/series")] + public Task> GetSeries() + => _service.ListSeries(); + + [HttpGet("parameter/ticket-statuses")] + public Task> GetTicketStatuses() + => _service.ListTicketStatuses(); + + [HttpGet("parameter/tracks")] + public Task> GetTracks() + => _service.ListTracks(); +} diff --git a/src/Gameboard.Api/Features/Reports/ReportsExportController.cs b/src/Gameboard.Api/Features/Reports/ReportsExportController.cs new file mode 100644 index 00000000..da252945 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/ReportsExportController.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Services; +using Gameboard.Api.Structure; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Gameboard.Api.Features.Reports; + +[Authorize] +[Route("/api/reports/export")] +public class ReportsExportController : ControllerBase +{ + private readonly User _actingUser; + private readonly IMediator _mediator; + + public ReportsExportController(IActingUserService actingUserService, IMediator mediator) + { + _actingUser = actingUserService.Get(); + _mediator = mediator; + } + + [HttpGet("challenges")] + [ProducesResponseType(typeof(FileContentResult), 200)] + public async Task GetChallengesReport(GetChallengesReportQueryArgs parameters) + { + var results = await _mediator.Send(new ChallengesReportExportQuery(parameters)); + return new FileContentResult(GetReportExport(results), MimeTypes.TextCsv); + } + + [HttpGet("enrollment")] + [ProducesResponseType(typeof(FileContentResult), 200)] + public async Task GetEnrollmentReportExport(EnrollmentReportParameters parameters) + { + var results = await _mediator.Send(new EnrollmentReportExportQuery(parameters)); + return new FileContentResult(GetReportExport(results), MimeTypes.TextCsv); + } + + [HttpGet("players")] + [ProducesResponseType(typeof(FileContentResult), 200)] + public async Task GetPlayersReport(PlayersReportQueryParameters parameters) + { + var results = await _mediator.Send(new PlayersReportExportQuery(parameters)); + return new FileContentResult(GetReportExport(results), MimeTypes.TextCsv); + } + + [HttpGet("practice-area")] + [ProducesResponseType(typeof(FileContentResult), 200)] + public async Task GetPracticeModeReportExport(PracticeModeReportParameters parameters, CancellationToken cancellationToken) + { + var results = await _mediator.Send(new PracticeModeReportCsvExportQuery(parameters, _actingUser), cancellationToken); + return new FileContentResult(GetReportExport(results), MimeTypes.TextCsv); + } + + [HttpGet("support-report")] + [ProducesResponseType(typeof(FileContentResult), 200)] + public async Task GetSupportReport(SupportReportParameters parameters) + { + var results = await _mediator.Send(new SupportReportExportQuery(parameters)); + return new FileContentResult(GetReportExport(results), MimeTypes.TextCsv); + } + + private byte[] GetReportExport(IEnumerable records) + { + var csvText = ServiceStack.StringExtensions.ToCsv(records); + return Encoding.UTF8.GetBytes(csvText.ToString()); + } +} diff --git a/src/Gameboard.Api/Features/Reports/ReportsMaps.cs b/src/Gameboard.Api/Features/Reports/ReportsMaps.cs new file mode 100644 index 00000000..6093c6a2 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/ReportsMaps.cs @@ -0,0 +1,22 @@ +using AutoMapper; + +namespace Gameboard.Api.Features.Reports; + +public class ReportsMaps : Profile +{ + public ReportsMaps() + { + CreateMap() + .ForMember(d => d.ChallengeSpecId, opt => opt.MapFrom(s => s.ChallengeSpec.Id)) + .ForMember(d => d.ChallengeSpecName, opt => opt.MapFrom(s => s.ChallengeSpec.Name)) + .ForMember(d => d.GameId, opt => opt.MapFrom(s => s.Game.Id)) + .ForMember(d => d.GameName, opt => opt.MapFrom(s => s.Game.Name)) + .ForMember(d => d.FastestSolvePlayerId, opt => opt.MapFrom(s => s.FastestSolve.Player.Id)) + .ForMember(d => d.FastestSolvePlayerName, opt => opt.MapFrom(s => s.FastestSolve.Player.Name)) + .ForMember(d => d.FastestSolveTimeMs, opt => opt.MapFrom(s => s.FastestSolve.SolveTimeMs)); + + CreateMap() + .ForMember(d => d.Attachments, opt => opt.MapFrom(s => string.Join("\n", s.AttachmentUris))) + .ForMember(d => d.Labels, opt => opt.MapFrom(s => string.Join(", ", s.Labels))); + } +} diff --git a/src/Gameboard.Api/Features/Reports/ReportsModels.cs b/src/Gameboard.Api/Features/Reports/ReportsModels.cs new file mode 100644 index 00000000..4bd00015 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/ReportsModels.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading; +using Gameboard.Api.Common; +using Gameboard.Api.Data; + +namespace Gameboard.Api.Features.Reports; + +public static class ReportKey +{ + public static string Challenges { get; } = "challenges"; + public static string Enrollment { get; } = "enrollment"; + public static string Players { get; } = "players"; + public static string PracticeArea { get; } = "practice-area"; + public static string Support { get; } = "support"; +} + +public interface IReportQuery +{ + public User ActingUser { get; } +} + +public sealed class ReportViewModel +{ + public required string Key { get; set; } + public required string Name { get; set; } + public required string Description { get; set; } + public required IEnumerable ExampleFields { get; set; } + public required IEnumerable ExampleParameters { get; set; } +} + +public sealed class ReportMetaData +{ + public required string Key { get; set; } + public required string Title { get; set; } + public string ParametersSummary { get; set; } + public required DateTimeOffset RunAt { get; set; } +} + +public sealed class ReportRawResults +{ + public required PagingArgs PagingArgs { get; set; } + public required string ParameterSummary { get; set; } + public required IEnumerable Records { get; set; } + public required string ReportKey { get; set; } + public required string Title { get; set; } +} + +public sealed class ReportRawResults +{ + public required TOverallStats OverallStats { get; set; } + public required PagingArgs PagingArgs { get; set; } + public required string ParameterSummary { get; set; } + public required IEnumerable Records { get; set; } + public required string ReportKey { get; set; } + public required string Title { get; set; } +} + +public interface IReportPredicateProvider +{ + IEnumerable>> GetPredicates(TProperty entityExpression) where TData : IEntity; +} + +public sealed class ReportDateRange // : IReportPredicateProvider +{ + public DateTimeOffset? DateStart { get; set; } = null; + public bool HasDateStartValue { get => DateStart != null && ((DateTimeOffset)DateStart).HasValue(); } + + public DateTimeOffset? DateEnd { get; set; } = null; + public bool HasDateEndValue { get => DateEnd != null && ((DateTimeOffset)DateStart).HasValue(); } + + // public IEnumerable>> GetPredicates(Func value) where TData : IEntity + // { + // if (DateStart != null) + // yield return d => new Expression>(d => value(d) >= DateStart.Value); + + // if (DateEnd != null) + // yield return d => d <= DateEnd.Value; + // } +} + +public sealed class ReportScoreRange : IReportPredicateProvider +{ + public double? Min { get; set; } = null; + public double? Max { get; set; } = null; + + public IEnumerable>> GetPredicates(double entityExpression) where TData : IEntity + { + if (Min != null) + yield return v => v >= Min.Value; + + if (Max != null) + yield return v => v <= Max.Value; + } +} + +public sealed class ReportResults +{ + public required ReportMetaData MetaData { get; set; } + public required PagingResults Paging { get; set; } + public required IEnumerable Records { get; set; } +} + +public sealed class ReportResults +{ + public required ReportMetaData MetaData { get; set; } + public required PagingResults Paging { get; set; } + public required IEnumerable Records { get; set; } + public required TOverallStats OverallStats { get; set; } +} diff --git a/src/Gameboard.Api/Features/Reports/ReportsService.cs b/src/Gameboard.Api/Features/Reports/ReportsService.cs new file mode 100644 index 00000000..f25c1567 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/ReportsService.cs @@ -0,0 +1,449 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using Gameboard.Api.Common; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Teams; +using Gameboard.Api.Services; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Reports; + +public interface IReportsService +{ + ReportResults BuildResults(ReportRawResults rawResults); + ReportResults BuildResults(ReportRawResults rawResults); + Task> GetChallengesReportRecords(GetChallengesReportQueryArgs parameters); + Task> GetTeamsByPlayerIds(IEnumerable playerIds, CancellationToken cancellationToken); + Task> List(); + Task> ListChallengeSpecs(string gameId = null); + Task> ListGames(); + Task> ListSeasons(); + Task> ListSeries(); + Task> ListSponsors(); + Task> ListTracks(); + Task> ListTicketStatuses(); + IEnumerable ParseMultiSelectCriteria(string criteria); +} + +public class ReportsService : IReportsService +{ + private static readonly string MULTI_SELECT_DELIMITER = ","; + public static readonly PagingArgs DEFAULT_PAGING = new() + { + PageNumber = 0, + PageSize = 20, + }; + + private readonly IMapper _mapper; + private readonly INowService _now; + private readonly IPagingService _paging; + private readonly IStore _store; + private readonly ITeamService _teamService; + + public ReportsService + ( + IMapper mapper, + INowService now, + IPagingService paging, + IStore store, + ITeamService teamService + ) + { + _mapper = mapper; + _now = now; + _paging = paging; + _store = store; + _teamService = teamService; + } + + public Task> List() + { + var reports = new ReportViewModel[] + { + new ReportViewModel + { + Name = "Enrollment", + Key = ReportKey.Enrollment, + Description = "View a summary of player enrollment - who enrolled when, which sponsors do they a represent, and how many of them actually played challenges.", + ExampleFields = new string[] + { + "Player Info", + "Games Enrolled", + "Sessions Launched", + "Challenge Performance", + "Sponsor" + }, + ExampleParameters = new string[] + { + "Enrollment Date Range", + "Season", + "Series", + "Sponsor", + "Track", + "Game & Challenge" + } + }, + new ReportViewModel + { + 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", + "Scoring", + "Trends" + }, + ExampleParameters = new string[] + { + "Practice Date", + "Series", + "Track", + "Season", + "Game", + "Sponsor" + } + }, + new ReportViewModel + { + 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", + "Challenge", + "Time Windows", + "Assignment Info" + }, + ExampleParameters = new string[] + { + "Challenge", + "Creation Date", + "Ticket Category", + "Time Window", + } + }, + }; + + return Task.FromResult>(reports); + } + + /// + /// Given a list of player Ids, return a dictionary of the teams that those players are assigned to. + /// + /// Note that the string key of the dictionary is the player's teamId, NOT their playerId. + /// + /// + /// + /// + public async Task> GetTeamsByPlayerIds(IEnumerable playerIds, CancellationToken cancellationToken) + { + var sponsors = await _store + .List() + .Select(s => new ReportSponsorViewModel + { + Id = s.Id, + Name = s.Name, + LogoFileName = s.Logo + }) + .ToDictionaryAsync(s => s.LogoFileName, s => s, cancellationToken); + + var teamPlayers = await _store + .List() + .Where(p => playerIds.Contains(p.Id)) + .ToArrayAsync(cancellationToken); + + // note that none of this actually runs back to the DB, but this was difficult + // to do with typical projection because ResolveCaptain is async to handle + // a signature that accepts a teamId + var teamDict = new Dictionary(); + foreach (var team in teamPlayers.GroupBy(p => p.TeamId)) + { + var captain = await _teamService.ResolveCaptain(team.ToList()); + + teamDict.Add(team.Key, new ReportTeamViewModel + { + Id = captain.TeamId, + Name = captain.ApprovedName, + Captain = new SimpleEntity { Id = captain.Id, Name = captain.Name }, + Players = team.Select(p => new SimpleEntity { Id = p.Id, Name = p.ApprovedName }), + Sponsors = team + .OrderBy(p => p.IsManager ? 0 : 1) + .Select(p => p.Sponsor.IsEmpty() ? null : new ReportSponsorViewModel + { + Id = sponsors[p.Sponsor].Id, + Name = sponsors[p.Sponsor].Name, + LogoFileName = p.Sponsor + }) + }); + } + + return teamDict; + } + + public async Task> ListChallengeSpecs(string gameId) + { + var query = _store.List(); + + if (gameId.NotEmpty()) + query = query.Where(c => c.GameId == gameId); + + return await query.Select(c => new SimpleEntity { Id = c.Id, Name = c.Name }) + .OrderBy(s => s.Name) + .ToArrayAsync(); + } + + public Task> ListSeasons() + => GetGameStringPropertyOptions(g => g.Season); + + public async Task> ListSeries() + => + ( + await _store + .List() + .Select(g => g.Competition) + .Distinct() + .Where(c => c != null && c != "") + .ToArrayAsync() + ).Where(s => s.NotEmpty()); + + public async Task> ListGames() + => await _store.List() + .Select(g => new SimpleEntity { Id = g.Id, Name = g.Name }) + .ToArrayAsync(); + + public async Task> ListSponsors() + => await _store.List() + .Select(s => new ReportSponsorViewModel + { + Id = s.Id, + Name = s.Name, + LogoFileName = s.Logo + }) + .OrderBy(s => s.Name) + .ToArrayAsync(); + + public Task> ListTracks() + => GetGameStringPropertyOptions(g => g.Track); + + public async Task> ListTicketStatuses() + { + return await _store.List() + .Select(t => t.Status) + .Distinct() + .ToArrayAsync(); + } + + public IEnumerable ParseMultiSelectCriteria(string criteria) + { + if (criteria.IsEmpty()) + return Array.Empty(); + + return criteria + .ToLower() + .Split(MULTI_SELECT_DELIMITER, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + public ReportResults BuildResults(ReportRawResults rawResults) + { + var pagedResults = _paging.Page(rawResults.Records, rawResults.PagingArgs); + + return new ReportResults + { + MetaData = new ReportMetaData + { + Title = rawResults.Title, + Key = rawResults.ReportKey, + ParametersSummary = null, + RunAt = _now.Get() + }, + Paging = pagedResults.Paging, + Records = pagedResults.Items + }; + } + + public ReportResults BuildResults(ReportRawResults rawResults) + { + var pagedResults = _paging.Page(rawResults.Records, rawResults.PagingArgs); + + return new ReportResults + { + MetaData = new ReportMetaData + { + Title = rawResults.Title, + Key = rawResults.ReportKey, + ParametersSummary = null, + RunAt = _now.Get() + }, + OverallStats = rawResults.OverallStats, + Paging = pagedResults.Paging, + Records = pagedResults.Items + }; + } + + public async Task> GetChallengesReportRecords(GetChallengesReportQueryArgs args) + { + // TODO: validation + var hasCompetition = args.Competition.NotEmpty(); + var hasGameId = args.GameId.NotEmpty(); + var hasSpecId = args.ChallengeSpecId.NotEmpty(); + var hasTrack = args.TrackName.NotEmpty(); + + // parameters resolve to challenge specs + var specs = await _store.List() + .Include(s => s.Game) + .Where(s => !hasCompetition || s.Game.Competition == args.Competition) + .Where(s => !hasGameId || s.GameId == args.GameId) + .Where(s => !hasTrack || s.Game.Track == args.TrackName) + .Where(s => !hasSpecId || s.Id == args.ChallengeSpecId) + .ToDictionaryAsync + ( + s => s.Id, + s => new ChallengesReportSpec + { + Id = s.Id, + Game = new SimpleEntity { Id = s.GameId, Name = s.Game.Name }, + Name = s.Name, + MaxPoints = s.Points + } + ); + + var specIds = specs.Keys; + var gameIds = specs.Values.Select(v => v.Game.Id).Distinct(); + + // this separate because players may be registered but not deploy this challenge, and we need to know that for reporting + var allPlayers = await _store.List() + .Where(p => gameIds.Contains(p.GameId)) + .ToArrayAsync(); + + var challenges = await _store.List() + .Include(c => c.Game) + .Include(c => c.Player) + .Include(c => c.Tickets) + .Where(c => specIds.Contains(c.SpecId)) + .Select(c => new ChallengesReportChallenge + { + Challenge = _mapper.Map(c), + Game = _mapper.Map(c.Game), + Player = new ChallengesReportPlayer + { + Player = new SimpleEntity { Id = c.PlayerId, Name = c.Player.ApprovedName }, + StartTime = c.Player.SessionBegin, + EndTime = c.Player.SessionEnd, + Result = c.Result, + SolveTimeMs = + ( + c.StartTime == DateTimeOffset.MinValue || c.LastScoreTime == DateTimeOffset.MinValue ? + null : + (c.LastScoreTime - c.StartTime).TotalMilliseconds + ), + Score = c.Player.Score + }, + SpecId = c.SpecId, + TicketCount = c.Tickets.Count() + }).ToArrayAsync(); + + var challengesBySpec = challenges + .GroupBy(c => c.SpecId) + .ToDictionary(c => c.Key, c => c.ToList()); + + // computed columns + var meanStats = challenges + .Where(c => c.Player.SolveTimeMs != null) + .GroupBy(c => c.SpecId) + .ToDictionary + ( + c => c.Key, + c => + { + var playersCompleteSolved = c.Where(p => p.Player.Result == ChallengeResult.Success); + + var meanCompleteSolveTime = playersCompleteSolved.Any() ? playersCompleteSolved.Average(p => p.Player.SolveTimeMs.Value) : null as double?; + var meanScore = c.Any() ? c.Average(c => c.Player.Score) : null as double?; + + return new ChallengesReportMeanChallengeStats + { + MeanCompleteSolveTimeMs = meanCompleteSolveTime, + MeanScore = meanScore + }; + } + ); + + var fastestSolves = challenges + .Where(c => c.Player.SolveTimeMs != null) + .Select(c => new + { + c.SpecId, + c.Player.Player, + SolveTime = c.Player.SolveTimeMs.Value + }) + .GroupBy(specPlayerSolve => specPlayerSolve.SpecId) + .ToDictionary(c => c.Key, c => c.Select(c => new ChallengesReportPlayerSolve + { + Player = c.Player, + SolveTimeMs = c.SolveTime + }).ToList().FirstOrDefault()); + + return specs.Values.Select(spec => BuildRecord(spec, allPlayers, challengesBySpec, fastestSolves, meanStats)); + } + + private ChallengesReportRecord BuildRecord + ( + ChallengesReportSpec spec, + Data.Player[] allPlayers, + Dictionary> challengesBySpec, + Dictionary fastestSolves, + Dictionary meanStats + ) + { + var hasChallenges = challengesBySpec.ContainsKey(spec.Id); + var challenge = hasChallenges ? challengesBySpec[spec.Id].Where(s => s.Game.Id == spec.Game.Id).FirstOrDefault() : null; + var hasSolves = fastestSolves.ContainsKey(spec.Id); + var hasStats = meanStats.ContainsKey(spec.Id); + + return new ChallengesReportRecord + { + ChallengeSpec = new SimpleEntity { Id = spec.Id, Name = spec.Name }, + Game = new SimpleEntity { Id = spec.Game.Id, Name = spec.Game.Name }, + Challenge = challenge == null ? null : new SimpleEntity { Id = challenge.Challenge.Id, Name = challenge.Challenge.Name }, + PlayersEligible = allPlayers + .Where(p => p.GameId == spec.Game.Id) + .Count(), + PlayersStarted = !hasChallenges ? 0 : challengesBySpec[spec.Id] + .Where(c => c.Player.StartTime > DateTimeOffset.MinValue) + .Count(), + PlayersWithCompleteSolve = !hasChallenges ? 0 : challengesBySpec[spec.Id] + .Where(p => p.Player.Result == ChallengeResult.Success) + .Count(), + PlayersWithPartialSolve = !hasChallenges ? 0 : challengesBySpec[spec.Id] + .Where(p => p.Player.Result == ChallengeResult.Partial) + .Count(), + FastestSolve = !hasSolves ? null : fastestSolves[spec.Id], + MaxPossibleScore = spec.MaxPoints, + MeanCompleteSolveTimeMs = !hasStats ? null : meanStats[spec.Id].MeanCompleteSolveTimeMs, + MeanScore = !hasStats ? null : meanStats[spec.Id].MeanScore, + TicketCount = hasChallenges ? challengesBySpec[spec.Id].Select(c => c.TicketCount).Sum() : 0 + }; + } + + private async Task> GetGameStringPropertyOptions(Expression> property) + => + ( + await _store + .List() + .Select(property) + .Distinct() + // catch as many blanks as we can here, but have to use + // client side eval to distinguish long blanks + .Where(s => s != null && s != string.Empty) + .OrderBy(s => s) + .ToArrayAsync() + ).Where(s => s.NotEmpty()); +} diff --git a/src/Gameboard.Api/Features/Report/ReportController.cs b/src/Gameboard.Api/Features/Reports/ReportsV1Controller.cs similarity index 93% rename from src/Gameboard.Api/Features/Report/ReportController.cs rename to src/Gameboard.Api/Features/Reports/ReportsV1Controller.cs index fbef1713..156927bc 100644 --- a/src/Gameboard.Api/Features/Report/ReportController.cs +++ b/src/Gameboard.Api/Features/Reports/ReportsV1Controller.cs @@ -3,7 +3,6 @@ using System.Dynamic; using System.Globalization; using System.Linq; -using System.Text; using System.Threading.Tasks; using Gameboard.Api.Services; using Microsoft.AspNetCore.Authorization; @@ -15,18 +14,18 @@ namespace Gameboard.Api.Controllers { [Authorize] - public class ReportController: _Controller + public class ReportsV1Controller : _Controller { - public ReportController( - ILogger logger, + public ReportsV1Controller( + ILogger logger, IDistributedCache cache, - ReportService service, + ReportServiceLegacy service, GameService gameService, ChallengeSpecService challengeSpecService, FeedbackService feedbackService, TicketService ticketService, Defaults defaults - ): base(logger, cache) + ) : base(logger, cache) { Service = service; GameService = gameService; @@ -36,7 +35,7 @@ Defaults defaults Defaults = defaults; } - ReportService Service { get; } + ReportServiceLegacy Service { get; } GameService GameService { get; } FeedbackService FeedbackService { get; } ChallengeSpecService ChallengeSpecService { get; } @@ -110,7 +109,7 @@ public async Task ExportPlayerStats() List> playerStats = new List>(); playerStats.Add(new Tuple("Game", "Player Count", "Players with Sessions Count")); - foreach(PlayerStat playerStat in result.Stats) + foreach (PlayerStat playerStat in result.Stats) { playerStats.Add(new Tuple(playerStat.GameName, playerStat.PlayerCount.ToString(), playerStat.SessionPlayerCount.ToString())); } @@ -145,7 +144,7 @@ public async Task> ExportSponsorStats() List> sponsorStats = new List>(); sponsorStats.Add(new Tuple("Name", "User Count")); - foreach(SponsorStat sponsorStat in result.Stats) + foreach (SponsorStat sponsorStat in result.Stats) { sponsorStats.Add(new Tuple(sponsorStat.Name, sponsorStat.Count.ToString())); } @@ -182,7 +181,7 @@ public async Task> ExportGameSponsorsStats([From { return NotFound(); } - + if (game.MaxTeamSize > 1) { List> gameSponsorStats = new List>(); @@ -276,16 +275,33 @@ public async Task ExportChallengeStats([FromRoute] string id) } List challengeStats = new List(); - challengeStats.Add(new ChallengeStatsExport { GameName = "Game", ChallengeName = "Challenge", Tag = "Tag", Points = "Points", Attempts = "Attempts #", - Complete = "Complete(#/%)", Partial = "Partial(#/%)", AvgTime = "Avg Time", AvgScore = "Avg Score" }); + challengeStats.Add(new ChallengeStatsExport + { + GameName = "Game", + ChallengeName = "Challenge", + Tag = "Tag", + Points = "Points", + Attempts = "Attempts #", + Complete = "Complete(#/%)", + Partial = "Partial(#/%)", + AvgTime = "Avg Time", + AvgScore = "Avg Score" + }); foreach (ChallengeStat challengeStat in result.Stats) { - challengeStats.Add(new ChallengeStatsExport { - GameName = game.Name, ChallengeName = challengeStat.Name, Tag = challengeStat.Tag, Points = challengeStat.Points.ToString(), Attempts = challengeStat.AttemptCount.ToString(), + challengeStats.Add(new ChallengeStatsExport + { + GameName = game.Name, + ChallengeName = challengeStat.Name, + Tag = challengeStat.Tag, + Points = challengeStat.Points.ToString(), + Attempts = challengeStat.AttemptCount.ToString(), Complete = challengeStat.SuccessCount.ToString() + " / " + (challengeStat.SuccessCount / challengeStat.AttemptCount).ToString("P", CultureInfo.InvariantCulture), - Partial = challengeStat.PartialCount.ToString() + " / " + (challengeStat.PartialCount / challengeStat.AttemptCount).ToString("P", CultureInfo.InvariantCulture), - AvgTime = challengeStat.AverageTime, AvgScore = challengeStat.AverageScore.ToString()}); + Partial = challengeStat.PartialCount.ToString() + " / " + (challengeStat.PartialCount / challengeStat.AttemptCount).ToString("P", CultureInfo.InvariantCulture), + AvgTime = challengeStat.AverageTime, + AvgScore = challengeStat.AverageScore.ToString() + }); } return File( @@ -317,8 +333,16 @@ public async Task ExportChallengeDetails([FromRoute] string id) } List challengeDetails = new List(); - challengeDetails.Add(new ChallengeDetailsExport { GameName = "Game", ChallengeName = "Challenge", Tag = "Tag", Question = "Question", Points = "Points / % of Total", Solves = - "Solves / % of Attempts Correct" }); + challengeDetails.Add(new ChallengeDetailsExport + { + GameName = "Game", + ChallengeName = "Challenge", + Tag = "Tag", + Question = "Question", + Points = "Points / % of Total", + Solves = + "Solves / % of Attempts Correct" + }); foreach (ChallengeStat stat in result.Stats) { @@ -326,7 +350,12 @@ public async Task ExportChallengeDetails([FromRoute] string id) foreach (Part part in challengeDetail.Parts) { - challengeDetails.Add(new ChallengeDetailsExport { GameName = game.Name, ChallengeName = stat.Name, Tag = stat.Tag, Question = part.Text, + challengeDetails.Add(new ChallengeDetailsExport + { + GameName = game.Name, + ChallengeName = stat.Name, + Tag = stat.Tag, + Question = part.Text, Points = part.Weight.ToString() + " / " + (part.Weight / stat.Points).ToString("P", CultureInfo.InvariantCulture), Solves = part.SolveCount.ToString() + " / " + ((decimal)part.SolveCount / (decimal)challengeDetail.AttemptCount).ToString("P", CultureInfo.InvariantCulture) }); @@ -378,7 +407,8 @@ public async Task ExportFeedbackDetails([FromQuery] FeedbackSearc feedbackRow.Add(p.Name, (p.GetValue(response, null)?.ToString() ?? "")); } // Add each individual response as a new cell - foreach (var q in questionTemplate) { + foreach (var q in questionTemplate) + { feedbackRow.Add($"{q.Id} - {q.Prompt}", response.IdToAnswer.GetValueOrDefault(q.Id, "")); } results.Add(feedbackRow); @@ -387,9 +417,9 @@ public async Task ExportFeedbackDetails([FromQuery] FeedbackSearc string challengeTag = ""; if (model.WantsSpecificChallenge) challengeTag = (await ChallengeSpecService.Retrieve(model.ChallengeSpecId))?.Tag ?? ""; - + string filename = Service.GetFeedbackFilename(game.Name, model.WantsGame, model.WantsSpecificChallenge, challengeTag, false); - + return File( Service.ConvertToBytes(results), "application/octet-stream", @@ -430,7 +460,7 @@ public async Task ExportFeedbackStats([FromQuery] FeedbackSearchP string challengeTag = ""; if (model.WantsSpecificChallenge) challengeTag = (await ChallengeSpecService.Retrieve(model.ChallengeSpecId))?.Tag ?? ""; - + string filename = Service.GetFeedbackFilename(game.Name, model.WantsGame, model.WantsSpecificChallenge, challengeTag, true); return File( @@ -465,7 +495,7 @@ public async Task> GetFeedbackStats([FromQuery] Feed var maxResponses = await Service.GetFeedbackMaxResponses(model); var questionStats = Service.GetFeedbackQuestionStats(questionTemplate, expandedTable); - var fullStats = new FeedbackStats + var fullStats = new FeedbackStats { GameId = game.Id, ChallengeSpecId = model.ChallengeSpecId, @@ -534,7 +564,8 @@ public async Task> GetTicketChallengeStats( [HttpGet("api/report/exportticketdetails")] [Authorize] [ProducesResponseType(typeof(FileContentResult), 200)] - public async Task ExportTicketDetails([FromQuery] TicketReportFilter model) { + public async Task ExportTicketDetails([FromQuery] TicketReportFilter model) + { AuthorizeAny( () => Actor.IsObserver ); @@ -542,7 +573,8 @@ public async Task ExportTicketDetails([FromQuery] TicketReportFil var result = await Service.GetTicketDetails(model, Actor.Id); List ticketDetails = new List(); - ticketDetails.Add(new TicketDetailsExport { + ticketDetails.Add(new TicketDetailsExport + { Key = "Key", Summary = "Summary", Description = "Description", @@ -555,11 +587,13 @@ public async Task ExportTicketDetails([FromQuery] TicketReportFil Created = "Created", LastUpdated = "Last Updated", Label = "Label", - Status = "Status" }); + Status = "Status" + }); foreach (TicketDetail detail in result) { - ticketDetails.Add(new TicketDetailsExport { + ticketDetails.Add(new TicketDetailsExport + { Key = detail.Key.ToString(), Summary = detail.Summary, Description = detail.Description, @@ -580,7 +614,8 @@ public async Task ExportTicketDetails([FromQuery] TicketReportFil byte[] fileBytes = Service.ConvertToBytes(ticketDetails); // The total length of all properties concatenated together and separated by commas int totalCharacterLength = 0; - foreach (System.Reflection.PropertyInfo p in typeof(TicketDetailsExport).GetProperties()) { + foreach (System.Reflection.PropertyInfo p in typeof(TicketDetailsExport).GetProperties()) + { totalCharacterLength += p.Name.ToString().Count() + 1; } // The extra characters inserted into the second row that make them different from the variable names (spaces, punctuation, etc.) @@ -623,7 +658,8 @@ public async Task ExportTicketDayStats([FromQuery] TicketReportFi titles[titles.Count() - 2] = "Outside of Shifts Count"; titles[titles.Count() - 1] = "Total Created"; // Create a new title for each shift - for (int i = 2; i < titles.Count() - 2; i++) { + for (int i = 2; i < titles.Count() - 2; i++) + { titles[i] = "Shift " + (i - 1) + " Count"; } // Add to the byte list and remove the whitespace and newline from the file @@ -634,12 +670,14 @@ public async Task ExportTicketDayStats([FromQuery] TicketReportFi int[] sums = new int[titles.Count() - 2]; // Loop through each received TicketDayGroup - foreach (TicketDayGroup group in result.TicketDays) { + foreach (TicketDayGroup group in result.TicketDays) + { // Set each value within the row string[] row = new string[titles.Length]; row[0] = group.Date; row[1] = group.DayOfWeek; - for (int i = 2; i < row.Count() - 2; i++) { + for (int i = 2; i < row.Count() - 2; i++) + { row[i] = group.ShiftCounts[i - 2].ToString(); sums[i - 2] += group.ShiftCounts[i - 2]; } @@ -656,12 +694,13 @@ public async Task ExportTicketDayStats([FromQuery] TicketReportFi string[] rowLater = new string[titles.Length]; rowLater[0] = ""; rowLater[1] = "Total"; - for (int i = 2; i < rowLater.Length; i++) { + for (int i = 2; i < rowLater.Length; i++) + { rowLater[i] = sums[i - 2].ToString(); } // Add to the list fc.AddRange(Service.ConvertToBytes(rowLater)); - + // Convert the final byte list back to an array and return the resulting file f.FileContents = fc.ToArray(); @@ -729,7 +768,8 @@ public async Task ExportTicketChallengeStats([FromQuery] TicketRe [HttpGet("api/report/gameseriesstats")] [Authorize] - public async Task> GetSeriesStats() { + public async Task> GetSeriesStats() + { AuthorizeAny( () => Actor.IsObserver ); @@ -739,7 +779,8 @@ public async Task> GetSeriesStats() { [HttpGet("api/report/gametrackstats")] [Authorize] - public async Task> GetTrackStats() { + public async Task> GetTrackStats() + { AuthorizeAny( () => Actor.IsObserver ); @@ -749,7 +790,8 @@ public async Task> GetTrackStats() { [HttpGet("api/report/gameseasonstats")] [Authorize] - public async Task> GetSeasonStats() { + public async Task> GetSeasonStats() + { AuthorizeAny( () => Actor.IsObserver ); @@ -759,7 +801,8 @@ public async Task> GetSeasonStats() { [HttpGet("api/report/gamedivisionstats")] [Authorize] - public async Task> GetDivisionStats() { + public async Task> GetDivisionStats() + { AuthorizeAny( () => Actor.IsObserver ); @@ -769,7 +812,8 @@ public async Task> GetDivisionStats() { [HttpGet("api/report/gamemodestats")] [Authorize] - public async Task> GetModeStats() { + public async Task> GetModeStats() + { AuthorizeAny( () => Actor.IsObserver ); @@ -779,7 +823,8 @@ public async Task> GetModeStats() { [HttpGet("api/report/correlationstats")] [Authorize] - public async Task> GetCorrelationStats() { + public async Task> GetCorrelationStats() + { AuthorizeAny( () => Actor.IsObserver ); @@ -907,7 +952,8 @@ public async Task ExportCorrelationStats() } // Helper method to create participation reports - public FileContentResult ConstructParticipationReport(ParticipationReport report) { + public FileContentResult ConstructParticipationReport(ParticipationReportV1 report) + { List> participationStats = new List>(); participationStats.Add(new Tuple(report.Key, "Game Count", "Player Count", "Players with Sessions Count", "Team Count", "Teams with Session Count", "Challenges Deployed Count")); @@ -921,7 +967,8 @@ public FileContentResult ConstructParticipationReport(ParticipationReport report #region Many Column Tuple Report Helper Methods // Helper method to create reports constructed out of a tuple with 4 items - public FileContentResult ConstructManyColumnTupleReport(List> stats, string title) { + public FileContentResult ConstructManyColumnTupleReport(List> stats, string title) + { // Create the byte array now to remove a header row shortly byte[] fileBytes = Service.ConvertToBytes(stats); // The number of items per row @@ -939,7 +986,8 @@ public FileContentResult ConstructManyColumnTupleReport(List> stats, string title) { + public FileContentResult ConstructManyColumnTupleReport(List> stats, string title) + { // Create the byte array now to remove a header row shortly byte[] fileBytes = Service.ConvertToBytes(stats); // The number of items per row @@ -957,7 +1005,8 @@ public FileContentResult ConstructManyColumnTupleReport(List> stats, string title) { + public FileContentResult ConstructManyColumnTupleReport(List> stats, string title) + { // Create the byte array now to remove a header row shortly byte[] fileBytes = Service.ConvertToBytes(stats); // The number of items per row @@ -975,7 +1024,8 @@ public FileContentResult ConstructManyColumnTupleReport(List> stats, string title) { + public FileContentResult ConstructManyColumnTupleReport(List> stats, string title) + { // Create the byte array now to remove a header row shortly byte[] fileBytes = Service.ConvertToBytes(stats); // The number of items per row @@ -993,7 +1043,8 @@ public FileContentResult ConstructManyColumnTupleReport(List> stats, string title) { + public FileContentResult ConstructManyColumnTupleReport(List> stats, string title) + { // Create the byte array now to remove a header row shortly byte[] fileBytes = Service.ConvertToBytes(stats); // The number of items per row diff --git a/src/Gameboard.Api/Features/Reports/ReportsValidator.cs b/src/Gameboard.Api/Features/Reports/ReportsValidator.cs new file mode 100644 index 00000000..617c8283 --- /dev/null +++ b/src/Gameboard.Api/Features/Reports/ReportsValidator.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Authorizers; + +namespace Gameboard.Api.Features.Reports; + +internal class ReportsQueryValidator : IGameboardRequestValidator +{ + private readonly UserRoleAuthorizer _roleAuthorizer; + + public ReportsQueryValidator(UserRoleAuthorizer roleAuthorizer) + { + _roleAuthorizer = roleAuthorizer; + } + + public Task Validate(IReportQuery request) + { + _roleAuthorizer.AllowedRoles = new UserRole[] { UserRole.Director, UserRole.Admin, UserRole.Support }; + _roleAuthorizer.Authorize(); + + return Task.CompletedTask; + } +} diff --git a/src/Gameboard.Api/Features/Scores/ScoringModels.cs b/src/Gameboard.Api/Features/Scores/ScoringModels.cs index 40a83bd6..155794eb 100644 --- a/src/Gameboard.Api/Features/Scores/ScoringModels.cs +++ b/src/Gameboard.Api/Features/Scores/ScoringModels.cs @@ -1,5 +1,8 @@ using System.Collections.Generic; -using Gameboard.Api.Features.Common; +using Gameboard.Api.Common; +using Gameboard.Api.Features.ChallengeBonuses; + +namespace Gameboard.Api.Features.Scores; public class TeamChallengeScoreSummary { diff --git a/src/Gameboard.Api/Features/Scores/ScoringService.cs b/src/Gameboard.Api/Features/Scores/ScoringService.cs index 159db616..b8f68f0d 100644 --- a/src/Gameboard.Api/Features/Scores/ScoringService.cs +++ b/src/Gameboard.Api/Features/Scores/ScoringService.cs @@ -5,9 +5,10 @@ using AutoMapper; using Gameboard.Api.Data; using Gameboard.Api.Data.Abstractions; -using Gameboard.Api.Features.Common; +using Gameboard.Api.Common; using Gameboard.Api.Features.Teams; using Microsoft.EntityFrameworkCore; +using Gameboard.Api.Features.ChallengeBonuses; namespace Gameboard.Api.Features.Scores; @@ -15,7 +16,6 @@ public interface IScoringService { Task GetTeamChallengeScore(string challengeId); Task GetTeamGameScore(string teamId); - Task GetChallengeScores(string challengeId); } internal class ScoringService : IScoringService @@ -43,11 +43,6 @@ public ScoringService( _teamService = teamService; } - public Task GetChallengeScores(string challengeId) - { - throw new System.NotImplementedException(); - } - public async Task GetTeamChallengeScore(string challengeId) { var challenge = await _challengeStore @@ -55,10 +50,8 @@ public async Task GetTeamChallengeScore(string challe .Include(c => c.Player) .FirstOrDefaultAsync(c => c.Id == challengeId); - if (challenge == null) - { + if (challenge is null) return null; - } var spec = await _challengeSpecStore.Retrieve(challenge.SpecId); var bonuses = await _mapper.ProjectTo(_challengeBonusStore @@ -116,7 +109,7 @@ public async Task GetTeamGameScore(string teamId) ChallengeScoreSummaries = specs.Select(spec => { var challenge = challenges.FirstOrDefault(c => c.SpecId == spec.Id); - var bonuses = challenge == null ? new ManualChallengeBonus[] { } : challenge.AwardedManualBonuses; + var bonuses = challenge == null ? System.Array.Empty() : challenge.AwardedManualBonuses; var bonusesSum = challenge == null ? 0 : challenge.AwardedManualBonuses.Select(b => b.PointValue).Sum(); var points = challenge == null ? 0 : challenge.Points; diff --git a/src/Gameboard.Api/Features/Scores/TeamGameScoreQuery/TeamGameScoreQuery.cs b/src/Gameboard.Api/Features/Scores/TeamGameScoreQuery/TeamGameScoreQuery.cs index ae072d37..ee5411c2 100644 --- a/src/Gameboard.Api/Features/Scores/TeamGameScoreQuery/TeamGameScoreQuery.cs +++ b/src/Gameboard.Api/Features/Scores/TeamGameScoreQuery/TeamGameScoreQuery.cs @@ -6,7 +6,7 @@ namespace Gameboard.Api.Features.Scores; -public record TeamGameScoreQuery(string teamId) : IRequest; +public record TeamGameScoreQuery(string TeamId) : IRequest; internal class TeamGameScoreQueryHandler : IRequestHandler { @@ -26,11 +26,10 @@ public TeamGameScoreQueryHandler( public async Task Handle(TeamGameScoreQuery request, CancellationToken cancellationToken) { - _teamExists.TeamIdProperty = r => r.teamId; - _validatorService.AddValidator(_teamExists); + _validatorService.AddValidator(_teamExists.UseProperty(r => r.TeamId)); await _validatorService.Validate(request); return await _scoreService - .GetTeamGameScore(request.teamId); + .GetTeamGameScore(request.TeamId); } } diff --git a/src/Gameboard.Api/Features/Scores/TeamGameScoreQuery/TeamGameScoreQueryValidator.cs b/src/Gameboard.Api/Features/Scores/TeamGameScoreQuery/TeamGameScoreQueryValidator.cs index 34a200df..f0bfce4e 100644 --- a/src/Gameboard.Api/Features/Scores/TeamGameScoreQuery/TeamGameScoreQueryValidator.cs +++ b/src/Gameboard.Api/Features/Scores/TeamGameScoreQuery/TeamGameScoreQueryValidator.cs @@ -1,33 +1,25 @@ using System.Threading.Tasks; -using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Structure.MediatR; using Gameboard.Api.Structure.MediatR.Validators; -using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Features.Scores; internal class TeamGameScoreQueryValidator : IGameboardRequestValidator { - private readonly IPlayerStore _playerStore; private readonly TeamExistsValidator _teamExists; private readonly IValidatorService _validatorService; public TeamGameScoreQueryValidator( - IPlayerStore playerStore, TeamExistsValidator teamExists, IValidatorService validatorService) { - _playerStore = playerStore; _teamExists = teamExists; _validatorService = validatorService; } public async Task Validate(TeamGameScoreQuery request) { - _teamExists.TeamIdProperty = r => r.teamId; - _validatorService.AddValidator(_teamExists); - - + _validatorService.AddValidator(_teamExists.UseProperty(r => r.TeamId)); await _validatorService.Validate(request); } } diff --git a/src/Gameboard.Api/Features/SearchFilter.cs b/src/Gameboard.Api/Features/SearchFilter.cs index f706c133..5f06f97c 100644 --- a/src/Gameboard.Api/Features/SearchFilter.cs +++ b/src/Gameboard.Api/Features/SearchFilter.cs @@ -1,23 +1,24 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. +using System; -namespace Gameboard.Api +namespace Gameboard.Api; + +public class SearchFilter { - public class SearchFilter - { - public string Term { get; set; } - public int Skip { get; set; } - public int Take { get; set; } - public string Sort { get; set; } // Could possibly be deleted - public string[] Filter { get; set; } = new string[] {}; - // The column to order the result ticket list on - public string OrderItem { get; set; } - // Whether the list is in descending or ascending order - public bool IsDescending { get; set; } - public bool HasTerm => string.IsNullOrWhiteSpace(Term).Equals(false); - } + public string Term { get; set; } + public int Skip { get; set; } + public int Take { get; set; } + public string Sort { get; set; } // Could possibly be deleted + public string[] Filter { get; set; } = Array.Empty(); + // The column to order the result ticket list on + public string OrderItem { get; set; } + // Whether the list is in descending or ascending order + public bool IsDescending { get; set; } + public bool HasTerm => string.IsNullOrWhiteSpace(Term).Equals(false); +} - public class Entity { - public string Id { get; set; } - } +public class Entity +{ + public string Id { get; set; } } diff --git a/src/Gameboard.Api/Features/Sponsor/SponserValidator.cs b/src/Gameboard.Api/Features/Sponsor/SponserValidator.cs index fe10ca06..7b198192 100644 --- a/src/Gameboard.Api/Features/Sponsor/SponserValidator.cs +++ b/src/Gameboard.Api/Features/Sponsor/SponserValidator.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using Gameboard.Api.Data.Abstractions; -using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Validators { @@ -56,10 +55,7 @@ private async Task _validate(NewSponsor model) private async Task Exists(string id) { - return - id.NotEmpty() && - (await _store.Retrieve(id)) is Data.Sponsor - ; + return id.NotEmpty() && await _store.Exists(id); } } diff --git a/src/Gameboard.Api/Features/Sponsor/Sponsor.cs b/src/Gameboard.Api/Features/Sponsor/Sponsor.cs index a27176f3..250b9ba1 100644 --- a/src/Gameboard.Api/Features/Sponsor/Sponsor.cs +++ b/src/Gameboard.Api/Features/Sponsor/Sponsor.cs @@ -26,5 +26,4 @@ public class ChangedSponsor public string Logo { get; set; } public bool Approved { get; set; } } - } diff --git a/src/Gameboard.Api/Features/Sponsor/SponsorController.cs b/src/Gameboard.Api/Features/Sponsor/SponsorController.cs index d80fbe00..ca767fa6 100644 --- a/src/Gameboard.Api/Features/Sponsor/SponsorController.cs +++ b/src/Gameboard.Api/Features/Sponsor/SponsorController.cs @@ -27,7 +27,7 @@ public SponsorController( SponsorValidator validator, SponsorService sponsorService, CoreOptions options - ): base(logger, cache, validator) + ) : base(logger, cache, validator) { _logger = logger; SponsorService = sponsorService; @@ -69,7 +69,7 @@ public async Task CreateBatch([FromBody] ChangedSponsor[] model) /// Sponsor [HttpGet("api/sponsor/{id}")] [Authorize] - public async Task Retrieve([FromRoute]string id) + public async Task Retrieve([FromRoute] string id) { return await SponsorService.Retrieve(id); } @@ -84,9 +84,7 @@ public async Task Retrieve([FromRoute]string id) public async Task Update([FromBody] ChangedSponsor model) { await Validate(model); - model.Approved = Actor.IsRegistrar; - await SponsorService.AddOrUpdate(model); } @@ -97,7 +95,7 @@ public async Task Update([FromBody] ChangedSponsor model) /// [HttpDelete("/api/sponsor/{id}")] [Authorize(Policy = AppConstants.RegistrarPolicy)] - public async Task Delete([FromRoute]string id) + public async Task Delete([FromRoute] string id) { await SponsorService.Delete(id); } diff --git a/src/Gameboard.Api/Features/Sponsor/SponsorService.cs b/src/Gameboard.Api/Features/Sponsor/SponsorService.cs index 094887e3..b7427f33 100644 --- a/src/Gameboard.Api/Features/Sponsor/SponsorService.cs +++ b/src/Gameboard.Api/Features/Sponsor/SponsorService.cs @@ -8,12 +8,15 @@ using Gameboard.Api.Data.Abstractions; using Microsoft.Extensions.Logging; using System.IO; +using System.Collections.Generic; +using System.Threading; +using System; namespace Gameboard.Api.Services { public class SponsorService : _Service { - IStore Store { get; } + IStore _store { get; } public SponsorService( ILogger logger, @@ -22,41 +25,41 @@ public SponsorService( IStore store ) : base(logger, mapper, options) { - Store = store; + _store = store; } public async Task Create(NewSponsor model) { var entity = Mapper.Map(model); - await Store.Create(entity); + await _store.Create(entity); return Mapper.Map(entity); } public async Task Retrieve(string id) { - return Mapper.Map(await Store.Retrieve(id)); + return Mapper.Map(await _store.Retrieve(id)); } public async Task AddOrUpdate(ChangedSponsor model) { - var entity = await Store.Retrieve(model.Id); + var entity = await _store.Retrieve(model.Id); if (entity is not null) { Mapper.Map(model, entity); - await Store.Update(entity); + await _store.Update(entity); return; } entity = Mapper.Map(model); - await Store.Create(entity); + await _store.Create(entity); } public async Task Delete(string id) { - var entity = await Store.Retrieve(id); + var entity = await _store.Retrieve(id); - await Store.Delete(id); + await _store.Delete(id); if (entity.Logo.IsEmpty()) return; @@ -69,7 +72,7 @@ public async Task Delete(string id) public async Task List(SearchFilter model) { - var q = Store.List(model.Term); + var q = _store.List(model.Term); q = q.OrderBy(p => p.Id); @@ -83,16 +86,16 @@ public async Task List(SearchFilter model) public async Task AddOrUpdate(string id, string filename) { - var entity = await Store.Retrieve(id); + var entity = await _store.Retrieve(id); if (entity is null) { - entity = await Store.Create(new Data.Sponsor { Id = id }); + entity = await _store.Create(new Data.Sponsor { Id = id }); } entity.Logo = filename; - await Store.Update(entity); + await _store.Update(entity); return Mapper.Map(entity); } diff --git a/src/Gameboard.Api/Features/Teams/Requests/GetTeamQuery.cs b/src/Gameboard.Api/Features/Teams/Requests/GetTeamQuery.cs index 1b7e2c90..8f2df403 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/GetTeamQuery.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/GetTeamQuery.cs @@ -1,25 +1,35 @@ using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Validators; using MediatR; namespace Gameboard.Api.Features.Teams; -public record GetTeamQuery(string TeamId, User actingUser) : IRequest; +public record GetTeamQuery(string TeamId, User User) : IRequest; internal class GetTeamQueryHandler : IRequestHandler { + private readonly TeamExistsValidator _teamExists; private readonly ITeamService _teamService; private readonly IValidatorService _validatorService; - public GetTeamQueryHandler(ITeamService teamService, IValidatorService validatorService) + public GetTeamQueryHandler + ( + TeamExistsValidator teamExists, + ITeamService teamService, + IValidatorService validatorService) { + _teamExists = teamExists; _teamService = teamService; _validatorService = validatorService; } public async Task Handle(GetTeamQuery request, CancellationToken cancellationToken) { + _validatorService.AddValidator(_teamExists.UseProperty(r => r.TeamId)); + await _validatorService.Validate(request); + return await _teamService.GetTeam(request.TeamId); } } diff --git a/src/Gameboard.Api/Features/Teams/TeamController.cs b/src/Gameboard.Api/Features/Teams/TeamController.cs index 7cfda61e..05645fb7 100644 --- a/src/Gameboard.Api/Features/Teams/TeamController.cs +++ b/src/Gameboard.Api/Features/Teams/TeamController.cs @@ -3,7 +3,6 @@ using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; namespace Gameboard.Api.Features.Teams; @@ -11,23 +10,17 @@ namespace Gameboard.Api.Features.Teams; [Route("/api/team")] public class TeamController : ControllerBase { - private IActingUserService _actingUserService; - private ILogger _logger; - private IMediator _mediator; - private readonly ITeamService _teamService; + private readonly IActingUserService _actingUserService; + private readonly IMediator _mediator; public TeamController ( IActingUserService actingUserService, - ILogger logger, - IMediator mediator, - ITeamService teamService + IMediator mediator ) { _actingUserService = actingUserService; - _logger = logger; _mediator = mediator; - _teamService = teamService; } [HttpGet("{teamId}")] diff --git a/src/Gameboard.Api/Features/Teams/TeamService.cs b/src/Gameboard.Api/Features/Teams/TeamService.cs index 3e3bbf13..0941bac6 100644 --- a/src/Gameboard.Api/Features/Teams/TeamService.cs +++ b/src/Gameboard.Api/Features/Teams/TeamService.cs @@ -4,6 +4,8 @@ using AutoMapper; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.Player; +using Gameboard.Api.Hubs; +using Gameboard.Api.Services; using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Features.Teams; @@ -14,6 +16,7 @@ public interface ITeamService Task GetSessionCount(string teamId, string gameId); Task GetTeam(string id); Task ResolveCaptain(string teamId); + Task ResolveCaptain(IEnumerable players); Task PromoteCaptain(string teamId, string newCaptainPlayerId, User actingUser); Task UpdateTeamSponsors(string teamId); } @@ -25,11 +28,13 @@ internal class TeamService : ITeamService private readonly IInternalHubBus _teamHubService; private readonly IPlayerStore _store; - public TeamService( + public TeamService + ( IMapper mapper, INowService now, IInternalHubBus teamHubService, - IPlayerStore store) + IPlayerStore store + ) { _mapper = mapper; _now = now; @@ -38,9 +43,7 @@ public TeamService( } public async Task GetExists(string teamId) - { - return (await _store.ListTeam(teamId).CountAsync()) > 0; - } + => await _store.ListTeam(teamId).AnyAsync(); public async Task GetSessionCount(string teamId, string gameId) { @@ -60,17 +63,12 @@ public async Task GetSessionCount(string teamId, string gameId) public async Task GetTeam(string id) { var players = await _store.ListTeam(id).ToArrayAsync(); - if (players.Count() == 0) + if (players.Length == 0) return null; - var team = _mapper.Map( - players.First(p => p.IsManager) - ); - - team.Members = _mapper.Map( - players.Select(p => p.User) - ); + var team = _mapper.Map(players.First(p => p.IsManager)); + team.Members = _mapper.Map(players.Select(p => p.User)); team.TeamSponsors = string.Join("|", players.Select(p => p.Sponsor)); return team; @@ -109,16 +107,25 @@ await _store throw new PromotionFailed(teamId, newCaptainPlayerId, affectedPlayers); await UpdateTeamSponsors(teamId); - await transaction.CommitAsync(); } await _teamHubService.SendPlayerRoleChanged(_mapper.Map(newCaptain), actingUser); } - public async Task ResolveCaptain(string teamId) + public Task ResolveCaptain(string teamId) + { + return ResolveCaptain(teamId, null); + } + + public Task ResolveCaptain(IEnumerable players) + { + return ResolveCaptain(null, players); + } + + private async Task ResolveCaptain(string teamId, IEnumerable players) { - var players = await _store + players ??= await _store .List() .Where(p => p.TeamId == teamId) .ToListAsync(); @@ -153,9 +160,9 @@ public async Task UpdateTeamSponsors(string teamId) .Where(p => p.TeamId == teamId) .Select(p => new { - Id = p.Id, - Sponsor = p.Sponsor, - IsManager = p.IsManager + p.Id, + p.Sponsor, + p.IsManager }) .ToArrayAsync(); diff --git a/src/Gameboard.Api/Features/Teams/TeamsModels.cs b/src/Gameboard.Api/Features/Teams/TeamsModels.cs new file mode 100644 index 00000000..fcee6e2a --- /dev/null +++ b/src/Gameboard.Api/Features/Teams/TeamsModels.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using Gameboard.Api.Common; + +namespace Gameboard.Api.Features.Teams; + +public class PromoteToManagerRequest +{ + public User Actor { get; set; } + public bool AsAdmin { get; set; } + public string CurrentManagerPlayerId { get; set; } + public string NewManagerPlayerId { get; set; } + public string TeamId { get; set; } +} + +public class TeamInvitation +{ + public string Code { get; set; } +} + +public class TeamAdvancement +{ + public string[] TeamIds { get; set; } + public string GameId { get; set; } + public bool WithScores { get; set; } + public string NextGameId { get; set; } +} + +public class Team +{ + private static readonly char SPONSOR_STRING_DELIMITER = '|'; + + public string TeamId { get; set; } + public string ApprovedName { get; set; } + public string GameId { get; set; } + public string Sponsor { get; set; } + public string TeamSponsors { get; set; } + public DateTimeOffset SessionBegin { get; set; } + public DateTimeOffset SessionEnd { get; set; } + public int Rank { get; set; } + public int Score { get; set; } + public long Time { get; set; } + public int CorrectCount { get; set; } + public int PartialCount { get; set; } + public bool Advanced { get; set; } + public IEnumerable Challenges { get; set; } = new List(); + public IEnumerable Members { get; set; } = new List(); + public string[] SponsorList => (TeamSponsors ?? Sponsor).Split(SPONSOR_STRING_DELIMITER); +} + +public class TeamSummary +{ + public string Id { get; set; } + public string Name { get; set; } + public string Sponsor { get; set; } + public string TeamSponsors { get; set; } + public string[] Members { get; set; } + public string[] SponsorList => (TeamSponsors ?? Sponsor).Split("|"); +} + +public class TeamPlayer +{ + public string Id { get; set; } + public string TeamId { get; set; } + public string Name { get; set; } + public string ApprovedName { get; set; } + public string UserId { get; set; } + public string UserName { get; set; } + public string UserApprovedName { get; set; } + public string UserNameStatus { get; set; } + public string Sponsor { get; set; } + public PlayerRole Role { get; set; } + public bool IsManager => Role == PlayerRole.Manager; +} + +public class TeamState +{ + public string TeamId { get; set; } + public Api.Player ActingPlayer { get; set; } + public string ApprovedName { get; set; } + public string Name { get; set; } + public string NameStatus { get; set; } + public DateTimeOffset SessionBegin { get; set; } + public DateTimeOffset SessionEnd { get; set; } + public User Actor { get; set; } +} diff --git a/src/Gameboard.Api/Features/Ticket/ITicketStore.cs b/src/Gameboard.Api/Features/Ticket/ITicketStore.cs index 5ae18e5a..afc3bb51 100644 --- a/src/Gameboard.Api/Features/Ticket/ITicketStore.cs +++ b/src/Gameboard.Api/Features/Ticket/ITicketStore.cs @@ -5,7 +5,6 @@ namespace Gameboard.Api.Data.Abstractions { - public interface ITicketStore : IStore { Task Load(string id); diff --git a/src/Gameboard.Api/Features/Ticket/TicketController.cs b/src/Gameboard.Api/Features/Ticket/TicketController.cs index 049b0570..44c198d1 100644 --- a/src/Gameboard.Api/Features/Ticket/TicketController.cs +++ b/src/Gameboard.Api/Features/Ticket/TicketController.cs @@ -61,7 +61,7 @@ await Cache.SetStringAsync new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = new TimeSpan(0, 15, 0) } ); - return await TicketService.Retrieve(id, Actor.Id); + return await TicketService.Retrieve(id); } @@ -97,7 +97,7 @@ public async Task Update([FromBody] ChangedTicket model) await Validate(model); // Retrieve the previous ticket result for comparison soon - var prevTicket = await TicketService.Retrieve(model.Id, Actor.Id); + var prevTicket = await TicketService.Retrieve(model.Id); var result = await TicketService.Update(model, Actor.Id, Actor.IsSupport); // Ignore labels being different diff --git a/src/Gameboard.Api/Features/Ticket/TicketService.cs b/src/Gameboard.Api/Features/Ticket/TicketService.cs index 06ebf825..73579aa9 100644 --- a/src/Gameboard.Api/Features/Ticket/TicketService.cs +++ b/src/Gameboard.Api/Features/Ticket/TicketService.cs @@ -21,8 +21,9 @@ public class TicketService : _Service private readonly INowService _now; ITicketStore Store { get; } - public TicketService - ( + internal static char LABELS_DELIMITER = ' '; + + public TicketService( IFileUploadService fileUploadService, IGuidService guids, ILogger logger, @@ -38,14 +39,14 @@ ITicketStore store _now = now; } - public async Task Retrieve(string id, string actorId) + public async Task Retrieve(string id) { var entity = await Store.LoadDetails(id); entity.Activity = entity.Activity.OrderByDescending(a => a.Timestamp).ToList(); return TransformInPlace(Mapper.Map(entity)); } - public async Task Retrieve(int id, string actorId) + public async Task Retrieve(int id) { var entity = await Store.LoadDetails(id); entity.Activity = entity.Activity.OrderByDescending(a => a.Timestamp).ToList(); @@ -217,7 +218,7 @@ public async Task AddComment(NewTicketComment model, string acto }; var uploads = await _fileUploadService.Upload(Path.Combine(Options.SupportUploadsFolder, model.TicketId, commentActivity.Id), model.Uploads); - if (uploads.Count() > 0) + if (uploads.Any()) { commentActivity.Attachments = Mapper.Map(uploads.Select(x => x.FileName).ToArray()); } @@ -245,7 +246,7 @@ public async Task ListLabels(SearchFilter model) var b = tickets .Where(t => !t.Label.IsEmpty()) - .SelectMany(t => t.Label.Split(" ")) + .SelectMany(t => TransformTicketLabels(t.Label)) .OrderBy(t => t) .ToHashSet().ToArray(); @@ -316,6 +317,19 @@ public async Task UserCanUpdate(string ticketId, string userId) return false; } + internal IEnumerable TransformTicketLabels(string labels) + { + if (labels.IsEmpty()) + return Array.Empty(); + + return labels.Split(LABELS_DELIMITER, StringSplitOptions.RemoveEmptyEntries); + } + + internal string TransformTicketKey(int key) + { + return Options.KeyPrefix + "-" + key.ToString(); + } + private async Task UpdatedSessionContext(Data.Ticket entity) { if (!entity.ChallengeId.IsEmpty()) @@ -385,7 +399,7 @@ private IEnumerable Transform(IEnumerable tickets) private Ticket TransformInPlace(Ticket ticket) { - ticket.FullKey = FullKey(ticket.Key); + ticket.FullKey = TransformTicketKey(ticket.Key); return ticket; } diff --git a/src/Gameboard.Api/Features/Ticket/TicketValidator.cs b/src/Gameboard.Api/Features/Ticket/TicketValidator.cs index ff43283a..07454f35 100644 --- a/src/Gameboard.Api/Features/Ticket/TicketValidator.cs +++ b/src/Gameboard.Api/Features/Ticket/TicketValidator.cs @@ -3,115 +3,79 @@ using System.Threading.Tasks; using Gameboard.Api.Data.Abstractions; -using Microsoft.EntityFrameworkCore; -namespace Gameboard.Api.Validators +namespace Gameboard.Api.Validators; + +public class TicketValidator : IModelValidator { - public class TicketValidator : IModelValidator + private readonly ITicketStore _store; + + public TicketValidator(ITicketStore store) + { + _store = store; + } + + public Task Validate(object model) + { + if (model is Entity) + return _validate(model as Entity); + + if (model is Ticket) + return _validate(model as Ticket); + + if (model is ChangedTicket) + return _validate(model as ChangedTicket); + + if (model is NewTicket) + return _validate(model as NewTicket); + + if (model is NewTicketComment) + return _validate(model as NewTicketComment); + + + throw new System.NotImplementedException(); + } + + private async Task _validate(Entity model) + { + if ((await _store.Exists(model.Id)).Equals(false)) + throw new ResourceNotFound(model.Id); + + await Task.CompletedTask; + } + + private async Task _validate(NewTicket model) + { + // TODO validate that references exist and belong to the requester + // see feedback validator for examples + // for example, challenge exists and it is part of a player session that the user belongs to + + await Task.CompletedTask; + } + + private async Task _validate(Ticket model) + { + if ((await _store.Exists(model.Id)).Equals(false)) + throw new ResourceNotFound(model.Id); + + await Task.CompletedTask; + } + + private async Task _validate(ChangedTicket model) + { + if ((await _store.Exists(model.Id)).Equals(false)) + throw new ResourceNotFound(model.Id); + + // TODO validate that references exist and belong to the requester + + await Task.CompletedTask; + } + + private async Task _validate(NewTicketComment model) { - private readonly ITicketStore _store; - - public TicketValidator( - ITicketStore store - ) - { - _store = store; - } - - public Task Validate(object model) - { - if (model is Entity) - return _validate(model as Entity); - - if (model is Ticket) - return _validate(model as Ticket); - - if (model is ChangedTicket) - return _validate(model as ChangedTicket); - - if (model is NewTicket) - return _validate(model as NewTicket); - - if (model is NewTicketComment) - return _validate(model as NewTicketComment); - - - throw new System.NotImplementedException(); - } - - private async Task _validate(Entity model) - { - if ((await Exists(model.Id)).Equals(false)) - throw new ResourceNotFound(model.Id); - - await Task.CompletedTask; - } - - private async Task _validate(NewTicket model) - { - // TODO validate that references exist and belong to the requester - // see feedback validator for examples - // for example, challenge exists and it is part of a player session that the user belongs to - - await Task.CompletedTask; - } - - private async Task _validate(Ticket model) - { - if ((await Exists(model.Id)).Equals(false)) - throw new ResourceNotFound(model.Id); - - await Task.CompletedTask; - } - - private async Task _validate(ChangedTicket model) - { - if ((await Exists(model.Id)).Equals(false)) - throw new ResourceNotFound(model.Id); - - // TODO validate that references exist and belong to the requester - - await Task.CompletedTask; - } - - private async Task _validate(NewTicketComment model) - { - if ((await Exists(model.TicketId)).Equals(false)) - throw new ResourceNotFound(model.TicketId); - - await Task.CompletedTask; - } - - private async Task Exists(string id) - { - return - id.NotEmpty() && - (await _store.Retrieve(id)) is Data.Ticket - ; - } - - private async Task ChallengeExists(string id) - { - return - id.NotEmpty() && - (await _store.DbContext.Challenges.FindAsync(id)) is Data.Challenge - ; - } - - private async Task PlayerExists(string id) - { - return - id.NotEmpty() && - (await _store.DbContext.Players.FindAsync(id)) is Data.Player - ; - } - - private async Task UserExists(string id) - { - return - id.NotEmpty() && - (await _store.DbContext.Users.FindAsync(id)) is Data.User - ; - } + if ((await _store.Exists(model.TicketId)).Equals(false)) + throw new ResourceNotFound(model.TicketId); + + await Task.CompletedTask; } } diff --git a/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs b/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs index 66bc92d0..25dabf44 100644 --- a/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs +++ b/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using AutoMapper; +using Gameboard.Api.Data; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.UnityGames; using Gameboard.Api.Features.UnityGames.ViewModels; diff --git a/src/Gameboard.Api/Features/UnityGames/UnityGameValidator.cs b/src/Gameboard.Api/Features/UnityGames/UnityGameValidator.cs index ecd47e37..06f16f69 100644 --- a/src/Gameboard.Api/Features/UnityGames/UnityGameValidator.cs +++ b/src/Gameboard.Api/Features/UnityGames/UnityGameValidator.cs @@ -1,17 +1,31 @@ using System.Linq; using System.Threading.Tasks; -using Gameboard.Api.Validators; +using Gameboard.Api.Data; +using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Features.Teams; using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Features.UnityGames; public class UnityGamesValidator : IModelValidator { + private readonly IChallengeStore _challengeStore; + private readonly IGameStore _gameStore; private readonly IUnityStore _store; - - public UnityGamesValidator(IUnityStore store) + private readonly ITeamService _teamService; + + public UnityGamesValidator + ( + IChallengeStore challengeStore, + IGameStore gameStore, + IUnityStore store, + ITeamService teamService + ) { + _challengeStore = challengeStore; + _gameStore = gameStore; _store = store; + _teamService = teamService; } public async Task Validate(object model) @@ -20,12 +34,12 @@ public async Task Validate(object model) { var typedModel = model as NewUnityChallenge; - if (!(await GameExists(typedModel.GameId))) + if (!(await _gameStore.Exists(typedModel.GameId))) { throw new ResourceNotFound(typedModel.GameId); } - if (!(await TeamExists(typedModel.TeamId))) + if (!(await _teamService.GetExists(typedModel.TeamId))) { throw new ResourceNotFound(typedModel.TeamId); } @@ -49,12 +63,12 @@ public async Task Validate(object model) { var typedModel = model as NewUnityChallengeEvent; - if (!(await ChallengeExists(typedModel.ChallengeId))) + if (!(await _challengeStore.Exists(typedModel.ChallengeId))) { throw new ResourceNotFound(typedModel.ChallengeId); } - if (!(await TeamExists(typedModel.TeamId))) + if (!(await _teamService.GetExists(typedModel.TeamId))) { throw new ResourceNotFound(typedModel.TeamId); } @@ -63,7 +77,7 @@ public async Task Validate(object model) { var typedModel = model as UnityMissionUpdate; - if (!(await TeamExists(typedModel.TeamId))) + if (!(await _teamService.GetExists(typedModel.TeamId))) { throw new ResourceNotFound(typedModel.TeamId); } @@ -73,21 +87,4 @@ public async Task Validate(object model) throw new ValidationTypeFailure(model.GetType()); } } - - private async Task ChallengeExists(string id) - => id.HasValue() && (await _store.DbContext.Challenges.FindAsync(id)) is Data.Challenge; - - private async Task GameExists(string id) - => !string.IsNullOrEmpty(id) && (await _store.DbContext.Games.FindAsync(id)) is Data.Game; - - private async Task TeamExists(string teamId) - => !string.IsNullOrWhiteSpace(teamId) && ( - await _store - .DbContext.Players - .Where(p => p.TeamId == teamId) - .FirstOrDefaultAsync() - ) != null; - - private async Task UserExists(string id) - => id.NotEmpty() && (await _store.DbContext.Users.FindAsync(id)) is Data.User; } diff --git a/src/Gameboard.Api/Features/User/GetUserActiveChallenges/GetUserActiveChallenges.cs b/src/Gameboard.Api/Features/User/GetUserActiveChallenges/GetUserActiveChallenges.cs new file mode 100644 index 00000000..d258b358 --- /dev/null +++ b/src/Gameboard.Api/Features/User/GetUserActiveChallenges/GetUserActiveChallenges.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using Gameboard.Api.Common; +using Gameboard.Api.Data; +using Gameboard.Api.Features.GameEngine; +using Gameboard.Api.Features.Player; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Authorizers; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Users; + +public record GetUserActiveChallengesQuery(string UserId) : IRequest; + +public sealed class UserActiveChallenges +{ + public required SimpleEntity User { get; set; } + public required IEnumerable Practice { get; set; } + public required IEnumerable Competition { get; set; } +} + +internal class GetUserActiveChallengesHandler : IRequestHandler +{ + private readonly IGameEngineService _gameEngine; + private readonly IMapper _mapper; + private readonly INowService _now; + private readonly IStore _store; + private readonly ITimeWindowService _timeWindowService; + private readonly EntityExistsValidator _userExists; + private readonly UserRoleAuthorizer _userRoleAuthorizer; + private readonly IValidatorService _validator; + + public GetUserActiveChallengesHandler + ( + IGameEngineService gameEngine, + IMapper mapper, + INowService now, + IStore store, + ITimeWindowService timeWindowService, + EntityExistsValidator userExists, + UserRoleAuthorizer userRoleAuthorizer, + IValidatorService validator + ) + { + _gameEngine = gameEngine; + _mapper = mapper; + _now = now; + _store = store; + _timeWindowService = timeWindowService; + _userExists = userExists; + _userRoleAuthorizer = userRoleAuthorizer; + _validator = validator; + } + + public async Task Handle(GetUserActiveChallengesQuery request, CancellationToken cancellationToken) + { + // validate + _validator.AddValidator(_userExists.UseProperty(m => m.UserId)); + await _validator.Validate(request); + + _userRoleAuthorizer.AllowedRoles = new UserRole[] { UserRole.Admin }; + _userRoleAuthorizer.AllowedUserId = request.UserId; + _userRoleAuthorizer.Authorize(); + + // retrieve stuff (initial pull from DB side eval) + var user = await _store + .List() + .Select(u => new SimpleEntity { Id = u.Id, Name = u.ApprovedName }) + .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + + var challenges = await _store + .List() + .Include(c => c.Game) + .Include(c => c.Player) + .ThenInclude(p => p.User) + .Where(c => c.Player.SessionBegin >= DateTimeOffset.MinValue) + .Where(c => c.Player.SessionEnd > _now.Get()) + .Where(c => c.Player.UserId == request.UserId) + .OrderByDescending(c => c.Player.SessionEnd) + .Select(c => new + { + // have to join spec separately later to get the names/other properties + Spec = new ActiveChallengeSpec + { + Id = c.SpecId, + Name = null, + Tag = null, + AverageDeploySeconds = 0 + }, + Game = new SimpleEntity { Id = c.GameId, Name = c.Game.Name }, + c.GameEngineType, + Player = new SimpleEntity { Id = c.PlayerId, Name = c.Player.ApprovedName }, + User = new SimpleEntity { Id = c.Player.UserId, Name = c.Player.User.ApprovedName }, + ChallengeDeployment = new ActiveChallengeDeployment + { + ChallengeId = c.Id, + // these are dummy values we'll fill out below (can't do it here because we're on the db server side during the query) + Markdown = string.Empty, + IsDeployed = false, + Vms = Array.Empty() + }, + c.Player.TeamId, + Session = _timeWindowService.CreateWindow(_now.Get(), c.Player.SessionBegin, c.Player.SessionEnd), + c.PlayerMode, + // additional dummy values - we'll get the real attempts and stuff from state + ScoreAndAttemptsState = new ActiveChallengeScoreAndAttemptsState + { + Score = new decimal(c.Score), + MaxPossibleScore = c.Points, + Attempts = 0, + MaxAttempts = 0 + }, + c.State + }) + .ToListAsync(cancellationToken); + + // load the spec names and set state properties + var specIds = challenges.Select(c => c.Spec.Id).ToList(); + var specs = await _store + .List() + .Where(s => specIds.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => s, cancellationToken); + + // now do client side eval + foreach (var challenge in challenges) + { + if (specs.ContainsKey(challenge.Spec.Id)) + { + challenge.Spec.Name = specs[challenge.Spec.Id].Name; + challenge.Spec.Tag = specs[challenge.Spec.Id].Tag; + challenge.Spec.AverageDeploySeconds = specs[challenge.Spec.Id].AverageDeploySeconds; + } + + // we need the state json as an object so we don't lose our minds and to make some decisions + var state = await _gameEngine.GetChallengeState(challenge.GameEngineType, challenge.State); + + // set attempt info + challenge.ScoreAndAttemptsState.Attempts = state.Challenge.Attempts; + challenge.ScoreAndAttemptsState.MaxAttempts = state.Challenge.MaxAttempts; + + // currently, topomojo sends an empty VM list when the vms are turned off, so we use this to + // proxy whether the challenge is deployed. hopefully topo will eventually send VMs with + // isRunning = false when asked, so we're making these separate concepts on the API surface + challenge.ChallengeDeployment.IsDeployed = state.Vms.Any(); + challenge.ChallengeDeployment.Vms = state.Vms; + // now that we have the state, we can also read the final challenge document (which may different than the spec due to transforms or etc.) + challenge.ChallengeDeployment.Markdown = state.Markdown; + } + + var typedChallenges = challenges.Select(c => new ActiveChallenge + { + Spec = c.Spec, + Game = c.Game, + Player = c.Player, + User = c.User, + ChallengeDeployment = c.ChallengeDeployment, + TeamId = c.TeamId, + PlayerMode = c.PlayerMode, + ScoreAndAttemptsState = c.ScoreAndAttemptsState, + Session = c.Session + }) + // now that we have info about points and attempts, we can reason about whether we should return + // challenges that are maxed out on score or guesses + .Where(c => c.ScoreAndAttemptsState.Score < c.ScoreAndAttemptsState.MaxPossibleScore) + .Where(c => c.ScoreAndAttemptsState.Attempts < c.ScoreAndAttemptsState.MaxAttempts); + + return new UserActiveChallenges + { + User = user, + Competition = typedChallenges.Where(c => c.PlayerMode == PlayerMode.Competition), + Practice = typedChallenges.Where(c => c.PlayerMode == PlayerMode.Practice) + }; + } +} diff --git a/src/Gameboard.Api/Features/User/IUserStore.cs b/src/Gameboard.Api/Features/User/IUserStore.cs deleted file mode 100644 index f1003ba0..00000000 --- a/src/Gameboard.Api/Features/User/IUserStore.cs +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright 2021 Carnegie Mellon University. All Rights Reserved. -// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. - -namespace Gameboard.Api.Data.Abstractions -{ - public interface IUserStore : IStore { } -} diff --git a/src/Gameboard.Api/Features/User/UpdateUserLoginEvents.cs b/src/Gameboard.Api/Features/User/UpdateUserLoginEvents.cs new file mode 100644 index 00000000..6ed03d72 --- /dev/null +++ b/src/Gameboard.Api/Features/User/UpdateUserLoginEvents.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Data; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using Gameboard.Api.Structure.MediatR.Validators; +using MediatR; + +namespace Gameboard.Api.Features.Users; + +public sealed class UpdateUserLoginEventsResult +{ + public DateTimeOffset CurrentLoginDate { get; set; } + public DateTimeOffset? LastLoginDate { get; set; } +} + +public record UpdateUserLoginEventsCommand(string UserId) : IRequest; + +internal class UpdateUserLoginEventsHandler : IRequestHandler +{ + private readonly INowService _now; + private readonly IStore _store; + private readonly EntityExistsValidator _userExists; + private readonly IValidatorService _validator; + + public UpdateUserLoginEventsHandler + ( + INowService now, + IStore store, + EntityExistsValidator userExists, + IValidatorService validator + ) + { + _now = now; + _store = store; + _userExists = userExists; + _validator = validator; + } + + public async Task Handle(UpdateUserLoginEventsCommand request, CancellationToken cancellationToken) + { + // validate + _validator.AddValidator(_userExists.UseProperty(r => r.UserId)); + await _validator.Validate(request); + + var user = await _store.Retrieve(request.UserId); + var lastLoginDate = user.LastLoginDate; + var currentLoginDate = _now.Get(); + + await _store.ExecuteUpdateAsync + ( + u => u.Id == user.Id, + u => u + .SetProperty(u => u.LoginCount, user.LoginCount + 1) + .SetProperty(u => u.LastLoginDate, currentLoginDate) + ); + + return new UpdateUserLoginEventsResult + { + LastLoginDate = lastLoginDate, + CurrentLoginDate = currentLoginDate + }; + } +} diff --git a/src/Gameboard.Api/Features/User/User.cs b/src/Gameboard.Api/Features/User/User.cs index b5409b51..518c91f3 100644 --- a/src/Gameboard.Api/Features/User/User.cs +++ b/src/Gameboard.Api/Features/User/User.cs @@ -1,95 +1,105 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. +using System; using System.Linq; -namespace Gameboard.Api +namespace Gameboard.Api; + +public class User : IUserViewModel { - public class User : IUserViewModel - { - public string Id { get; set; } - public string Name { get; set; } - public string NameStatus { get; set; } - public string ApprovedName { get; set; } - public string Sponsor { get; set; } - public UserRole Role { get; set; } - public Player[] Enrollments { get; set; } - public bool IsAdmin => Role.HasFlag(UserRole.Admin); - public bool IsDirector => Role.HasFlag(UserRole.Director); - public bool IsRegistrar => Role.HasFlag(UserRole.Registrar); - public bool IsDesigner => Role.HasFlag(UserRole.Designer); - public bool IsTester => Role.HasFlag(UserRole.Tester); - public bool IsObserver => Role.HasFlag(UserRole.Observer); - public bool IsSupport => Role.HasFlag(UserRole.Support); - } + public string Id { get; set; } + public string Name { get; set; } + public string NameStatus { get; set; } + public string ApprovedName { get; set; } + public string Sponsor { get; set; } + public UserRole Role { get; set; } + public Player[] Enrollments { get; set; } + public DateTimeOffset CreatedOn { get; set; } + public DateTimeOffset? LastLoginDate { get; set; } + public int LoginCount { get; set; } - public class NewUser - { - public string Id { get; set; } - public string Name { get; set; } - public string Sponsor { get; set; } - } + public bool IsAdmin => Role.HasFlag(UserRole.Admin); + public bool IsDirector => Role.HasFlag(UserRole.Director); + public bool IsRegistrar => Role.HasFlag(UserRole.Registrar); + public bool IsDesigner => Role.HasFlag(UserRole.Designer); + public bool IsTester => Role.HasFlag(UserRole.Tester); + public bool IsObserver => Role.HasFlag(UserRole.Observer); + public bool IsSupport => Role.HasFlag(UserRole.Support); +} - public class ChangedUser - { - public string Id { get; set; } - public string Name { get; set; } - public string NameStatus { get; set; } - public string ApprovedName { get; set; } - public string Sponsor { get; set; } - public UserRole Role { get; set; } - } +public class NewUser +{ + public string Id { get; set; } +} - public class SelfChangedUser - { - public string Id { get; set; } - public string Name { get; set; } - public string Sponsor { get; set; } - } +public class ChangedUser +{ + public string Id { get; set; } + public string Name { get; set; } + public string NameStatus { get; set; } + public string ApprovedName { get; set; } + public string Sponsor { get; set; } + public UserRole Role { get; set; } +} - public class TeamMember - { - public string Id { get; set; } - public string ApprovedName { get; set; } - public PlayerRole Role { get; set; } - } +public class SelfChangedUser +{ + public string Id { get; set; } + public string Name { get; set; } + public string Sponsor { get; set; } +} - public class UserSearch : SearchFilter - { - public const string UserRoleFilter = "roles"; - public const string NamePendingFilter = "pending"; - public const string NameDisallowedFilter = "disallowed"; - public bool WantsRoles => Filter.Contains(UserRoleFilter); - public bool WantsPending => Filter.Contains(NamePendingFilter); - public bool WantsDisallowed => Filter.Contains(NameDisallowedFilter); - } +public class TeamMember +{ + public string Id { get; set; } + public string ApprovedName { get; set; } + public PlayerRole Role { get; set; } +} - public class UserSimple : IUserViewModel - { - public string Id { get; set; } - public string ApprovedName { get; set; } - } +public class UserSearch : SearchFilter +{ + public const string UserRoleFilter = "roles"; + public const string NamePendingFilter = "pending"; + public const string NameDisallowedFilter = "disallowed"; + public bool WantsRoles => Filter.Contains(UserRoleFilter); + public bool WantsPending => Filter.Contains(NamePendingFilter); + public bool WantsDisallowed => Filter.Contains(NameDisallowedFilter); +} - public class Announcement - { - public string TeamId { get; set; } - public string Message { get; set; } - } +public class UserSimple : IUserViewModel +{ + public string Id { get; set; } + public string ApprovedName { get; set; } +} - public class UserOnly : IUserViewModel - { - public string Id { get; set; } - public string Email { get; set; } - public string Name { get; set; } - public string NameStatus { get; set; } - public string ApprovedName { get; set; } - public string Sponsor { get; set; } - public UserRole Role { get; set; } - } +public class Announcement +{ + public string TeamId { get; set; } + public string Message { get; set; } +} - public interface IUserViewModel - { - public string Id { get; set; } - public string ApprovedName { get; set; } - } +public class UserOnly : IUserViewModel +{ + public string Id { get; set; } + public string Name { get; set; } + public string NameStatus { get; set; } + public string ApprovedName { get; set; } + public string Sponsor { get; set; } + public DateTimeOffset CreatedOn { get; set; } + public DateTimeOffset? LastLoginDate { get; set; } + public int LoginCount { get; set; } + public UserRole Role { get; set; } +} + +public interface IUserViewModel +{ + public string Id { get; set; } + public string ApprovedName { get; set; } +} + +public class TryCreateUserResult +{ + public required bool IsNewUser { get; set; } + public User User { get; set; } } diff --git a/src/Gameboard.Api/Features/User/UserClaimTransformation.cs b/src/Gameboard.Api/Features/User/UserClaimTransformation.cs index f3f14774..1a39b6c9 100644 --- a/src/Gameboard.Api/Features/User/UserClaimTransformation.cs +++ b/src/Gameboard.Api/Features/User/UserClaimTransformation.cs @@ -27,10 +27,13 @@ UserService svc public async Task TransformAsync(ClaimsPrincipal principal) { - string subject = principal.Subject() - ?? throw new ArgumentException("ClaimsPrincipal requires 'sub' claim"); + // subject will be null if the user is authenticating with the GraderKey scheme + // in this case, just pass the claims we already have + string subject = principal.Subject(); + if (subject is null) + return principal; - if (! _cache.TryGetValue(subject, out User user)) + if (!_cache.TryGetValue(subject, out User user)) { user = await _svc.Retrieve(subject) ?? new User { @@ -39,17 +42,18 @@ public async Task TransformAsync(ClaimsPrincipal principal) }; // TODO: implement IChangeToken for this - - _cache.Set(subject, user, new TimeSpan(0, 5, 0)); + _cache.Set(subject, user, new TimeSpan(0, 5, 0)); } - var claims = new List(); - claims.Add(new Claim(AppConstants.SubjectClaimName, user.Id)); - claims.Add(new Claim(AppConstants.NameClaimName, user.Name ?? "")); - claims.Add(new Claim(AppConstants.ApprovedNameClaimName, user.ApprovedName ?? "")); - claims.Add(new Claim(AppConstants.RoleListClaimName, user.Role.ToString())); + var claims = new List + { + new Claim(AppConstants.SubjectClaimName, user.Id), + new Claim(AppConstants.NameClaimName, user.Name ?? ""), + new Claim(AppConstants.ApprovedNameClaimName, user.ApprovedName ?? ""), + new Claim(AppConstants.RoleListClaimName, user.Role.ToString()) + }; - foreach(string role in user.Role.ToString().Replace(" ", "").Split(',')) + foreach (string role in user.Role.ToString().Replace(" ", "").Split(',')) claims.Add(new Claim(AppConstants.RoleClaimName, role)); return new ClaimsPrincipal( diff --git a/src/Gameboard.Api/Features/User/UserController.cs b/src/Gameboard.Api/Features/User/UserController.cs index a0bab8ef..7751cc73 100644 --- a/src/Gameboard.Api/Features/User/UserController.cs +++ b/src/Gameboard.Api/Features/User/UserController.cs @@ -17,12 +17,18 @@ using Gameboard.Api.Hubs; using Gameboard.Api.Services; using Gameboard.Api.Validators; +using Gameboard.Api.Features.Users; +using MediatR; +using Microsoft.AspNetCore.Http; namespace Gameboard.Api.Controllers { [Authorize] public class UserController : _Controller { + private readonly string _actingUserId; + private readonly IMediator _mediator; + UserService UserService { get; } CoreOptions Options { get; } IHubContext Hub { get; } @@ -33,12 +39,17 @@ public UserController( UserValidator validator, UserService userService, CoreOptions options, + IHttpContextAccessor httpContextAccessor, + IMediator mediator, IHubContext hub ) : base(logger, cache, validator) { UserService = userService; Options = options; Hub = hub; + + _actingUserId = httpContextAccessor.HttpContext.User.ToActor().Id; + _mediator = mediator; } /// @@ -49,16 +60,15 @@ IHubContext hub /// User [HttpPost("api/user")] [Authorize] - public async Task Create([FromBody] NewUser model) + public async Task TryCreate([FromBody] NewUser model) { - AuthorizeAny( + AuthorizeAny + ( () => Actor.IsRegistrar, () => model.Id == Actor.Id ); - await Validate(model); - - var user = await UserService.Create(model); + var result = await UserService.TryCreate(model); await HttpContext.SignInAsync( AppConstants.MksCookie, @@ -67,7 +77,7 @@ await HttpContext.SignInAsync( ) ); - return user; + return result; } /// @@ -126,6 +136,15 @@ public async Task Delete([FromRoute] string id) await UserService.Delete(id); } + /// + /// Get the user's active challenges. + /// + /// + /// + [HttpGet("/api/user/{userId}/challenges/active")] + public Task GetUserActiveChallenges([FromRoute] string userId) + => _mediator.Send(new GetUserActiveChallengesQuery(userId)); + /// /// Find users /// @@ -236,5 +255,10 @@ await audience.Announcement(new HubEvent ActingUser = HubEventActingUserDescription.FromUser(Actor) }); } + + [HttpPut("/api/user/login")] + [Authorize] + public Task UpdateUserLoginEvents() + => _mediator.Send(new UpdateUserLoginEventsCommand(_actingUserId)); } } diff --git a/src/Gameboard.Api/Features/User/UserMapper.cs b/src/Gameboard.Api/Features/User/UserMapper.cs index afff5fc8..e8d539a8 100644 --- a/src/Gameboard.Api/Features/User/UserMapper.cs +++ b/src/Gameboard.Api/Features/User/UserMapper.cs @@ -2,6 +2,7 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using AutoMapper; +using Gameboard.Api.Common; using Gameboard.Api.Hubs; namespace Gameboard.Api.Services @@ -17,6 +18,8 @@ public UserMapper() CreateMap(); CreateMap(); CreateMap(); + CreateMap() + .ForMember(s => s.Name, opt => opt.MapFrom(u => u.ApprovedName)); CreateMap(); CreateMap(); CreateMap(); diff --git a/src/Gameboard.Api/Features/User/UserService.cs b/src/Gameboard.Api/Features/User/UserService.cs index bb984ec9..78c76eaa 100644 --- a/src/Gameboard.Api/Features/User/UserService.cs +++ b/src/Gameboard.Api/Features/User/UserService.cs @@ -14,26 +14,30 @@ namespace Gameboard.Api.Services; public class UserService { - IUserStore Store { get; } - IMapper Mapper { get; } + private readonly IMapper _mapper; + private readonly INowService _now; + private readonly IStore _store; private readonly IMemoryCache _localcache; private readonly INameService _namesvc; private readonly Defaults _defaultOptions; public UserService( - IUserStore store, + INowService now, + IStore store, IMapper mapper, IMemoryCache cache, INameService namesvc, Defaults defaultOptions ) { - Store = store; - Mapper = mapper; + _defaultOptions = defaultOptions; _localcache = cache; + _mapper = mapper; _namesvc = namesvc; - _defaultOptions = defaultOptions; + _now = now; + // _userStore = userStore; + _store = store; } /// @@ -41,59 +45,69 @@ Defaults defaultOptions /// /// /// - public async Task Create(NewUser model) + public async Task TryCreate(NewUser model) { - var entity = await Store.Retrieve(model.Id); + if (model.Id.IsEmpty()) + throw new ArgumentException(nameof(model.Id)); - if (entity is Data.User && entity.Id.HasValue()) - { - // entity.Name = model.Name; - // // entity.Email = model.Email; - // // entity.Username = model.Username; - // await Store.Update(entity); - } - else + var entity = await _store.Retrieve(model.Id); + if (entity is not null) { - entity = Mapper.Map(model); - - bool found = false; - int i = 0; - do + return new TryCreateUserResult { - entity.ApprovedName = _namesvc.GetRandomName(); - entity.Name = entity.ApprovedName; + IsNewUser = false, + User = _mapper.Map(entity) + }; + } - // check uniqueness - found = await Store.DbSet.AnyAsync(p => - p.Id != entity.Id && - p.Name == entity.Name - ); - } while (found && i++ < 20); + entity = _mapper.Map(model); + entity.Sponsor = _defaultOptions.DefaultSponsor; - entity.Sponsor = _defaultOptions.DefaultSponsor; + // first user gets admin + if (!await _store.AnyAsync()) + entity.Role = AppConstants.AllRoles; - await Store.Create(entity); + if (entity.CreatedOn.DoesntHaveValue()) + { + entity.CreatedOn = _now.Get(); + entity.LastLoginDate = entity.CreatedOn; } - _localcache.Remove(entity.Id); + bool found = false; + int i = 0; + do + { + entity.ApprovedName = _namesvc.GetRandomName(); + entity.Name = entity.ApprovedName; + + // check uniqueness + found = await _store.AnyAsync(p => p.Id != entity.Id && p.Name == entity.Name); + } while (found && i++ < 20); - return Mapper.Map(entity); + await _store.Create(entity); + + _localcache.Remove(entity.Id); + return new TryCreateUserResult + { + IsNewUser = true, + User = _mapper.Map(entity) + }; } public async Task Retrieve(string id) { - return Mapper.Map(await Store.Retrieve(id)); + return _mapper.Map(await _store.Retrieve(id)); } public async Task Update(ChangedUser model, bool sudo, bool admin = false) { - var entity = await Store.Retrieve(model.Id); + var entity = await _store.Retrieve(model.Id); bool differentName = entity.Name != model.Name; if (!sudo) { - Mapper.Map( - Mapper.Map(model), + _mapper.Map( + _mapper.Map(model), entity ); @@ -107,13 +121,13 @@ public async Task Update(ChangedUser model, bool sudo, bool admin = false) if (!admin && model.Role != entity.Role) throw new ActionForbidden(); - Mapper.Map(model, entity); + _mapper.Map(model, entity); } if (differentName) { // check uniqueness - bool found = await Store.DbSet.AnyAsync(p => + bool found = await _store.DbSet.AnyAsync(p => p.Id != entity.Id && p.Name == entity.Name ); @@ -122,23 +136,21 @@ public async Task Update(ChangedUser model, bool sudo, bool admin = false) entity.NameStatus = AppConstants.NameStatusNotUnique; } - await Store.Update(entity); - + await _store.Update(entity); _localcache.Remove(entity.Id); - } public async Task Delete(string id) { - await Store.Delete(id); + await _store.Delete(id); _localcache.Remove(id); } public async Task> List(UserSearch model) where TProject : class, IUserViewModel { - var q = Store.List(model.Term); + var q = _store.List(model.Term); - if (model.Term.HasValue()) + if (model.Term.NotEmpty()) { model.Term = model.Term.ToLower(); q = q.Where(u => @@ -164,19 +176,19 @@ public async Task> List(UserSearch model) where if (model.Take > 0) q = q.Take(model.Take); - return await Mapper + return await _mapper .ProjectTo(q) .ToArrayAsync(); } public async Task ListSupport(SearchFilter model) { - var q = Store.List(model.Term); + var q = _store.List(model.Term); // Might want to also include observers if they can be assigned. Or just make possible assignees "Support" roles q = q.Where(u => u.Role.HasFlag(UserRole.Support)); - if (model.Term.HasValue()) + if (model.Term.NotEmpty()) { model.Term = model.Term.ToLower(); q = q.Where(u => @@ -186,20 +198,7 @@ public async Task ListSupport(SearchFilter model) ); } - return await Mapper.ProjectTo(q).ToArrayAsync(); - } - - internal string ResolveRandomName(IUserStore store, INameService nameSvc, User entity) - { - var randomName = nameSvc.GetRandomName(); - var existing = store.DbSet.AnyAsync(p => p.Id != entity.Id && p.Name == entity.Name); - - if (existing != null) - { - return $"{randomName}_${DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; - } - - return randomName; + return await _mapper.ProjectTo(q).ToArrayAsync(); } internal bool HasRole(User user, UserRole role) diff --git a/src/Gameboard.Api/Features/User/UserStore.cs b/src/Gameboard.Api/Features/User/UserStore.cs deleted file mode 100644 index feb0780d..00000000 --- a/src/Gameboard.Api/Features/User/UserStore.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2021 Carnegie Mellon University. All Rights Reserved. -// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. - -using System.Linq; -using System.Threading.Tasks; -using Gameboard.Api.Data.Abstractions; -using Gameboard.Api.Services; - -namespace Gameboard.Api.Data -{ - - public class UserStore : Store, IUserStore - { - public UserStore(IGuidService guids, GameboardDbContext dbContext) - : base(guids, dbContext) { } - - public override Task Create(User entity) - { - // first user gets admin - if (DbSet.Any().Equals(false)) - entity.Role = AppConstants.AllRoles; - - return base.Create(entity); - } - } -} diff --git a/src/Gameboard.Api/Features/User/UserValidator.cs b/src/Gameboard.Api/Features/User/UserValidator.cs index a60179fb..8cddcc77 100644 --- a/src/Gameboard.Api/Features/User/UserValidator.cs +++ b/src/Gameboard.Api/Features/User/UserValidator.cs @@ -3,8 +3,7 @@ using System.Threading.Tasks; using Gameboard.Api.Data.Abstractions; -using Gameboard.Api.Features.ApiKeys; -using Gameboard.Api.Features.Users; +using Gameboard.Api.Services; namespace Gameboard.Api.Validators { @@ -12,11 +11,11 @@ namespace Gameboard.Api.Validators public class UserValidator : IModelValidator { private readonly INowService _now; - private readonly IUserStore _store; + private readonly IStore _store; public UserValidator( INowService now, - IUserStore store + IStore store ) { _now = now; @@ -28,9 +27,6 @@ public Task Validate(object model) if (model is Entity) return _validate(model as Entity); - if (model is NewUser) - return _validate(model as NewUser); - if (model is ChangedUser) return _validate(model as ChangedUser); @@ -39,7 +35,7 @@ public Task Validate(object model) private async Task _validate(Entity model) { - if ((await Exists(model.Id)).Equals(false)) + if (!await _store.Exists(model.Id)) throw new ResourceNotFound(model.Id); await Task.CompletedTask; @@ -53,11 +49,6 @@ private async Task _validate(ChangedUser model) await Task.CompletedTask; } - private async Task _validate(NewUser model) - { - await Task.CompletedTask; - } - private async Task Exists(string id) { return diff --git a/src/Gameboard.Api/Features/_Controller.cs b/src/Gameboard.Api/Features/_Controller.cs index 64b2de0a..5e447861 100644 --- a/src/Gameboard.Api/Features/_Controller.cs +++ b/src/Gameboard.Api/Features/_Controller.cs @@ -12,7 +12,6 @@ namespace Gameboard.Api.Controllers { public class _Controller : ControllerBase, IActionFilter { - public _Controller( ILogger logger, IDistributedCache cache, @@ -25,13 +24,15 @@ params IModelValidator[] validators } protected User Actor { get; set; } + protected string AuthenticatedGraderForChallengeId { get; set; } protected ILogger Logger { get; private set; } protected IDistributedCache Cache { get; private set; } - private IModelValidator[] _validators; + private readonly IModelValidator[] _validators; public virtual void OnActionExecuting(ActionExecutingContext context) { Actor = User.ToActor(); + AuthenticatedGraderForChallengeId = User.ToAuthenticatedGraderForChallengeId(); } public void OnActionExecuted(ActionExecutedContext context) diff --git a/src/Gameboard.Api/Gameboard.Api.csproj b/src/Gameboard.Api/Gameboard.Api.csproj index f95da247..e7b236dd 100644 --- a/src/Gameboard.Api/Gameboard.Api.csproj +++ b/src/Gameboard.Api/Gameboard.Api.csproj @@ -1,24 +1,25 @@ - + - - - - - - + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - + + + + + + - - + + diff --git a/src/Gameboard.Api/Program.cs b/src/Gameboard.Api/Program.cs index e6d261bf..9327eff7 100644 --- a/src/Gameboard.Api/Program.cs +++ b/src/Gameboard.Api/Program.cs @@ -4,8 +4,8 @@ using System; using System.Linq; using System.Runtime.CompilerServices; +using Gameboard.Api; using Gameboard.Api.Extensions; -using Gameboard.Api.Services; using Gameboard.Api.Structure; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Logging; @@ -26,7 +26,7 @@ ConfToEnv.Load($"appsettings.{envname}.conf"); ConfToEnv.Load(path); -startupLogger.LogInformation($"Starting Gameboard in {envname} configuration."); +startupLogger.LogInformation(message: $"Starting Gameboard in {envname} configuration."); // create an application builder var builder = WebApplication.CreateBuilder(args); diff --git a/src/Gameboard.Api/Services/ActingUserService.cs b/src/Gameboard.Api/Services/ActingUserService.cs index a6dd354e..2c447583 100644 --- a/src/Gameboard.Api/Services/ActingUserService.cs +++ b/src/Gameboard.Api/Services/ActingUserService.cs @@ -9,11 +9,11 @@ public interface IActingUserService internal class ActingUserService : IActingUserService { - private readonly User _actingUser; + private readonly User _actingUser = null; public ActingUserService(IHttpContextAccessor httpContextAccessor) { - _actingUser = httpContextAccessor.HttpContext.User.ToActor(); + _actingUser = httpContextAccessor?.HttpContext?.User?.ToActor(); } public User Get() diff --git a/src/Gameboard.Api/Services/FlattenService.cs b/src/Gameboard.Api/Services/FlattenService.cs new file mode 100644 index 00000000..53456505 --- /dev/null +++ b/src/Gameboard.Api/Services/FlattenService.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Dynamic; +using System.Reflection; + +namespace Gameboard.Api.Common.Services; + +// TODO: this is a potentially promising approach to flattening for CSV, but +// the enumerable case is a bit tricky to figure out because we may want "cross-joined" +// data, and doing that via reflection could be pretty crazy. leaving it in because it might be +// useful later, but isn't now. +public interface IFlattenService +{ + IEnumerable Flatten(IEnumerable items) where T : class; +} + +internal class FlattenService : IFlattenService +{ + public IEnumerable Flatten(IEnumerable items) where T : class + { + var output = new List(); + + foreach (var item in items) + { + dynamic dItem = new ExpandoObject(); + var propertyValues = GetPropertyValues(item, string.Empty, new Dictionary()); + + foreach (var propertyValue in propertyValues) + (dItem as IDictionary).Add(propertyValue.Key, propertyValue.Value); + + output.Add(dItem); + } + + return output; + } + + private IDictionary GetPropertyValues(TItem item, string namePath, IDictionary propertyValues) + { + Console.WriteLine($"FLATTEN::{namePath}"); + if (item is null || IsLeafType(item.GetType())) + { + propertyValues.Add(namePath, item); + return propertyValues; + } + + if (item.GetType().IsAssignableTo(typeof(IEnumerable))) + { + + } + + foreach (var prop in item.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + GetPropertyValues(prop.GetValue(item), $"{namePath}{prop.Name}", propertyValues); + } + + return propertyValues; + } + + private bool IsLeafType(Type type) + => type.IsPrimitive || type.IsEnum || type.IsValueType || type == typeof(string); +} diff --git a/src/Gameboard.Api/Services/GuidService.cs b/src/Gameboard.Api/Services/GuidService.cs index f3054dd9..1be51765 100644 --- a/src/Gameboard.Api/Services/GuidService.cs +++ b/src/Gameboard.Api/Services/GuidService.cs @@ -9,8 +9,14 @@ public interface IGuidService internal class GuidService : IGuidService { - public string GetGuid() + // static so it can be referred to in places where we can't easily inject, like migrations + public static string StaticGenerateGuid() { return Guid.NewGuid().ToString("n"); } + + public string GetGuid() + { + return StaticGenerateGuid(); + } } diff --git a/src/Gameboard.Api/Services/HashService.cs b/src/Gameboard.Api/Services/HashService.cs deleted file mode 100644 index 510b90bf..00000000 --- a/src/Gameboard.Api/Services/HashService.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Security.Cryptography; -using System.Text; - -namespace Gameboard.Api.Services; - -public interface IHashService -{ - string Hash(string input); -} - -internal class HashService : IHashService -{ - public string Hash(string input) - { - using var sha = SHA512.Create(); - return Convert.ToBase64String(sha.ComputeHash(Encoding.UTF8.GetBytes(input))); - } -} diff --git a/src/Gameboard.Api/Services/HtmlToImageService.cs b/src/Gameboard.Api/Services/HtmlToImageService.cs new file mode 100644 index 00000000..679793d4 --- /dev/null +++ b/src/Gameboard.Api/Services/HtmlToImageService.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Gameboard.Api.Common.Services; + +public interface IHtmlToImageService +{ + Task ToPdf(string fileName, string htmlString, int? width = null, int? height = null); + + /// + /// Convert a string of HTML to a PNG image. + /// + /// + /// + /// The height of the image to be generated. If left null or if width is set, will infer automatically. + /// The height of the image to be generated. If left null or if height is set, will infer automatically. + /// + Task ToPng(string fileName, string htmlString, int? width = null, int? height = null); +} + +internal class HtmlToImageService : IHtmlToImageService +{ + private readonly CoreOptions _coreOptions; + + public HtmlToImageService(CoreOptions coreOptions) + { + _coreOptions = coreOptions; + } + + public async Task ToPdf(string fileName, string htmlString, int? width = null, int? height = null) + { + var tempHtmlPath = Path.Combine(_coreOptions.TempDirectory, $"{fileName}.html"); + var pdfPath = Path.Combine(_coreOptions.TempDirectory, $"{fileName}.pdf"); + await File.WriteAllTextAsync(tempHtmlPath, htmlString); + + var args = new string[] + { + "--title", + """ "Gameboard Certificate" '""", + // "--dpi", + // "300", + "--margin-top", + "0mm", + "--margin-right", + "0mm", + "--margin-bottom", + "0mm", + "--margin-left", + "0mm", + "-O", + "Landscape", + "--page-size", + "Letter", + "--no-outline", + tempHtmlPath, + pdfPath + }; + + // run chromium and verify + var result = await StartProcessAsync.StartAsync("wkhtmltopdf", args); + if (result != 0) + throw new Exception("PDF generation failed."); + + var pdfBytes = await File.ReadAllBytesAsync(pdfPath); + + File.Delete(tempHtmlPath); + File.Delete(pdfPath); + return pdfBytes; + } + + public async Task ToPng(string fileName, string htmlString, int? width = null, int? height = null) + { + var tempImageResult = await ToTempImage(fileName, htmlString, width, height); + var imageBytes = await File.ReadAllBytesAsync(tempImageResult.TempImagePath); + + tempImageResult.Delete(); + return imageBytes; + } + + private class ToTempImageResult + { + public required string TempImagePath { get; set; } + public required string TempHtmlPath { get; set; } + + public void Delete() + { + File.Delete(TempHtmlPath); + File.Delete(TempImagePath); + } + } + + private async Task ToTempImage(string fileName, string htmlString, int? width = null, int? height = null) + { + // create temp paths + var tempHtmlPath = Path.Combine(_coreOptions.TempDirectory, $"{fileName}.html"); + var tempImagePath = Path.Combine(_coreOptions.TempDirectory, $"{fileName}.png"); + await File.WriteAllTextAsync(tempHtmlPath, htmlString); + + // // save it with chromium headless + // var args = new string[] + // { + // "--headless", + // "--no-sandbox", + // "--disable-gpu", + // "--landscape", + // // ask chromium not to use dev shared memory - it defaults to only 64mb on docker + // "--disable-dev-shm-usage", + // width != null && height != null ? $"--window-size={width.Value}x{height.Value}" : null, + // $"--screenshot={tempImagePath}", + // tempHtmlPath + // } + // .Where(arg => !arg.IsEmpty()) + // .ToArray(); + + // // run chromium and verify + // var result = await StartProcessAsync.StartAsync("chromium", args); + + // save it with wkhtmltoimage + var args = new string[] + { + "-f", + "png", + "--quality", + "30", + tempHtmlPath, + tempImagePath + }; + + var result = await StartProcessAsync.StartAsync("wkhtmltoimage", args); + if (result != 0) + throw new Exception("Image generation failed."); + + // return the temp paths of both files for cleanup later + return new ToTempImageResult + { + TempHtmlPath = tempHtmlPath, + TempImagePath = tempImagePath + }; + } +} diff --git a/src/Gameboard.Api/Services/JsonService.cs b/src/Gameboard.Api/Services/JsonService.cs index 6966cbb1..f00391c0 100644 --- a/src/Gameboard.Api/Services/JsonService.cs +++ b/src/Gameboard.Api/Services/JsonService.cs @@ -7,12 +7,21 @@ namespace Gameboard.Api.Services; public interface IJsonService { string Serialize(T obj) where T : class; - T Deserialize(string json) where T : class, new(); + T Deserialize(string json) where T : new(); } internal class JsonService : IJsonService { - internal static Action BuildJsonSerializerOptions() + public JsonService() { } + + public static JsonSerializerOptions GetJsonSerializerOptions() + { + var options = new JsonSerializerOptions(); + BuildJsonSerializerOptions()(options); + return options; + } + + public static Action BuildJsonSerializerOptions() { return options => { @@ -20,12 +29,12 @@ internal static Action BuildJsonSerializerOptions() options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.ReferenceHandler = ReferenceHandler.IgnoreCycles; options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - options.Converters.Add(new JsonDateTimeConverter()); + options.Converters.Add(new JsonDateTimeOffsetConverter()); }; } - internal static JsonService WithGameboardSerializerOptions() - => new JsonService(BuildJsonSerializerOptions()); + public static JsonService WithGameboardSerializerOptions() + => new(BuildJsonSerializerOptions()); public JsonSerializerOptions Options { get; private set; } @@ -40,8 +49,11 @@ public JsonService(JsonSerializerOptions options) Options = options; } - public T Deserialize(string json) where T : class, new() + public T Deserialize(string json) where T : new() { + if (json.IsEmpty()) + return default; + return JsonSerializer.Deserialize(json, Options); } diff --git a/src/Gameboard.Api/Services/NowService.cs b/src/Gameboard.Api/Services/NowService.cs index db1300c7..976304a1 100644 --- a/src/Gameboard.Api/Services/NowService.cs +++ b/src/Gameboard.Api/Services/NowService.cs @@ -1,5 +1,7 @@ using System; +namespace Gameboard.Api.Services; + public interface INowService { public DateTimeOffset Get(); diff --git a/src/Gameboard.Api/Services/StartupLogger.cs b/src/Gameboard.Api/Services/StartupLogger.cs index b80a27de..b6000184 100644 --- a/src/Gameboard.Api/Services/StartupLogger.cs +++ b/src/Gameboard.Api/Services/StartupLogger.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using Microsoft.Extensions.Logging; -namespace Gameboard.Api.Services; +namespace Gameboard.Api; public sealed class ColorConsoleLoggerConfiguration { diff --git a/src/Gameboard.Api/Services/TimeWindowService.cs b/src/Gameboard.Api/Services/TimeWindowService.cs deleted file mode 100644 index 0facb25c..00000000 --- a/src/Gameboard.Api/Services/TimeWindowService.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; - -namespace Gameboard.Api.Features.Player; - -public class TimeWindow -{ - public DateTimeOffset Now { get; } - public DateTimeOffset Start { get; } - public DateTimeOffset End { get; } - public TimeSpan Duration { get; private set; } - public TimeWindowState State { get; private set; } - public TimeSpan? TimeUntilStart { get; private set; } - public TimeSpan? TimeUntilEnd { get; private set; } - - public TimeWindow(DateTimeOffset now, DateTimeOffset start, DateTimeOffset end) - { - Now = now; - Start = start; - End = end; - - State = TimeWindowState.Before; - if (now >= start && now < end) - { - State = TimeWindowState.During; - } - else if (now >= end) - { - State = TimeWindowState.After; - } - - TimeUntilStart = (now < start ? start - now : null); - TimeUntilEnd = (now < end ? end - now : null); - Duration = end - start; - } -} - -public enum TimeWindowState -{ - Before, - During, - After -} - -public interface ITimeWindowService -{ - TimeWindow CreateWindow(DateTimeOffset start, DateTimeOffset end); -} - -public class TimeWindowService : ITimeWindowService -{ - private readonly INowService _now; - - public TimeWindowService(INowService now) - { - _now = now; - } - - public TimeWindow CreateWindow(DateTimeOffset start, DateTimeOffset end) - { - if (start >= end) - throw new ArgumentException("Can't create a time window with end date occurring before the start date."); - - return new TimeWindow(_now.Get(), start, end); - } -} diff --git a/src/Gameboard.Api/Structure/AppSettings.cs b/src/Gameboard.Api/Structure/AppSettings.cs index 746cde58..75525282 100644 --- a/src/Gameboard.Api/Structure/AppSettings.cs +++ b/src/Gameboard.Api/Structure/AppSettings.cs @@ -170,12 +170,12 @@ public class CoreOptions public string SupportUploadsRequestPath { get; set; } = "supportfiles"; public string SupportUploadsFolder { get; set; } = "wwwroot/supportfiles"; public string ChallengeDocUrl { get; set; } + public string TempDirectory { get; set; } = "wwwroot/temp"; + public string TemplatesDirectory { get; set; } = "wwwroot/templates"; public string SafeNamesFile { get; set; } = "names.json"; public string KeyPrefix { get; set; } = "GB"; public string GamebrainApiKey { get; set; } - public int MaxPracticeSessions { get; set; } = 0; - public int PracticeSessionMinutes { get; set; } = 60; - public int MaxPracticeSessionMinutes { get; set; } = 0; + public string WebHostRoot { get; set; } } public class CrucibleOptions diff --git a/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationChallengeException.cs b/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationChallengeException.cs index a80e29a0..e2ba9730 100644 --- a/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationChallengeException.cs +++ b/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationChallengeException.cs @@ -1,6 +1,6 @@ using System; -namespace Gameboard.Api.Auth; +namespace Gameboard.Api.Structure.Auth; public class ApiKeyAuthenticationChallengeException : Exception { diff --git a/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationExtensions.cs b/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationExtensions.cs index 6d995256..9a336165 100644 --- a/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationExtensions.cs +++ b/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationExtensions.cs @@ -3,7 +3,7 @@ using System; using Microsoft.AspNetCore.Authentication; -using Gameboard.Api.Auth; +using Gameboard.Api.Structure.Auth; namespace Microsoft.Extensions.DependencyInjection; diff --git a/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationHandler.cs b/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationHandler.cs index d67f4146..c0027ea2 100644 --- a/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationHandler.cs +++ b/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationHandler.cs @@ -4,6 +4,7 @@ using System.Security.Claims; using System.Text.Encodings.Web; using System.Threading.Tasks; +using Gameboard.Api.Data; using Gameboard.Api.Features.ApiKeys; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -11,7 +12,7 @@ using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; -namespace Gameboard.Api.Auth; +namespace Gameboard.Api.Structure.Auth; public static class ApiKeyAuthentication { @@ -38,8 +39,8 @@ IApiKeysService apiKeyService protected override async Task HandleAuthenticateAsync() { - StringValues requestApiKey; - if (!Request.Headers.TryGetValue(ApiKeyAuthentication.ApiKeyHeaderName, out requestApiKey)) + var requestApiKey = ResolveRequestApiKey(Request); + if (requestApiKey.IsEmpty()) { return AuthenticateResult.NoResult(); } @@ -54,10 +55,11 @@ protected override async Task HandleAuthenticateAsync() } var principal = new ClaimsPrincipal( - new ClaimsIdentity( + new ClaimsIdentity + ( new Claim[] { - new Claim(AppConstants.SubjectClaimName, user.Id), - new Claim(AppConstants.NameClaimName, user.Name), + new Claim(AppConstants.SubjectClaimName, user.Id), + new Claim(AppConstants.NameClaimName, user.Name), }, Scheme.Name ) @@ -76,8 +78,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // for now, we're just doing x-api-key to standardize access internal string ResolveRequestApiKey(HttpRequest request) { - StringValues headerApiKey; - if (request.Headers.TryGetValue(ApiKeyAuthentication.ApiKeyHeaderName, out headerApiKey)) + if (request.Headers.TryGetValue(ApiKeyAuthentication.ApiKeyHeaderName, out StringValues headerApiKey)) { return headerApiKey; } diff --git a/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationOptions.cs b/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationOptions.cs index d3dacb77..421ec895 100644 --- a/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationOptions.cs +++ b/src/Gameboard.Api/Structure/Auth/ApiKeys/ApiKeyAuthenticationOptions.cs @@ -1,11 +1,11 @@ using Microsoft.AspNetCore.Authentication; -namespace Gameboard.Api.Auth; +namespace Gameboard.Api.Structure.Auth; public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { - public static int MIN_BYTES_RANDOMNESS = 16; - public static int MIN_RANDOMNESS_LENGTH = 10; + private readonly static int MIN_BYTES_RANDOMNESS = 16; + private readonly static int MIN_RANDOMNESS_LENGTH = 10; public int BytesOfRandomness { get; set; } = 32; public string KeyPrefix { get; set; } = "GB"; diff --git a/src/Gameboard.Api/Structure/Auth/AuthenticationStartupExtensions.cs b/src/Gameboard.Api/Structure/Auth/AuthenticationStartupExtensions.cs index fbe7b200..d22137da 100644 --- a/src/Gameboard.Api/Structure/Auth/AuthenticationStartupExtensions.cs +++ b/src/Gameboard.Api/Structure/Auth/AuthenticationStartupExtensions.cs @@ -4,6 +4,7 @@ using System.IdentityModel.Tokens.Jwt; using Gameboard.Api; using Gameboard.Api.Auth; +using Gameboard.Api.Structure.Auth; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Hosting; @@ -15,7 +16,8 @@ namespace Microsoft.Extensions.DependencyInjection { public static class AuthenticationStartupExtensions { - public static IServiceCollection AddConfiguredAuthentication( + public static IServiceCollection AddConfiguredAuthentication + ( this IServiceCollection services, OidcOptions oidcOptions, ApiKeyOptions apiKeyOptions, @@ -29,7 +31,7 @@ IWebHostEnvironment environment IdentityModelEventSource.ShowPII = true; } - services + _ = services .AddScoped() .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(jwt => @@ -67,12 +69,13 @@ IWebHostEnvironment environment return System.Threading.Tasks.Task.CompletedTask; }; }) + .AddGraderKeyAuthentication(GraderKeyAuthentication.AuthenticationScheme, opt => { }) .AddApiKeyAuthentication(ApiKeyAuthentication.AuthenticationScheme, opt => { opt.BytesOfRandomness = apiKeyOptions.BytesOfRandomness; opt.RandomCharactersLength = apiKeyOptions.RandomCharactersLength; }) - .AddTicketAuthentication(TicketAuthentication.AuthenticationScheme, opt => new TicketAuthenticationOptions()) + .AddTicketAuthentication(TicketAuthentication.AuthenticationScheme, opt => { }) ; return services; diff --git a/src/Gameboard.Api/Structure/Auth/AuthorizationStartupExtensions.cs b/src/Gameboard.Api/Structure/Auth/AuthorizationStartupExtensions.cs index c2fc3429..ce60964e 100644 --- a/src/Gameboard.Api/Structure/Auth/AuthorizationStartupExtensions.cs +++ b/src/Gameboard.Api/Structure/Auth/AuthorizationStartupExtensions.cs @@ -3,6 +3,7 @@ using Gameboard.Api; using Gameboard.Api.Auth; +using Gameboard.Api.Structure.Auth; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; @@ -80,7 +81,7 @@ this IServiceCollection services .RequireAuthenticatedUser() .AddAuthenticationSchemes( JwtBearerDefaults.AuthenticationScheme, - ApiKeyAuthentication.AuthenticationScheme + GraderKeyAuthentication.AuthenticationScheme ).Build() ); diff --git a/src/Gameboard.Api/Structure/Auth/ClaimsPrincipalExtensions.cs b/src/Gameboard.Api/Structure/Auth/ClaimsPrincipalExtensions.cs index 364e1a24..97a35954 100644 --- a/src/Gameboard.Api/Structure/Auth/ClaimsPrincipalExtensions.cs +++ b/src/Gameboard.Api/Structure/Auth/ClaimsPrincipalExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Security.Claims; +using Gameboard.Api.Structure.Auth; namespace Gameboard.Api { @@ -20,6 +21,14 @@ public static User ToActor(this ClaimsPrincipal principal) }; } + public static string ToAuthenticatedGraderForChallengeId(this ClaimsPrincipal principal) + { + if (!principal.HasClaim(claim => claim.Type == GraderKeyAuthentication.GraderKeyChallengeIdClaimName)) + return null; + + return principal.FindFirstValue(GraderKeyAuthentication.GraderKeyChallengeIdClaimName); + } + public static string Subject(this ClaimsPrincipal principal) { return principal.FindFirstValue(AppConstants.SubjectClaimName); diff --git a/src/Gameboard.Api/Structure/Auth/GraderKey/GraderKeyAuthenticationHandler.cs b/src/Gameboard.Api/Structure/Auth/GraderKey/GraderKeyAuthenticationHandler.cs new file mode 100644 index 00000000..2d26d446 --- /dev/null +++ b/src/Gameboard.Api/Structure/Auth/GraderKey/GraderKeyAuthenticationHandler.cs @@ -0,0 +1,89 @@ +using System; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Gameboard.Api.Auth; +using Gameboard.Api.Data; +using Gameboard.Api.Data.Abstractions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace Gameboard.Api.Structure.Auth; + +internal static class GraderKeyAuthentication +{ + public const string AuthenticationScheme = "GraderKey"; + public const string GraderKeyHeaderName = "Grader-Key"; + public const string GraderKeyChallengeIdClaimName = "GraderKeyChallengeId"; +} + +public class GraderKeyAuthenticationOptions : AuthenticationSchemeOptions { } + +internal class GraderKeyUnresolvedChallengeException : GameboardException +{ + public GraderKeyUnresolvedChallengeException(string graderKey) : base($"{nameof(GraderKeyAuthenticationHandler)} - failed authentication attempt - Couldn't resolve a challenge for grader key {graderKey}") { } +} + +public static class GraderKeyAuthenticationExtensions +{ + public static AuthenticationBuilder AddGraderKeyAuthentication + ( + this AuthenticationBuilder builder, + string scheme, + Action options + ) => builder.AddScheme + ( + scheme ?? GraderKeyAuthentication.AuthenticationScheme, + options + ); +} + +internal class GraderKeyAuthenticationHandler : AuthenticationHandler +{ + private readonly IChallengeStore _challengeStore; + public GraderKeyAuthenticationHandler + ( + IOptionsMonitor options, + ILoggerFactory loggerFactory, + UrlEncoder urlEncoder, + ISystemClock sysClock, + IChallengeStore challengeStore + ) : base(options, loggerFactory, urlEncoder, sysClock) + { + _challengeStore = challengeStore; + } + + protected override async Task HandleAuthenticateAsync() + { + // prefer values set with the Grader-Key header + if (!Request.Headers.TryGetValue(GraderKeyAuthentication.GraderKeyHeaderName, out StringValues graderKey)) + // but also accept values from the x-api-key header + if (!Request.Headers.TryGetValue(ApiKeyAuthentication.ApiKeyHeaderName, out graderKey)) + return AuthenticateResult.NoResult(); + + + var hashedKey = graderKey.ToString().ToSha256(); + var challenge = await _challengeStore + .List() + .AsNoTracking() + .SingleOrDefaultAsync(c => c.GraderKey == hashedKey); + + if (challenge is null) + return AuthenticateResult.Fail(new GraderKeyUnresolvedChallengeException(graderKey)); + + var claimsPrincipal = new ClaimsPrincipal + ( + new ClaimsIdentity + ( + new Claim[] { new Claim(GraderKeyAuthentication.GraderKeyChallengeIdClaimName, challenge.Id) }, + Scheme.Name + ) + ); + + Logger.Log(LogLevel.Information, $"Authenticated challenge grader for challenge {challenge.Id} authenticated with grader key '{graderKey}'."); + return AuthenticateResult.Success(new AuthenticationTicket(claimsPrincipal, Scheme.Name)); + } +} diff --git a/src/Gameboard.Api/Structure/Auth/TicketAuthenticationHandler.cs b/src/Gameboard.Api/Structure/Auth/TicketAuthenticationHandler.cs index c171e051..43cce452 100644 --- a/src/Gameboard.Api/Structure/Auth/TicketAuthenticationHandler.cs +++ b/src/Gameboard.Api/Structure/Auth/TicketAuthenticationHandler.cs @@ -74,7 +74,7 @@ protected override async Task HandleAuthenticateAsync() string value = await _cache.GetStringAsync(key); - if (!value.HasValue()) + if (value.IsEmpty()) return AuthenticateResult.NoResult(); await _cache.RemoveAsync(key); @@ -116,5 +116,4 @@ protected override async Task HandleChallengeAsync(AuthenticationProperties prop public class TicketAuthenticationOptions : AuthenticationSchemeOptions { } - } diff --git a/src/Gameboard.Api/Structure/AuthenticatingHandler.cs b/src/Gameboard.Api/Structure/AuthenticatingHandler.cs index 05ca26f2..cd3da0db 100644 --- a/src/Gameboard.Api/Structure/AuthenticatingHandler.cs +++ b/src/Gameboard.Api/Structure/AuthenticatingHandler.cs @@ -13,7 +13,6 @@ namespace Gameboard.Api { - public class AuthenticatingHandler : DelegatingHandler { private readonly IAsyncPolicy _policy; @@ -68,7 +67,7 @@ private void Authenticate(bool forceRefresh) } else { - _logger.LogError($"Error in {typeof(AuthenticatingHandler).Name}: {_token.Error}"); + _logger.LogError($"Error in {nameof(AuthenticatingHandler)}: {_token.Error}"); } } } diff --git a/src/Gameboard.Api/Structure/AuthenticationService.cs b/src/Gameboard.Api/Structure/AuthenticationService.cs index 4cbd776f..c5867234 100644 --- a/src/Gameboard.Api/Structure/AuthenticationService.cs +++ b/src/Gameboard.Api/Structure/AuthenticationService.cs @@ -3,12 +3,9 @@ using System; using System.Net.Http; -using System.Net.Http.Headers; using System.Threading; -using System.Threading.Tasks; using IdentityModel.Client; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Gameboard.Api { diff --git a/src/Gameboard.Api/Structure/Exceptions.cs b/src/Gameboard.Api/Structure/Exceptions.cs index b11ffd33..e54d06bf 100644 --- a/src/Gameboard.Api/Structure/Exceptions.cs +++ b/src/Gameboard.Api/Structure/Exceptions.cs @@ -36,7 +36,7 @@ internal CaptainResolutionFailure(string teamId, string message = null) internal class InvalidInvitationCode : GameboardException { - internal InvalidInvitationCode(string code, string reason) : base(reason) { } + internal InvalidInvitationCode(string code, string reason) : base($"""Can't join a team with code "{code}". {reason}""") { } } internal class InvalidParameterValue : GameboardValidationException @@ -44,6 +44,11 @@ internal class InvalidParameterValue : GameboardValidationException internal InvalidParameterValue(string parameterName, string ruleDescription, T value) : base($"""Parameter "{parameterName}" requires a value which complies with: "{ruleDescription}". Its value was "{value}". """) { } } + internal class MissingRequiredDate : GameboardValidationException + { + public MissingRequiredDate(string propertyName) : base($"The date property {propertyName} is required.") { } + } + internal class NotYetRegistered : GameboardException { internal NotYetRegistered(string playerId, string gameId) @@ -53,21 +58,21 @@ internal NotYetRegistered(string playerId, string gameId) internal class RegistrationIsClosed : GameboardException { internal RegistrationIsClosed(string gameId, string addlMessage = null) : - base($"Registration for game {gameId} is closed.{(addlMessage.HasValue() ? $" [{addlMessage}]" : string.Empty)}") + base($"Registration for game {gameId} is closed.{(addlMessage.NotEmpty() ? $" [{addlMessage}]" : string.Empty)}") { } } internal class ResourceAlreadyExists : GameboardException where T : class, IEntity { internal ResourceAlreadyExists(string id, string addlMessage = null) : - base($"Couldn't create resource '{id}' of type {typeof(T).Name} because it already exists.{(addlMessage.HasValue() ? $" {addlMessage}" : string.Empty)}") + base($"Couldn't create resource '{id}' of type {typeof(T).Name} because it already exists.{(addlMessage.NotEmpty() ? $" {addlMessage}" : string.Empty)}") { } } internal class ResourceNotFound : GameboardValidationException where T : class { internal ResourceNotFound(string id, string addlMessage = null) - : base($"Couldn't find resource {id} of type {typeof(T).Name}.{(addlMessage.HasValue() ? $" [{addlMessage}]" : string.Empty)}") { } + : base($"Couldn't find resource {id} of type {typeof(T).Name}.{(addlMessage.NotEmpty() ? $" [{addlMessage}]" : string.Empty)}") { } } internal class RequiresSameSponsor : GameboardException @@ -81,6 +86,11 @@ internal class SimpleValidatorException : GameboardValidationException public SimpleValidatorException(string message, Exception ex = null) : base(message, ex) { } } + internal class StartDateOccursAfterEndDate : GameboardValidationException + { + public StartDateOccursAfterEndDate(DateTimeOffset start, DateTimeOffset end) : base($"Invalid start/end date values supplied. Start date {start} occurs after End date {end}.") { } + } + internal class ValidationTypeFailure : GameboardException where TValidator : IModelValidator { internal ValidationTypeFailure(Type objectType) diff --git a/src/Gameboard.Api/Structure/Extensions/IListExtensions.cs b/src/Gameboard.Api/Structure/Extensions/IListExtensions.cs index e58d5c69..26c66b19 100644 --- a/src/Gameboard.Api/Structure/Extensions/IListExtensions.cs +++ b/src/Gameboard.Api/Structure/Extensions/IListExtensions.cs @@ -7,7 +7,6 @@ public static class IListExtensions { public static IList AddIf(this IList list, TItem item, Func condition) where TItem : class { - var conditionResult = condition(item); if (condition(item)) list.Add(item); diff --git a/src/Gameboard.Api/Structure/JobService.cs b/src/Gameboard.Api/Structure/JobService.cs index 8fc364b9..2b2ace4c 100644 --- a/src/Gameboard.Api/Structure/JobService.cs +++ b/src/Gameboard.Api/Structure/JobService.cs @@ -35,20 +35,18 @@ public Task StartAsync(CancellationToken cancellationToken) private void RunTasks(object state) { - using (var scope = _services.CreateScope()) + using var scope = _services.CreateScope(); + try { - try - { - var svc = scope.ServiceProvider.GetRequiredService(); - svc.SyncExpired().Wait(); - - var consoleMap = scope.ServiceProvider.GetRequiredService(); - consoleMap.Prune(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error running job"); - } + var svc = scope.ServiceProvider.GetRequiredService(); + svc.SyncExpired().Wait(); + + var consoleMap = scope.ServiceProvider.GetRequiredService(); + consoleMap.Prune(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error running job"); } } diff --git a/src/Gameboard.Api/Structure/JsonDateTimeConverter.cs b/src/Gameboard.Api/Structure/JsonDateTimeConverter.cs index ba7ea6f4..96fc3f84 100644 --- a/src/Gameboard.Api/Structure/JsonDateTimeConverter.cs +++ b/src/Gameboard.Api/Structure/JsonDateTimeConverter.cs @@ -7,16 +7,16 @@ namespace Gameboard.Api { - public class JsonDateTimeConverter : JsonConverter + public class JsonDateTimeOffsetConverter : JsonConverter { - public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - return DateTime.Parse(reader.GetString()); + return DateTimeOffset.Parse(reader.GetString()).ToUniversalTime(); } - public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) { - writer.WriteStringValue(DateTime.SpecifyKind(value, DateTimeKind.Utc)); + writer.WriteStringValue(value.ToUniversalTime()); } } } diff --git a/src/Gameboard.Api/Structure/MediatR/Authorizers/UserRoleAuthorizer.cs b/src/Gameboard.Api/Structure/MediatR/Authorizers/UserRoleAuthorizer.cs index 95f02870..cad5350e 100644 --- a/src/Gameboard.Api/Structure/MediatR/Authorizers/UserRoleAuthorizer.cs +++ b/src/Gameboard.Api/Structure/MediatR/Authorizers/UserRoleAuthorizer.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using Microsoft.AspNetCore.Http; @@ -6,8 +5,9 @@ namespace Gameboard.Api.Structure.MediatR.Authorizers; internal class UserRoleAuthorizer : IAuthorizer { - private User _actor; + private readonly User _actor; public IEnumerable AllowedRoles { get; set; } = new List { UserRole.Admin }; + public string AllowedUserId { get; set; } public UserRoleAuthorizer(IHttpContextAccessor httpContextAccessor) { @@ -19,12 +19,13 @@ public UserRoleAuthorizer(IHttpContextAccessor httpContextAccessor) public void Authorize() { + if (AllowedUserId.NotEmpty() && _actor.Id == AllowedUserId) + return; + foreach (var role in AllowedRoles) { if (_actor.Role.HasFlag(role)) - { return; - } } throw new ActionForbidden(); diff --git a/src/Gameboard.Api/Structure/MediatR/GameboardValidationException.cs b/src/Gameboard.Api/Structure/MediatR/GameboardValidationException.cs index 535e0cd3..5441a5cc 100644 --- a/src/Gameboard.Api/Structure/MediatR/GameboardValidationException.cs +++ b/src/Gameboard.Api/Structure/MediatR/GameboardValidationException.cs @@ -1,11 +1,10 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using System.Text; namespace Gameboard.Api.Structure; -abstract public class GameboardValidationException : ValidationException +abstract public class GameboardValidationException : Exception { internal GameboardValidationException(string message, Exception ex = null) : base($"{message}", ex) { } } diff --git a/src/Gameboard.Api/Structure/MediatR/Validators/EntityExistsValidator.cs b/src/Gameboard.Api/Structure/MediatR/Validators/EntityExistsValidator.cs index be67f3a0..6c4f5bbe 100644 --- a/src/Gameboard.Api/Structure/MediatR/Validators/EntityExistsValidator.cs +++ b/src/Gameboard.Api/Structure/MediatR/Validators/EntityExistsValidator.cs @@ -1,17 +1,18 @@ using System; using System.Threading.Tasks; using Gameboard.Api.Data; -using Gameboard.Api.Data.Abstractions; +using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Structure.MediatR.Validators; -internal class EntityExistsValidator : IGameboardValidator +public class EntityExistsValidator : IGameboardValidator + where TModel : class where TEntity : class, IEntity { - private readonly IStore _store; + private readonly IStore _store; private Func _idProperty; - public EntityExistsValidator(IStore store) + public EntityExistsValidator(IStore store) { _store = store; } @@ -21,7 +22,7 @@ public Func GetValidationTask() return async (model, context) => { var id = _idProperty(model); - if (!(await _store.Exists(id))) + if (!await _store.List().AnyAsync(e => e.Id == id)) context.AddValidationException(new ResourceNotFound(id)); }; } diff --git a/src/Gameboard.Api/Structure/MediatR/Validators/Exceptions.cs b/src/Gameboard.Api/Structure/MediatR/Validators/Exceptions.cs index e69de29b..324ce374 100644 --- a/src/Gameboard.Api/Structure/MediatR/Validators/Exceptions.cs +++ b/src/Gameboard.Api/Structure/MediatR/Validators/Exceptions.cs @@ -0,0 +1,3 @@ +using System; + + diff --git a/src/Gameboard.Api/Structure/MediatR/Validators/StartEndDateValidator.cs b/src/Gameboard.Api/Structure/MediatR/Validators/StartEndDateValidator.cs new file mode 100644 index 00000000..00e19389 --- /dev/null +++ b/src/Gameboard.Api/Structure/MediatR/Validators/StartEndDateValidator.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; + +namespace Gameboard.Api.Structure.MediatR.Validators; + +internal class StartEndDateValidator : IGameboardValidator +{ + public DateTimeOffset? EndDate { get; private set; } + public bool EndDateRequired { get; private set; } = false; + public DateTimeOffset? StartDate { get; private set; } + public bool StartDateRequired { get; private set; } = false; + + public Func StartDateProperty { get; set; } + public Func EndDateProperty { get; set; } + + private StartEndDateValidator() { } + + public static StartEndDateValidator Configure(Action> configAction) + { + var validator = new StartEndDateValidator(); + configAction(validator); + + if (validator.StartDateProperty == null) + throw new InvalidOperationException($"{nameof(StartEndDateValidator)} can't validate without a {nameof(StartEndDateValidator.StartDateProperty)} specified."); + + if (validator.EndDateProperty == null) + throw new InvalidOperationException($"{nameof(StartEndDateValidator)} can't validate without a {nameof(StartEndDateValidator.EndDateProperty)} specified."); + + return validator; + } + + public Func GetValidationTask() + { + return (model, context) => + { + var startDateValue = StartDateProperty(model); + var endDateValue = EndDateProperty(model); + + if (StartDateRequired && (startDateValue == null || startDateValue.Value.DoesntHaveValue())) + return Task.FromResult(new MissingRequiredDate(nameof(StartDate))); + + if (EndDateRequired && (endDateValue == null || endDateValue.Value.DoesntHaveValue())) + return Task.FromResult(new MissingRequiredDate(nameof(EndDate))); + + if (startDateValue != null && startDateValue.Value.HasValue() && endDateValue != null && endDateValue.Value.HasValue() && startDateValue > endDateValue) + return Task.FromResult(new StartDateOccursAfterEndDate(startDateValue.Value, endDateValue.Value)); + + return Task.FromResult(null); + }; + } +} diff --git a/src/Gameboard.Api/Structure/MediatR/Validators/TeamExistsValidator.cs b/src/Gameboard.Api/Structure/MediatR/Validators/TeamExistsValidator.cs index d501f41c..36661a39 100644 --- a/src/Gameboard.Api/Structure/MediatR/Validators/TeamExistsValidator.cs +++ b/src/Gameboard.Api/Structure/MediatR/Validators/TeamExistsValidator.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Features.Teams; using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Structure.MediatR.Validators; @@ -8,18 +9,24 @@ namespace Gameboard.Api.Structure.MediatR.Validators; internal class TeamExistsValidator : IGameboardValidator { private readonly IPlayerStore _playerStore; - public required Func TeamIdProperty { get; set; } + private Func _teamIdProperty; public TeamExistsValidator(IPlayerStore playerStore) { _playerStore = playerStore; } + public TeamExistsValidator UseProperty(Func propertyExpression) + { + _teamIdProperty = propertyExpression; + return this; + } + public Func GetValidationTask() { return async (model, context) => { - var teamId = TeamIdProperty(model); + var teamId = _teamIdProperty(model); if (string.IsNullOrEmpty(teamId)) context.AddValidationException(new MissingRequiredInput(nameof(teamId), teamId)); diff --git a/src/Gameboard.Api/Structure/MediatR/Validators/TeamHasChallengeValidator.cs b/src/Gameboard.Api/Structure/MediatR/Validators/TeamHasChallengeValidator.cs index c9ee93f6..3c036385 100644 --- a/src/Gameboard.Api/Structure/MediatR/Validators/TeamHasChallengeValidator.cs +++ b/src/Gameboard.Api/Structure/MediatR/Validators/TeamHasChallengeValidator.cs @@ -1,7 +1,7 @@ using System; using System.Linq; using System.Threading.Tasks; -using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Data; using Gameboard.Api.Features.GameEngine; using Microsoft.EntityFrameworkCore; diff --git a/src/Gameboard.Api/Structure/MediatR/Validators/UserIsPlayingGameValidator.cs b/src/Gameboard.Api/Structure/MediatR/Validators/UserIsPlayingGameValidator.cs index a56a9cc7..3301fa25 100644 --- a/src/Gameboard.Api/Structure/MediatR/Validators/UserIsPlayingGameValidator.cs +++ b/src/Gameboard.Api/Structure/MediatR/Validators/UserIsPlayingGameValidator.cs @@ -7,7 +7,7 @@ namespace Gameboard.Api.Structure.MediatR.Validators; -internal class UserIsPlayingGameValidator : IGameboardValidator +internal class UserIsPlayingGameValidator : IGameboardValidator where T : class { private readonly IPlayerStore _store; diff --git a/src/Gameboard.Api/Structure/MediatR/Validators/ValidatorService.cs b/src/Gameboard.Api/Structure/MediatR/Validators/ValidatorService.cs index b0487185..aa8d1f47 100644 --- a/src/Gameboard.Api/Structure/MediatR/Validators/ValidatorService.cs +++ b/src/Gameboard.Api/Structure/MediatR/Validators/ValidatorService.cs @@ -38,7 +38,7 @@ public async Task Validate(TModel model) await task(model, context); } - if (context.ValidationExceptions.Count() > 0) + if (context.ValidationExceptions.Any()) { throw GameboardAggregatedValidationExceptions.FromValidationExceptions(context.ValidationExceptions); } diff --git a/src/Gameboard.Api/Structure/MimeTypes.cs b/src/Gameboard.Api/Structure/MimeTypes.cs new file mode 100644 index 00000000..b9c0b547 --- /dev/null +++ b/src/Gameboard.Api/Structure/MimeTypes.cs @@ -0,0 +1,8 @@ +namespace Gameboard.Api.Structure; + +public static class MimeTypes +{ + public static string ApplicationPdf { get => "application/pdf"; } + public static string ImagePng { get => "image/png"; } + public static string TextCsv { get => "text/csv"; } +} diff --git a/src/Gameboard.Api/Structure/Models/StructureModelMaps.cs b/src/Gameboard.Api/Structure/Models/StructureModelMaps.cs new file mode 100644 index 00000000..adc509ca --- /dev/null +++ b/src/Gameboard.Api/Structure/Models/StructureModelMaps.cs @@ -0,0 +1,13 @@ +using AutoMapper; +using Gameboard.Api.Common; + +namespace Gameboard.Api.Structure; + +internal class StructureModelMaps : Profile +{ + public StructureModelMaps() + { + CreateMap() + .ConvertUsing(se => se != null ? se.Name : string.Empty); + } +} diff --git a/src/Gameboard.Api/Structure/ServiceRegistrationExtensions.cs b/src/Gameboard.Api/Structure/ServiceRegistrationExtensions.cs index d9f643d0..3162e90d 100644 --- a/src/Gameboard.Api/Structure/ServiceRegistrationExtensions.cs +++ b/src/Gameboard.Api/Structure/ServiceRegistrationExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using Microsoft.Extensions.DependencyInjection; namespace Gameboard.Api.Structure; @@ -30,6 +29,35 @@ public static IServiceCollection AddImplementationsOf(this IServiceCollection se return RegisterScoped(serviceCollection, types); } + public static IServiceCollection AddInterfacesWithSingleImplementations(this IServiceCollection serviceCollection) + { + var interfaceTypes = GetRootQuery() + .Where(t => t.IsInterface) + .ToArray(); + + var singleInterfaceTypes = GetRootTypeQuery() + .Where(t => t.GetInterfaces().Length == 1) + .Where(t => t.GetConstructors().Where(c => c.IsPublic).Any()) + .GroupBy(t => t.GetInterfaces()[0]) + .ToDictionary(t => t.Key, t => t.ToList()) + .Where(entry => entry.Value.Count == 1); + + foreach (var entry in singleInterfaceTypes) + { + // if it's a type we want to register and it hasn't already been registered by other logic, add it + var theInterfaceName = entry.Key.Name; + var hasInterface = interfaceTypes.Contains(entry.Key); + var isUnRegistered = serviceCollection.FirstOrDefault(s => s.ServiceType == entry.Key) == null; + + if (interfaceTypes.Contains(entry.Key) && serviceCollection.FirstOrDefault(s => s.ServiceType == entry.Key) == null) + { + serviceCollection.AddScoped(entry.Key, entry.Value[0]); + } + } + + return serviceCollection; + } + public static IServiceCollection AddConcretesFromNamespace(this IServiceCollection serviceCollection, string namespaceExact) => AddConcretesFromNamespaceCriterion(serviceCollection, t => t.Namespace == namespaceExact); @@ -38,9 +66,7 @@ public static IServiceCollection AddConcretesFromNamespaceStartsWith(this IServi private static IServiceCollection AddConcretesFromNamespaceCriterion(this IServiceCollection serviceCollection, Func matchCriterion) { - var types = Assembly - .GetExecutingAssembly() - .GetTypes() + var types = GetRootQuery() .Where (t => t.IsClass && @@ -68,13 +94,15 @@ private static IServiceCollection RegisterScoped(IServiceCollection serviceColle return serviceCollection; } - private static Type[] GetRootTypeQuery() - => Assembly - .GetExecutingAssembly() + private static IEnumerable GetRootQuery() + => typeof(Program) + .Assembly .GetTypes() - .Where - (t => - t.IsClass & !t.IsAbstract - ) + .Where(t => t.Namespace != null && t.Namespace.StartsWith("Gameboard")) + .ToArray(); + + private static Type[] GetRootTypeQuery() + => GetRootQuery() + .Where(t => t.IsClass & !t.IsAbstract) .ToArray(); } diff --git a/src/Gameboard.Api/Structure/StringExtensions.cs b/src/Gameboard.Api/Structure/StringExtensions.cs index 32590540..ffafcb1b 100644 --- a/src/Gameboard.Api/Structure/StringExtensions.cs +++ b/src/Gameboard.Api/Structure/StringExtensions.cs @@ -11,13 +11,6 @@ namespace Gameboard.Api { public static class StringExtensions { - public static string ToHash(this string str) - { - return BitConverter.ToString( - SHA1.Create().ComputeHash(Encoding.UTF8.GetBytes(str)) - ).Replace("-", "").ToLower(); - } - public static string[] AsHashTag(this string str) { var chars = str.ToLower().ToCharArray() @@ -29,28 +22,24 @@ public static string[] AsHashTag(this string str) } public static string ToSha256(this string input) - { - using (SHA256 alg = SHA256.Create()) - { - return BitConverter.ToString(alg - .ComputeHash(Encoding.UTF8.GetBytes(input))) - .Replace("-", "") - .ToLower(); - } - } + => BitConverter.ToString(SHA256.HashData(Encoding.UTF8.GetBytes(input))) + .Replace("-", "") + .ToLower(); + - public static bool NotEmpty(this DateTimeOffset ts) + public static bool HasValue(this DateTimeOffset ts) { return ts.Year > 1; } - public static bool Empty(this DateTimeOffset ts) + + public static bool DoesntHaveValue(this DateTimeOffset ts) { - return ts.Year == 1; + return !HasValue(ts); } public static string Tag(this string s) { - if (s.HasValue()) + if (s.NotEmpty()) { int x = s.IndexOf("#"); if (x >= 0) @@ -62,7 +51,7 @@ public static string Tag(this string s) //strips hashtag+ from string public static string Untagged(this string s) { - if (s.HasValue()) + if (s.NotEmpty()) { int x = s.IndexOf("#"); if (x >= 0) @@ -71,17 +60,14 @@ public static string Untagged(this string s) return s; } - public static bool HasValue(this string str) - { - return !string.IsNullOrEmpty(str); - } public static bool IsEmpty(this string str) { - return string.IsNullOrEmpty(str); + return string.IsNullOrWhiteSpace(str); } + public static bool NotEmpty(this string str) { - return str.IsEmpty().Equals(false); + return !IsEmpty(str); } public static string Sanitize(this string target, char[] exclude) diff --git a/src/Gameboard.Api/wwwroot/temp/.gitkeep b/src/Gameboard.Api/wwwroot/temp/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/Gameboard.Api/wwwroot/templates/practice-certificate.template.html b/src/Gameboard.Api/wwwroot/templates/practice-certificate.template.html new file mode 100644 index 00000000..7d5fcddf --- /dev/null +++ b/src/Gameboard.Api/wwwroot/templates/practice-certificate.template.html @@ -0,0 +1,47 @@ + + + + + + {{title}} + + + + +
+ {{bodyContent}} +
+ + + diff --git a/test/oidc-dev.http b/test/oidc-dev.http deleted file mode 100644 index 2085fac9..00000000 --- a/test/oidc-dev.http +++ /dev/null @@ -1,40 +0,0 @@ -### -# Configure OIDC resources with IdentityServer -# -POST http://localhost:5000/api/resource/devimport -Content-Type: application/json - -{ - "Apis": [ - { - "Name": "dev-api", - "Scopes": "dev-api", - "UserClaims": "" - } - ], - "Clients": [ - { - "Id": "dev-client", - "Secret": "dev-secret", - "GrantType": "client_credentials password", - "Scopes": " dev-api dev-api-admin openid profile" - }, - { - "Id": "dev-code", - "GrantType": "authorization_code", - "Scopes": "dev-api openid profile", - "RedirectUrl": [ - "http://localhost:4200/oidc", - "http://localhost:4201/oidc", - "http://localhost:4202/oidc", - "http://localhost:4200/assets/oidc-silent.html", - "http://localhost:4201/assets/oidc-silent.html", - "http://localhost:4202/assets/oidc-silent.html", - "http://localhost:5000/api/oauth2-redirect.html" - "http://localhost:5001/api/oauth2-redirect.html" - "http://localhost:5002/api/oauth2-redirect.html" - ] - - } - ] -}