From bafe2cde5a606289e08ae4be3ab085f5d6ec32e6 Mon Sep 17 00:00:00 2001 From: Tomasi - Developing Date: Thu, 6 Feb 2025 10:44:55 +0100 Subject: [PATCH] v 0.7.0 --- Api/Api.csproj | 3 +- Api/Configurations/SwaggerExtension.cs | 7 +- Api/Controllers/v1/ApiKeysController.cs | 100 ++ Api/Controllers/v1/PlayersController.cs | 40 +- Api/Controllers/v1/ValuesController.cs | 26 + Api/Helpers/AllowApiKeyAttribute.cs | 7 + .../HideEndpointsInProductionFilter.cs | 41 + Api/Middleware/ApiKeyMiddleware.cs | 120 ++ Api/Program.cs | 2 + Application/Application.csproj | 1 + Application/ApplicationDependencyInjection.cs | 5 +- .../DataTransferObjects/ApiKey/ApiKeyDto.cs | 17 + .../ApiKey/CreateApiKeyDto.cs | 6 + .../ApiKey/UpdateApiKeyDto.cs | 8 + .../Player/PlayerMvpDto.cs | 29 +- Application/Errors/ApiKeyErrors.cs | 12 + Application/Helpers/EncryptionService.cs | 53 + Application/Interfaces/IApiKeyRepository.cs | 18 + Application/Interfaces/IEncryptionService.cs | 8 + Application/Interfaces/IPlayerRepository.cs | 2 + Application/Profiles/PlayerProfile.cs | 1 - Application/Repositories/ApiKeyRepository.cs | 135 ++ Application/Repositories/PlayerRepository.cs | 115 +- Database/ApplicationContext.cs | 2 + .../Configurations/ApiKeyConfiguration.cs | 25 + Database/Entities/Alliance.cs | 2 + Database/Entities/ApiKey.cs | 18 + .../20250204093813_AddApiKey.Designer.cs | 1230 +++++++++++++++++ .../Migrations/20250204093813_AddApiKey.cs | 57 + .../ApplicationContextModelSnapshot.cs | 54 +- README.md | 11 + Ui/src/app/app-routing.module.ts | 2 + Ui/src/app/app.module.ts | 8 +- Ui/src/app/models/apiKey.model.ts | 18 + Ui/src/app/models/player.model.ts | 14 +- .../app/navigation/navigation.component.html | 5 + .../alliance-api-key.component.css | 90 ++ .../alliance-api-key.component.html | 131 ++ .../alliance-api-key.component.ts | 161 +++ ...alliance-user-administration.component.css | 0 ...lliance-user-administration.component.html | 34 + .../alliance-user-administration.component.ts | 100 ++ .../pages/alliance/alliance.component.html | 60 +- .../app/pages/alliance/alliance.component.ts | 87 +- .../desert-storm/desert-storm.component.css | 1 + Ui/src/app/pages/mvp/mvp.component.css | 0 Ui/src/app/pages/mvp/mvp.component.html | 134 ++ Ui/src/app/pages/mvp/mvp.component.ts | 54 + Ui/src/app/services/api-key.service.ts | 30 + Ui/src/app/services/player.service.ts | 9 +- 50 files changed, 2928 insertions(+), 165 deletions(-) create mode 100644 Api/Controllers/v1/ApiKeysController.cs create mode 100644 Api/Controllers/v1/ValuesController.cs create mode 100644 Api/Helpers/AllowApiKeyAttribute.cs create mode 100644 Api/Helpers/HideEndpointsInProductionFilter.cs create mode 100644 Api/Middleware/ApiKeyMiddleware.cs create mode 100644 Application/DataTransferObjects/ApiKey/ApiKeyDto.cs create mode 100644 Application/DataTransferObjects/ApiKey/CreateApiKeyDto.cs create mode 100644 Application/DataTransferObjects/ApiKey/UpdateApiKeyDto.cs create mode 100644 Application/Errors/ApiKeyErrors.cs create mode 100644 Application/Helpers/EncryptionService.cs create mode 100644 Application/Interfaces/IApiKeyRepository.cs create mode 100644 Application/Interfaces/IEncryptionService.cs create mode 100644 Application/Repositories/ApiKeyRepository.cs create mode 100644 Database/Configurations/ApiKeyConfiguration.cs create mode 100644 Database/Entities/ApiKey.cs create mode 100644 Database/Migrations/20250204093813_AddApiKey.Designer.cs create mode 100644 Database/Migrations/20250204093813_AddApiKey.cs create mode 100644 Ui/src/app/models/apiKey.model.ts create mode 100644 Ui/src/app/pages/alliance/alliance-api-key/alliance-api-key.component.css create mode 100644 Ui/src/app/pages/alliance/alliance-api-key/alliance-api-key.component.html create mode 100644 Ui/src/app/pages/alliance/alliance-api-key/alliance-api-key.component.ts create mode 100644 Ui/src/app/pages/alliance/alliance-user-administration/alliance-user-administration.component.css create mode 100644 Ui/src/app/pages/alliance/alliance-user-administration/alliance-user-administration.component.html create mode 100644 Ui/src/app/pages/alliance/alliance-user-administration/alliance-user-administration.component.ts create mode 100644 Ui/src/app/pages/mvp/mvp.component.css create mode 100644 Ui/src/app/pages/mvp/mvp.component.html create mode 100644 Ui/src/app/pages/mvp/mvp.component.ts create mode 100644 Ui/src/app/services/api-key.service.ts diff --git a/Api/Api.csproj b/Api/Api.csproj index 6df56e5..5e9c8ac 100644 --- a/Api/Api.csproj +++ b/Api/Api.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -20,6 +20,7 @@ + diff --git a/Api/Configurations/SwaggerExtension.cs b/Api/Configurations/SwaggerExtension.cs index 6e89239..8901bcf 100644 --- a/Api/Configurations/SwaggerExtension.cs +++ b/Api/Configurations/SwaggerExtension.cs @@ -1,16 +1,17 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; +using Api.Helpers; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.OpenApi.Models; namespace Api.Configurations; public static class SwaggerExtension { - // Configures Swagger for API documentation public static void ConfigureAndAddSwagger(this IServiceCollection services) { services.AddSwaggerGen(options => { - // Defines API documentation details for version 1 + options.EnableAnnotations(); + options.DocumentFilter(); options.SwaggerDoc("v1", new OpenApiInfo { Title = "Last War Player Management API", diff --git a/Api/Controllers/v1/ApiKeysController.cs b/Api/Controllers/v1/ApiKeysController.cs new file mode 100644 index 0000000..40555b0 --- /dev/null +++ b/Api/Controllers/v1/ApiKeysController.cs @@ -0,0 +1,100 @@ +using Application.DataTransferObjects.ApiKey; +using Application.Errors; +using Application.Interfaces; +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Api.Controllers.v1 +{ + [Route("api/v{version:apiVersion}/[controller]")] + [ApiController] + [ApiVersion("1.0")] + [Authorize] + public class ApiKeysController(IApiKeyRepository apiKeyRepository, ILogger logger, IClaimTypeService claimTypeService) : ControllerBase + { + [HttpGet("{allianceId:guid}")] + public async Task> GetAllianceApiKey(Guid allianceId, + CancellationToken cancellationToken) + { + try + { + var allianceApiKeyResult = + await apiKeyRepository.GetApiKeyByAllianceIdAsync(allianceId, cancellationToken); + + return allianceApiKeyResult.IsFailure + ? BadRequest(allianceApiKeyResult.Error) + : Ok(allianceApiKeyResult.Value); + } + catch (Exception e) + { + logger.LogError(e, e.Message); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + [HttpPost] + public async Task> CreateApiKey(CreateApiKeyDto createApiKeyDto, + CancellationToken cancellationToken) + { + try + { + if (!ModelState.IsValid) return UnprocessableEntity(ModelState); + + var createResult = await apiKeyRepository.CreateApiKeyAsync(createApiKeyDto, + claimTypeService.GetFullName(User), cancellationToken); + + return createResult.IsFailure + ? BadRequest(createResult.Error) + : Ok(createResult.Value); + } + catch (Exception e) + { + logger.LogError(e, e.Message); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + [HttpPut("{apiKeyId:guid}")] + public async Task> UpdateApiKey(Guid apiKeyId, UpdateApiKeyDto updateApiKeyDto, + CancellationToken cancellationToken) + { + try + { + if (!ModelState.IsValid) return UnprocessableEntity(ModelState); + + if (updateApiKeyDto.Id != apiKeyId) return Conflict(ApiKeyErrors.IdConflict); + + var updateResult = await apiKeyRepository.UpdateApiKeyAsync(updateApiKeyDto, + claimTypeService.GetFullName(User), cancellationToken); + + return updateResult.IsFailure + ? BadRequest(updateResult.Error) + : Ok(updateResult.Value); + } + catch (Exception e) + { + logger.LogError(e, e.Message); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + [HttpDelete("{apiKeyId:guid}")] + public async Task> DeleteApiKey(Guid apiKeyId, CancellationToken cancellationToken) + { + try + { + var deleteResult = await apiKeyRepository.DeleteApiKeyAsync(apiKeyId, cancellationToken); + + return deleteResult.IsFailure + ? BadRequest(deleteResult.Error) + : Ok(deleteResult.Value); + } + catch (Exception e) + { + logger.LogError(e, e.Message); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + } +} diff --git a/Api/Controllers/v1/PlayersController.cs b/Api/Controllers/v1/PlayersController.cs index 26a9bc7..55ddd4f 100644 --- a/Api/Controllers/v1/PlayersController.cs +++ b/Api/Controllers/v1/PlayersController.cs @@ -1,4 +1,5 @@ -using Application.DataTransferObjects; +using Api.Helpers; +using Application.DataTransferObjects; using Application.DataTransferObjects.ExcelImport; using Application.DataTransferObjects.Player; using Application.Errors; @@ -6,6 +7,7 @@ using Application.Interfaces; using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; namespace Api.Controllers.v1 @@ -13,9 +15,9 @@ namespace Api.Controllers.v1 [Route("api/v{version:apiVersion}/[controller]")] [ApiController] [ApiVersion("1.0")] - [Authorize] public class PlayersController(IPlayerRepository playerRepository, IClaimTypeService claimTypeService, IExcelService excelService, ILogger logger) : ControllerBase { + [Authorize] [HttpGet("{playerId:guid}")] public async Task> GetPlayer(Guid playerId, CancellationToken cancellationToken) { @@ -34,6 +36,7 @@ namespace Api.Controllers.v1 } } + [Authorize] [HttpGet("Alliance/{allianceId:guid}")] public async Task>> GetAlliancePlayers(Guid allianceId, CancellationToken cancellationToken) { @@ -55,6 +58,7 @@ namespace Api.Controllers.v1 } } + [Authorize] [HttpGet("Alliance/dismiss/{allianceId:guid}")] public async Task>> GetAllianceDismissPlayers(Guid allianceId, CancellationToken cancellationToken, [FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 10) { @@ -76,6 +80,7 @@ namespace Api.Controllers.v1 } } + [Authorize] [HttpGet("DismissInformation/{playerId:guid}")] public async Task> GetDismissPlayerInformation(Guid playerId, CancellationToken cancellationToken) { @@ -141,6 +146,32 @@ namespace Api.Controllers.v1 } } + [AllowApiKey] + [HttpGet("Mvp/")] + [SwaggerOperation( + Summary = "Get Alliance MVPs", + Description = "Retrieves the MVPs (Most Valuable Players) for a given alliance. If the 'playerType' parameter is not provided, all MVPs will be returned. The 'playerType' parameter specifies whether the MVPs should be from 'players' or 'leadership'. Possible values for 'playerType' are 'players' or 'leadership'." + )] + public async Task>> GetAllianceMvp([FromQuery] Guid allianceId, [FromQuery]string? playerType, [FromQuery]string? key, CancellationToken cancellationToken) + { + try + { + var allianceMvpResult = await playerRepository.GetAllianceMvp(allianceId, playerType, cancellationToken); + + if (allianceMvpResult.IsFailure) return BadRequest(allianceMvpResult.Error); + + return allianceMvpResult.Value.Count > 0 + ? Ok(allianceMvpResult.Value) + : NoContent(); + } + catch (Exception e) + { + logger.LogError(e, e.Message); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + [Authorize] [HttpPost] public async Task> CreatePlayer(CreatePlayerDto createPlayerDto, CancellationToken cancellationToken) { @@ -161,6 +192,7 @@ namespace Api.Controllers.v1 } } + [Authorize] [HttpPost("ExcelImport")] public async Task> ImportPlayersFromExcel( [FromForm] ExcelImportRequest excelImportRequest, CancellationToken cancellationToken) @@ -194,6 +226,7 @@ namespace Api.Controllers.v1 } } + [Authorize] [HttpPut("{playerId:guid}")] public async Task> UpdatePlayer(Guid playerId, UpdatePlayerDto updatePlayerDto, CancellationToken cancellationToken) { @@ -216,6 +249,7 @@ namespace Api.Controllers.v1 } } + [Authorize] [HttpPut("{playerId:guid}/dismiss")] public async Task> DismissPlayer(Guid playerId, DismissPlayerDto dismissPlayerDto, CancellationToken cancellationToken) @@ -239,6 +273,7 @@ namespace Api.Controllers.v1 } } + [Authorize] [HttpPut("{playerId:guid}/reactive")] public async Task> ReactivePlayer(Guid playerId, ReactivatePlayerDto reactivatePlayerDto, CancellationToken cancellationToken) { @@ -261,6 +296,7 @@ namespace Api.Controllers.v1 } } + [Authorize] [HttpDelete("{playerId:guid}")] public async Task> DeletePlayer(Guid playerId, CancellationToken cancellationToken) { diff --git a/Api/Controllers/v1/ValuesController.cs b/Api/Controllers/v1/ValuesController.cs new file mode 100644 index 0000000..6166422 --- /dev/null +++ b/Api/Controllers/v1/ValuesController.cs @@ -0,0 +1,26 @@ +using Api.Helpers; +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.WebEncoders.Testing; + +namespace Api.Controllers.v1 +{ + [Route("api/v{version:apiVersion}/[controller]")] + [ApiController] + [ApiVersion("1.0")] + public class ValuesController : ControllerBase + { + [AllowApiKey] + [HttpGet] + public async Task Test([FromQuery] Guid allianceId, [FromQuery] string? key) + { + return Ok(new + { + AllianceId = allianceId, + Key = key + }); + } + } +} diff --git a/Api/Helpers/AllowApiKeyAttribute.cs b/Api/Helpers/AllowApiKeyAttribute.cs new file mode 100644 index 0000000..6d70da3 --- /dev/null +++ b/Api/Helpers/AllowApiKeyAttribute.cs @@ -0,0 +1,7 @@ +namespace Api.Helpers; + +[AttributeUsage(AttributeTargets.Method)] +public class AllowApiKeyAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/Api/Helpers/HideEndpointsInProductionFilter.cs b/Api/Helpers/HideEndpointsInProductionFilter.cs new file mode 100644 index 0000000..1328729 --- /dev/null +++ b/Api/Helpers/HideEndpointsInProductionFilter.cs @@ -0,0 +1,41 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Api.Helpers; + +public class HideEndpointsInProductionFilter(IWebHostEnvironment environment) : IDocumentFilter +{ + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + if (environment.IsProduction()) + { + var allowedPaths = new List + { + "/api/v{version}/Players/Mvp" + }; + + var allowedSchemas = new List + { + "PlayerMvpDto" + }; + + var pathsToRemove = swaggerDoc.Paths + .Where(path => !allowedPaths.Any(allowedPath => path.Key.Contains(allowedPath))) + .ToList(); + + foreach (var path in pathsToRemove) + { + swaggerDoc.Paths.Remove(path.Key); + } + + var schemaToRemove = context.SchemaRepository.Schemas + .Where(schema => !allowedSchemas.Contains(schema.Key)) + .ToList(); + + foreach (var schema in schemaToRemove) + { + context.SchemaRepository.Schemas.Remove(schema.Key); + } + } + } +} \ No newline at end of file diff --git a/Api/Middleware/ApiKeyMiddleware.cs b/Api/Middleware/ApiKeyMiddleware.cs new file mode 100644 index 0000000..5c288ba --- /dev/null +++ b/Api/Middleware/ApiKeyMiddleware.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using Api.Helpers; +using Application.Interfaces; +using Database.Entities; +using Microsoft.AspNetCore.Mvc; + +namespace Api.Middleware; + +public class ApiKeyMiddleware(RequestDelegate next, IServiceScopeFactory scopeFactory, IEncryptionService encryptionService) +{ + private const string ApiKeyHeaderName = "X-Api-Key"; + private const string AllianzIdQueryParamName = "AllianceId"; + private const string ApiKeyQueryParamName = "Key"; + public async Task InvokeAsync(HttpContext context) + { + + if (context.User.Identity?.IsAuthenticated == true) + { + await next(context); + return; + } + + var endpoint = context.GetEndpoint(); + var allowApiKeyAttribute = endpoint?.Metadata.GetMetadata(); + + if (allowApiKeyAttribute is null) + { + await next(context); + return; + } + + if (!context.Request.Headers.ContainsKey(ApiKeyHeaderName) && !context.Request.Query.ContainsKey(ApiKeyQueryParamName)) + { + await WriteProblemDetailsResponse(context, StatusCodes.Status401Unauthorized, "Unauthorized", "API Key is required."); + return; + } + + if (!context.Request.Query.ContainsKey(AllianzIdQueryParamName)) + { + await WriteProblemDetailsResponse(context, StatusCodes.Status400BadRequest, "Bad Request", "AllianceId is required."); + return; + } + + if (!Guid.TryParse(context.Request.Query[AllianzIdQueryParamName], out var allianceId)) + { + await WriteProblemDetailsResponse(context, StatusCodes.Status400BadRequest, "Bad Request", "The provided AllianceId is not a valid GUID."); + return; + } + + + + var apiKey = context.Request.Headers.ContainsKey(ApiKeyHeaderName) + ? context.Request.Headers[ApiKeyHeaderName] + : context.Request.Query[ApiKeyQueryParamName]; + + using var scope = scopeFactory.CreateScope(); + var apiKeyRepository = scope.ServiceProvider.GetRequiredService(); + var apiKeyResult = await apiKeyRepository.GetAllianceApiKeyAsync(allianceId); + + if (apiKeyResult.IsFailure) + { + await WriteProblemDetailsResponse(context, StatusCodes.Status401Unauthorized, "Unauthorized", "The provided API Key is not valid."); + return; + } + + if (!await IsValidApiKeyAsync(apiKeyResult.Value, apiKey!)) + { + await WriteProblemDetailsResponse(context, StatusCodes.Status401Unauthorized, "Unauthorized", "The API Key does not match."); + return; + } + + await next(context); + } + + private async Task IsValidApiKeyAsync(ApiKey apiKey, string key) + { + var decryptedKey = await encryptionService.Decrypt(apiKey.EncryptedKey).ConfigureAwait(false); + + return key == decryptedKey; + } + + private static async Task WriteProblemDetailsResponse(HttpContext context, int statusCode, string title, + string detail) + { + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/problem+json"; + + var type = GetErrorTypeForStatusCode(statusCode); + + var problemDetail = new ProblemDetails + { + Type = type, + Status = statusCode, + Title = title, + Detail = detail, + Instance = context.Request.Path, + Extensions = + { + ["traceId"] = context.TraceIdentifier, + ["timestamp"] = DateTime.UtcNow.ToString("o"), + ["method"] = context.Request.Method + } + }; + + await context.Response.WriteAsJsonAsync(problemDetail, + new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + } + + private static string GetErrorTypeForStatusCode(int statusCode) + { + return statusCode switch + { + 400 => "https://tools.ietf.org/html/rfc9110#section-15.5.1", + 401 => "https://tools.ietf.org/html/rfc9110#section-15.2.2", + 404 => "https://tools.ietf.org/html/rfc9110#section-15.5.5", + 500 => "https://tools.ietf.org/html/rfc9110#section-15.6.1", + _ => "https://tools.ietf.org/html/rfc9110#section-15.6.5" + }; + } +} \ No newline at end of file diff --git a/Api/Program.cs b/Api/Program.cs index a8ad2bf..6b1cf52 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -1,6 +1,7 @@ // Configure Serilog logger using Api.Configurations; +using Api.Middleware; using Application; using Database; using HealthChecks.UI.Client; @@ -60,6 +61,7 @@ try app.UseHttpsRedirection(); app.UseAuthentication(); + app.UseMiddleware(); app.UseAuthorization(); app.MapControllers(); diff --git a/Application/Application.csproj b/Application/Application.csproj index bed1d1e..1292af0 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -20,6 +20,7 @@ + diff --git a/Application/ApplicationDependencyInjection.cs b/Application/ApplicationDependencyInjection.cs index 5730eee..4258057 100644 --- a/Application/ApplicationDependencyInjection.cs +++ b/Application/ApplicationDependencyInjection.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Application.Helpers; using Application.Interfaces; using Application.Repositories; using Application.Services; @@ -32,12 +33,14 @@ public static class ApplicationDependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); return services; } diff --git a/Application/DataTransferObjects/ApiKey/ApiKeyDto.cs b/Application/DataTransferObjects/ApiKey/ApiKeyDto.cs new file mode 100644 index 0000000..fb81313 --- /dev/null +++ b/Application/DataTransferObjects/ApiKey/ApiKeyDto.cs @@ -0,0 +1,17 @@ +namespace Application.DataTransferObjects.ApiKey; + +public class ApiKeyDto +{ + public Guid Id { get; set; } + public Guid AllianceId { get; set; } + + public required string Key { get; set; } + + public DateTime CreatedOn { get; set; } + + public required string CreatedBy { get; set; } + + public DateTime? ModifiedOn { get; set; } + + public string? ModifiedBy { get; set; } +} \ No newline at end of file diff --git a/Application/DataTransferObjects/ApiKey/CreateApiKeyDto.cs b/Application/DataTransferObjects/ApiKey/CreateApiKeyDto.cs new file mode 100644 index 0000000..096bbbe --- /dev/null +++ b/Application/DataTransferObjects/ApiKey/CreateApiKeyDto.cs @@ -0,0 +1,6 @@ +namespace Application.DataTransferObjects.ApiKey; + +public class CreateApiKeyDto +{ + public Guid AllianceId { get; set; } +} \ No newline at end of file diff --git a/Application/DataTransferObjects/ApiKey/UpdateApiKeyDto.cs b/Application/DataTransferObjects/ApiKey/UpdateApiKeyDto.cs new file mode 100644 index 0000000..b44df2c --- /dev/null +++ b/Application/DataTransferObjects/ApiKey/UpdateApiKeyDto.cs @@ -0,0 +1,8 @@ +namespace Application.DataTransferObjects.ApiKey; + +public class UpdateApiKeyDto +{ + public Guid Id { get; set; } + + public Guid AllianceId { get; set; } +} \ No newline at end of file diff --git a/Application/DataTransferObjects/Player/PlayerMvpDto.cs b/Application/DataTransferObjects/Player/PlayerMvpDto.cs index 6d8ff8a..a8a4700 100644 --- a/Application/DataTransferObjects/Player/PlayerMvpDto.cs +++ b/Application/DataTransferObjects/Player/PlayerMvpDto.cs @@ -1,12 +1,27 @@ -namespace Application.DataTransferObjects.Player; +using Swashbuckle.AspNetCore.Annotations; + +namespace Application.DataTransferObjects.Player; public class PlayerMvpDto { - public required string PlayerName { get; set; } - public required string Rank { get; set; } - public long TotalVsDuelPoints { get; set; } - public int MarshalGuardParticipationCount { get; set; } + [SwaggerSchema(Description = "Name of the player.")] + public required string Name { get; set; } + + [SwaggerSchema(Description = "The alliance rank of the player, which can be R5, R4, R3, R2, or R1.")] + public required string AllianceRank { get; set; } + + [SwaggerSchema(Description = "Total points the player earned in the last three weeks based on duel performance.")] + public long DuelPointsLast3Weeks { get; set; } + + [SwaggerSchema(Description = "The number of times the player participated in the Marshal competition in the last three weeks.")] + public int MarshalParticipationCount { get; set; } + + [SwaggerSchema(Description = "The number of times the player participated in the Desert Storm competition in the last three weeks.")] public int DesertStormParticipationCount { get; set; } - public bool IsOldestVsDuelParticipated { get; set; } - public decimal MvpPoints { get; set; } + + [SwaggerSchema(Description = "Indicates whether the player has participated in the oldest VS Duel for at least the last three weeks.")] + public bool HasParticipatedInOldestDuel { get; set; } + + [SwaggerSchema(Description = "The total points earned by the player according to the MVP formula.")] + public decimal MvpScore { get; set; } } \ No newline at end of file diff --git a/Application/Errors/ApiKeyErrors.cs b/Application/Errors/ApiKeyErrors.cs new file mode 100644 index 0000000..a3f60bd --- /dev/null +++ b/Application/Errors/ApiKeyErrors.cs @@ -0,0 +1,12 @@ +namespace Application.Errors; + +public static class ApiKeyErrors +{ + public static readonly Error NotFound = new("Error.ApiKey.NotFound", + "The api key with the specified identifier was not found"); + + public static readonly Error NoKeyForAlliance = new("Error.ApiKey.NoKeyForAlliance", + "The alliance has no api key"); + + public static readonly Error IdConflict = new("Error.ApiKey.IdConflict", "There is a conflict with the id's"); +} \ No newline at end of file diff --git a/Application/Helpers/EncryptionService.cs b/Application/Helpers/EncryptionService.cs new file mode 100644 index 0000000..ca6f664 --- /dev/null +++ b/Application/Helpers/EncryptionService.cs @@ -0,0 +1,53 @@ +using System.Security.Cryptography; +using System.Text; +using Application.Interfaces; +using Microsoft.Extensions.Configuration; +using Aes = System.Security.Cryptography.Aes; + +namespace Application.Helpers; + +public class EncryptionService : IEncryptionService +{ + private readonly byte[] _key; + private readonly byte[] _iv; + public EncryptionService(IConfiguration configuration) + { + var encryptionSection = configuration.GetRequiredSection("Encryption"); + _key = Encoding.UTF8.GetBytes(encryptionSection["Key"] ?? throw new InvalidOperationException()); + _iv = Encoding.UTF8.GetBytes(encryptionSection["Iv"] ?? throw new InvalidOperationException()); + } + public async Task EncryptAsync(string plainText) + { + if (string.IsNullOrEmpty(plainText)) throw new ArgumentNullException(nameof(plainText)); + + using var aes = Aes.Create(); + aes.Key = _key; + aes.IV = _iv; + + using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV); + using var memoryStream = new MemoryStream(); + await using var cryptoStream = new CryptoStream(memoryStream, encryptor, CryptoStreamMode.Write); + + var inputBytes = Encoding.UTF8.GetBytes(plainText); + await cryptoStream.WriteAsync(inputBytes, 0, inputBytes.Length); + await cryptoStream.FlushFinalBlockAsync(); + + return Convert.ToBase64String(memoryStream.ToArray()); + } + + public async Task Decrypt(string encryptedText) + { + if (string.IsNullOrEmpty(encryptedText)) throw new ArgumentNullException(nameof(encryptedText)); + + using var aes = Aes.Create(); + aes.Key = _key; + aes.IV = _iv; + + using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV); + using var memoryStream = new MemoryStream(Convert.FromBase64String(encryptedText)); + await using var cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read); + + using var reader = new StreamReader(cryptoStream, Encoding.UTF8); + return await reader.ReadToEndAsync(); + } +} \ No newline at end of file diff --git a/Application/Interfaces/IApiKeyRepository.cs b/Application/Interfaces/IApiKeyRepository.cs new file mode 100644 index 0000000..2c24546 --- /dev/null +++ b/Application/Interfaces/IApiKeyRepository.cs @@ -0,0 +1,18 @@ +using Application.Classes; +using Application.DataTransferObjects.ApiKey; +using Database.Entities; + +namespace Application.Interfaces; + +public interface IApiKeyRepository +{ + Task> GetAllianceApiKeyAsync(Guid allianceId); + + Task> GetApiKeyByAllianceIdAsync(Guid allianceId, CancellationToken cancellationToken); + + Task> CreateApiKeyAsync(CreateApiKeyDto createApiKeyDto, string creator, CancellationToken cancellationToken); + + Task> UpdateApiKeyAsync(UpdateApiKeyDto updateApiKeyDto, string modifier, CancellationToken cancellationToken); + + Task> DeleteApiKeyAsync(Guid apiKeyId, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/Application/Interfaces/IEncryptionService.cs b/Application/Interfaces/IEncryptionService.cs new file mode 100644 index 0000000..1dac789 --- /dev/null +++ b/Application/Interfaces/IEncryptionService.cs @@ -0,0 +1,8 @@ +namespace Application.Interfaces; + +public interface IEncryptionService +{ + Task EncryptAsync(string plainText); + + Task Decrypt(string encryptedText); +} \ No newline at end of file diff --git a/Application/Interfaces/IPlayerRepository.cs b/Application/Interfaces/IPlayerRepository.cs index c38bb77..36ed4c3 100644 --- a/Application/Interfaces/IPlayerRepository.cs +++ b/Application/Interfaces/IPlayerRepository.cs @@ -16,6 +16,8 @@ public interface IPlayerRepository Task>> GetAllianceLeadershipMvp(Guid allianceId, CancellationToken cancellationToken); + Task>> GetAllianceMvp(Guid allianceId, string? playerType, CancellationToken cancellationToken); + Task> GetDismissPlayerInformationAsync(Guid playerId, CancellationToken cancellationToken); Task> CreatePlayerAsync(CreatePlayerDto createPlayerDto, string createdBy, CancellationToken cancellationToken); diff --git a/Application/Profiles/PlayerProfile.cs b/Application/Profiles/PlayerProfile.cs index 34b5420..63fb540 100644 --- a/Application/Profiles/PlayerProfile.cs +++ b/Application/Profiles/PlayerProfile.cs @@ -15,7 +15,6 @@ public class PlayerProfile : Profile CreateMap() .ForMember(des => des.Id, opt => opt.MapFrom(src => Guid.CreateVersion7())) - .ForMember(des => des.IsDismissed, opt => opt.MapFrom(src => false)) .ForMember(des => des.CreatedOn, opt => opt.MapFrom(src => DateTime.Now)); CreateMap() diff --git a/Application/Repositories/ApiKeyRepository.cs b/Application/Repositories/ApiKeyRepository.cs new file mode 100644 index 0000000..5a350c2 --- /dev/null +++ b/Application/Repositories/ApiKeyRepository.cs @@ -0,0 +1,135 @@ +using Application.Classes; +using Application.DataTransferObjects.ApiKey; +using Application.Errors; +using Application.Interfaces; +using Database; +using Database.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Application.Repositories; + +public class ApiKeyRepository(ApplicationContext context, ILogger logger, IEncryptionService encryptionService) : IApiKeyRepository +{ + public async Task> GetAllianceApiKeyAsync(Guid allianceId) + { + var allianceApiKey = await context.ApiKeys + .AsNoTracking() + .FirstOrDefaultAsync(apiKey => apiKey.AllianceId == allianceId); + + return allianceApiKey is null + ? Result.Failure(ApiKeyErrors.NoKeyForAlliance) + : Result.Success(allianceApiKey); + } + + public async Task> GetApiKeyByAllianceIdAsync(Guid allianceId, CancellationToken cancellationToken) + { + var allianceApiKey = await context.ApiKeys + .AsNoTracking() + .FirstOrDefaultAsync(apiKey => apiKey.AllianceId == allianceId, cancellationToken); + + if (allianceApiKey is null) return Result.Failure(ApiKeyErrors.NoKeyForAlliance); + + return Result.Success(new ApiKeyDto() + { + Id = allianceApiKey.Id, + CreatedBy = allianceApiKey.CreatedBy, + CreatedOn = allianceApiKey.CreatedOn, + AllianceId = allianceApiKey.AllianceId, + ModifiedBy = allianceApiKey.ModifiedBy, + ModifiedOn = allianceApiKey.ModifiedOn, + Key = await encryptionService.Decrypt(allianceApiKey.EncryptedKey) + }); + } + + public async Task> CreateApiKeyAsync(CreateApiKeyDto createApiKeyDto, string creator, CancellationToken cancellationToken) + { + try + { + var apiKey = Guid.NewGuid().ToString("N"); + var newApiKey = new ApiKey() + { + Id = Guid.CreateVersion7(), + CreatedBy = creator, + EncryptedKey = await encryptionService.EncryptAsync(apiKey), + AllianceId = createApiKeyDto.AllianceId, + CreatedOn = DateTime.Now + }; + + await context.ApiKeys.AddAsync(newApiKey, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + return Result.Success(new ApiKeyDto() + { + Id = newApiKey.Id, + CreatedBy = newApiKey.CreatedBy, + CreatedOn = newApiKey.CreatedOn, + AllianceId = newApiKey.AllianceId, + ModifiedBy = newApiKey.ModifiedBy, + ModifiedOn = newApiKey.ModifiedOn, + Key = apiKey + }); + } + catch (Exception e) + { + logger.LogError(e, e.Message); + return Result.Failure(GeneralErrors.DatabaseError); + } + } + + public async Task> UpdateApiKeyAsync(UpdateApiKeyDto updateApiKeyDto, string modifier, CancellationToken cancellationToken) + { + try + { + var apiKeyToUpdate = await context.ApiKeys + .FirstOrDefaultAsync(apiKey => apiKey.Id == updateApiKeyDto.Id, cancellationToken); + + if (apiKeyToUpdate == null) return Result.Failure(ApiKeyErrors.NotFound); + + var newApiKey = Guid.NewGuid().ToString("N"); + + apiKeyToUpdate.EncryptedKey = await encryptionService.EncryptAsync(newApiKey); + apiKeyToUpdate.ModifiedBy = modifier; + apiKeyToUpdate.ModifiedOn = DateTime.Now; + + await context.SaveChangesAsync(cancellationToken); + + return Result.Success(new ApiKeyDto() + { + Id = apiKeyToUpdate.Id, + CreatedBy = apiKeyToUpdate.CreatedBy, + CreatedOn = apiKeyToUpdate.CreatedOn, + AllianceId = apiKeyToUpdate.AllianceId, + ModifiedBy = apiKeyToUpdate.ModifiedBy, + ModifiedOn = apiKeyToUpdate.ModifiedOn, + Key = newApiKey + }); + + } + catch (Exception e) + { + logger.LogError(e, e.Message); + return Result.Failure(GeneralErrors.DatabaseError); + } + } + + public async Task> DeleteApiKeyAsync(Guid apiKeyId, CancellationToken cancellationToken) + { + try + { + var apiKeyToDelete = await context.ApiKeys + .FirstOrDefaultAsync(apiKey => apiKey.Id == apiKeyId, cancellationToken); + + if (apiKeyToDelete == null) return Result.Failure(ApiKeyErrors.NotFound); + + context.ApiKeys.Remove(apiKeyToDelete); + await context.SaveChangesAsync(cancellationToken); + return Result.Success(true); + } + catch (Exception e) + { + logger.LogError(e, e.Message); + return Result.Failure(GeneralErrors.DatabaseError); + } + } +} \ No newline at end of file diff --git a/Application/Repositories/PlayerRepository.cs b/Application/Repositories/PlayerRepository.cs index 9bdbccb..b1dce3a 100644 --- a/Application/Repositories/PlayerRepository.cs +++ b/Application/Repositories/PlayerRepository.cs @@ -63,6 +63,67 @@ public class PlayerRepository(ApplicationContext context, IMapper mapper, ILogge }); } + public async Task>> GetAllianceMvp(Guid allianceId, string? playerType, CancellationToken cancellationToken) + { + var currentDate = DateTime.Now; + var threeWeeksAgo = currentDate.AddDays(-21); + + var query = context.Players.Where(p => p.AllianceId == allianceId); + + query = playerType switch + { + "players" => query.Where(p => p.Rank.Name != "R4" && p.Rank.Name != "R5"), + "leadership" => query.Where(p => p.Rank.Name == "R4" || p.Rank.Name == "R5"), + _ => query + }; + + var playerMvps = await query + .Select(p => new + { + p.Id, + p.PlayerName, + Rank = p.Rank.Name, + + VsDuels = context.VsDuelParticipants + .Where(vp => vp.PlayerId == p.Id && vp.VsDuel.EventDate <= currentDate && !vp.VsDuel.IsInProgress) + .OrderByDescending(vp => vp.VsDuel.EventDate) + .Take(3) + .Sum(vp => vp.WeeklyPoints), + + IsOldestVsDuelParticipated = context.VsDuelParticipants + .Where(vp => vp.PlayerId == p.Id && vp.VsDuel.EventDate <= currentDate && !vp.VsDuel.IsInProgress) + .OrderByDescending(vp => vp.VsDuel.EventDate) + .Skip(2) + .Take(1) + .Any(), + + MarshalGuardParticipationCount = context.MarshalGuardParticipants + .Count(mpg => mpg.PlayerId == p.Id && mpg.Participated && mpg.MarshalGuard.EventDate > threeWeeksAgo), + + DessertStormParticipationCount = context.DesertStormParticipants + .Count(dsp => dsp.PlayerId == p.Id && dsp.Participated && dsp.DesertStorm.EventDate > threeWeeksAgo) + }) + .Select(p => new PlayerMvpDto() + { + Name = p.PlayerName, + AllianceRank = p.Rank, + DuelPointsLast3Weeks = p.VsDuels, + MarshalParticipationCount = p.MarshalGuardParticipationCount, + DesertStormParticipationCount = p.DessertStormParticipationCount, + HasParticipatedInOldestDuel = p.IsOldestVsDuelParticipated, + MvpScore = Math.Round( + (decimal)((p.VsDuels / 1000000.0 * 0.8) + + ((p.MarshalGuardParticipationCount * 20 + p.DessertStormParticipationCount * 40) * 0.2)), 2) + }) + .OrderByDescending(p => p.MvpScore) + .ThenByDescending(p => p.DuelPointsLast3Weeks) + .ThenByDescending(p => p.MarshalParticipationCount) + .ThenBy(p => p.Name) + .ToListAsync(cancellationToken); + + return playerMvps; + } + public async Task>> GetAlliancePlayersMvp(Guid allianceId, CancellationToken cancellationToken) { var currentDate = DateTime.Now; @@ -97,20 +158,20 @@ public class PlayerRepository(ApplicationContext context, IMapper mapper, ILogge }) .Select(p => new PlayerMvpDto() { - PlayerName = p.PlayerName, - Rank = p.Rank, - TotalVsDuelPoints = p.VsDuels, - MarshalGuardParticipationCount = p.MarshalGuardParticipationCount, + Name = p.PlayerName, + AllianceRank = p.Rank, + DuelPointsLast3Weeks = p.VsDuels, + MarshalParticipationCount = p.MarshalGuardParticipationCount, DesertStormParticipationCount = p.DessertStormParticipationCount, - IsOldestVsDuelParticipated = p.IsOldestVsDuelParticipated, - MvpPoints = Math.Round( + HasParticipatedInOldestDuel = p.IsOldestVsDuelParticipated, + MvpScore = Math.Round( (decimal)((p.VsDuels / 1000000.0 * 0.8) + ((p.MarshalGuardParticipationCount * 20 + p.DessertStormParticipationCount * 40) * 0.2)),2) }) - .OrderByDescending(p => p.MvpPoints) - .ThenByDescending(p => p.TotalVsDuelPoints) - .ThenByDescending(p => p.MarshalGuardParticipationCount) - .ThenBy(p => p.PlayerName) + .OrderByDescending(p => p.MvpScore) + .ThenByDescending(p => p.DuelPointsLast3Weeks) + .ThenByDescending(p => p.MarshalParticipationCount) + .ThenBy(p => p.Name) .ToListAsync(cancellationToken); return playerMvps; @@ -143,19 +204,19 @@ public class PlayerRepository(ApplicationContext context, IMapper mapper, ILogge }) .Select(p => new PlayerMvpDto() { - PlayerName = p.PlayerName, - Rank = p.Rank, - TotalVsDuelPoints = p.VsDuels, - MarshalGuardParticipationCount = p.MarshalGuardParticipationCount, + Name = p.PlayerName, + AllianceRank = p.Rank, + DuelPointsLast3Weeks = p.VsDuels, + MarshalParticipationCount = p.MarshalGuardParticipationCount, DesertStormParticipationCount = p.DessertStormParticipationCount, - MvpPoints = Math.Round( + MvpScore = Math.Round( (decimal)((p.VsDuels / 1000000.0 * 0.8) + ((p.MarshalGuardParticipationCount * 20 + p.DessertStormParticipationCount * 40) * 0.2)), 2) }) - .OrderByDescending(p => p.MvpPoints) - .ThenByDescending(p => p.TotalVsDuelPoints) - .ThenByDescending(p => p.MarshalGuardParticipationCount) - .ThenBy(p => p.PlayerName) + .OrderByDescending(p => p.MvpScore) + .ThenByDescending(p => p.DuelPointsLast3Weeks) + .ThenByDescending(p => p.MarshalParticipationCount) + .ThenBy(p => p.Name) .ToListAsync(cancellationToken); return playerMvps; @@ -176,8 +237,20 @@ public class PlayerRepository(ApplicationContext context, IMapper mapper, ILogge public async Task> CreatePlayerAsync(CreatePlayerDto createPlayerDto, string createdBy, CancellationToken cancellationToken) { - var newPlayer = mapper.Map(createPlayerDto); - newPlayer.CreatedBy = createdBy; + var newPlayer = new Player() + { + CreatedBy = createdBy, + PlayerName = createPlayerDto.PlayerName, + AllianceId = createPlayerDto.AllianceId, + RankId = createPlayerDto.RankId, + Level = createPlayerDto.Level, + CreatedOn = DateTime.Now, + ModifiedOn = null, + ModifiedBy = null, + DismissalReason = null, + DismissedAt = null, + IsDismissed = false + }; await context.Players.AddAsync(newPlayer, cancellationToken); diff --git a/Database/ApplicationContext.cs b/Database/ApplicationContext.cs index 8efa7e7..cccabed 100644 --- a/Database/ApplicationContext.cs +++ b/Database/ApplicationContext.cs @@ -42,6 +42,8 @@ public class ApplicationContext(DbContextOptions options) : public DbSet VsDuelLeagues { get; set; } + public DbSet ApiKeys { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); diff --git a/Database/Configurations/ApiKeyConfiguration.cs b/Database/Configurations/ApiKeyConfiguration.cs new file mode 100644 index 0000000..802adc2 --- /dev/null +++ b/Database/Configurations/ApiKeyConfiguration.cs @@ -0,0 +1,25 @@ +using Database.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Database.Configurations; + +public class ApiKeyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(apiKey => apiKey.Id); + builder.Property(apiKey => apiKey.Id).ValueGeneratedNever(); + + builder.Property(apiKey => apiKey.EncryptedKey).IsRequired().HasMaxLength(64); + builder.Property(apiKey => apiKey.CreatedOn).IsRequired(); + builder.Property(apiKey => apiKey.CreatedBy).IsRequired().HasMaxLength(150); + builder.Property(apiKey => apiKey.ModifiedOn).IsRequired(false); + builder.Property(apiKey => apiKey.ModifiedBy).IsRequired(false); + + builder.HasOne(apiKey => apiKey.Alliance) + .WithOne(alliance => alliance.ApiKey) + .HasForeignKey(apiKey => apiKey.AllianceId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/Database/Entities/Alliance.cs b/Database/Entities/Alliance.cs index c45e3f3..b0702b6 100644 --- a/Database/Entities/Alliance.cs +++ b/Database/Entities/Alliance.cs @@ -14,6 +14,8 @@ public class Alliance : BaseEntity public string? ModifiedBy { get; set; } + public ApiKey? ApiKey { get; set; } + public ICollection Players { get; set; } = []; public ICollection Users { get; set; } = []; diff --git a/Database/Entities/ApiKey.cs b/Database/Entities/ApiKey.cs new file mode 100644 index 0000000..84e5752 --- /dev/null +++ b/Database/Entities/ApiKey.cs @@ -0,0 +1,18 @@ +namespace Database.Entities; + +public class ApiKey : BaseEntity +{ + public Alliance Alliance { get; set; } = null!; + + public Guid AllianceId { get; set; } + + public required string EncryptedKey { get; set; } + + public DateTime CreatedOn { get; set; } + + public required string CreatedBy { get; set; } + + public DateTime? ModifiedOn { get; set; } + + public string? ModifiedBy { get; set; } +} \ No newline at end of file diff --git a/Database/Migrations/20250204093813_AddApiKey.Designer.cs b/Database/Migrations/20250204093813_AddApiKey.Designer.cs new file mode 100644 index 0000000..78dccad --- /dev/null +++ b/Database/Migrations/20250204093813_AddApiKey.Designer.cs @@ -0,0 +1,1230 @@ +// +using System; +using Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Database.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20250204093813_AddApiKey")] + partial class AddApiKey + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("dbo") + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Database.Entities.Admonition", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("PlayerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.ToTable("Admonitions", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.Alliance", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("nvarchar(5)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Server") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Alliances", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("AllianceId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("EncryptedKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AllianceId") + .IsUnique(); + + b.ToTable("ApiKeys", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.CustomEvent", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("AllianceId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EventDate") + .HasColumnType("datetime2"); + + b.Property("IsInProgress") + .HasColumnType("bit"); + + b.Property("IsParticipationEvent") + .HasColumnType("bit"); + + b.Property("IsPointsEvent") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.HasKey("Id"); + + b.HasIndex("AllianceId"); + + b.ToTable("CustomEvents", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.CustomEventParticipant", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("AchievedPoints") + .HasColumnType("bigint"); + + b.Property("CustomEventId") + .HasColumnType("uniqueidentifier"); + + b.Property("Participated") + .HasColumnType("bit"); + + b.Property("PlayerId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("CustomEventId"); + + b.HasIndex("PlayerId"); + + b.ToTable("CustomEventParticipants", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.DesertStorm", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("AllianceId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EventDate") + .HasColumnType("datetime2"); + + b.Property("IsInProgress") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("OpponentName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("OpponentServer") + .HasColumnType("int"); + + b.Property("OpposingParticipants") + .HasColumnType("int"); + + b.Property("Won") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AllianceId"); + + b.ToTable("DesertStorms", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.DesertStormParticipant", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("DesertStormId") + .HasColumnType("uniqueidentifier"); + + b.Property("Participated") + .HasColumnType("bit"); + + b.Property("PlayerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Registered") + .HasColumnType("bit"); + + b.Property("StartPlayer") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("DesertStormId"); + + b.HasIndex("PlayerId"); + + b.ToTable("DesertStormParticipants", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.MarshalGuard", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("AllianceId") + .HasColumnType("uniqueidentifier"); + + b.Property("AllianceSize") + .HasColumnType("int"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EventDate") + .HasColumnType("datetime2"); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("RewardPhase") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AllianceId"); + + b.ToTable("MarshalGuards", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.MarshalGuardParticipant", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("MarshalGuardId") + .HasColumnType("uniqueidentifier"); + + b.Property("Participated") + .HasColumnType("bit"); + + b.Property("PlayerId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MarshalGuardId"); + + b.HasIndex("PlayerId"); + + b.ToTable("MarshalGuardParticipants", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.Note", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("PlayerId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlayerNote") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.ToTable("Notes", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.Player", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("AllianceId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DismissalReason") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("DismissedAt") + .HasColumnType("datetime2"); + + b.Property("IsDismissed") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("PlayerName") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("RankId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AllianceId"); + + b.HasIndex("RankId"); + + b.ToTable("Players", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.Rank", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)"); + + b.HasKey("Id"); + + b.ToTable("Ranks", "dbo"); + + b.HasData( + new + { + Id = new Guid("b1c10a1c-5cf3-4e22-9fc1-d9b165b85dd3"), + Name = "R5" + }, + new + { + Id = new Guid("0fc2f68a-0a4d-4922-981e-c624e4c39024"), + Name = "R4" + }, + new + { + Id = new Guid("4970e1f5-f7f5-43e8-88cc-7f8fc4075418"), + Name = "R3" + }, + new + { + Id = new Guid("d8d0c587-f269-45ff-b13e-4631298bf0af"), + Name = "R2" + }, + new + { + Id = new Guid("326edef0-5074-43a5-9db9-edc71221a0f7"), + Name = "R1" + }); + }); + + modelBuilder.Entity("Database.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("AllianceId") + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("PlayerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("AllianceId"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.VsDuel", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("AllianceId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EventDate") + .HasColumnType("datetime2"); + + b.Property("IsInProgress") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("OpponentName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("OpponentPower") + .HasColumnType("bigint"); + + b.Property("OpponentServer") + .HasColumnType("int"); + + b.Property("OpponentSize") + .HasColumnType("int"); + + b.Property("VsDuelLeagueId") + .HasColumnType("uniqueidentifier"); + + b.Property("Won") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AllianceId"); + + b.HasIndex("VsDuelLeagueId"); + + b.ToTable("VsDuels", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.VsDuelLeague", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.HasKey("Id"); + + b.ToTable("VsDuelLeagues", "dbo"); + + b.HasData( + new + { + Id = new Guid("0194d053-12b9-7818-9417-4d4eaa3b5ec1"), + Code = 1, + Name = "Silver League" + }, + new + { + Id = new Guid("0194d053-12b9-7c64-9efb-e88745510c35"), + Code = 2, + Name = "Gold League" + }, + new + { + Id = new Guid("0194d053-12b9-7217-a040-88ba1bbc7d69"), + Code = 3, + Name = "Diamond League" + }); + }); + + modelBuilder.Entity("Database.Entities.VsDuelParticipant", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("PlayerId") + .HasColumnType("uniqueidentifier"); + + b.Property("VsDuelId") + .HasColumnType("uniqueidentifier"); + + b.Property("WeeklyPoints") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("VsDuelId"); + + b.ToTable("VsDuelParticipants", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.ZombieSiege", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("AllianceId") + .HasColumnType("uniqueidentifier"); + + b.Property("AllianceSize") + .HasColumnType("int"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EventDate") + .HasColumnType("datetime2"); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AllianceId"); + + b.ToTable("ZombieSieges", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.ZombieSiegeParticipant", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("PlayerId") + .HasColumnType("uniqueidentifier"); + + b.Property("SurvivedWaves") + .HasColumnType("int"); + + b.Property("ZombieSiegeId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ZombieSiegeId"); + + b.ToTable("ZombieSiegeParticipants", "dbo"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "dbo"); + + b.HasData( + new + { + Id = new Guid("d8b9f882-95f0-4ba0-80ed-9c22c27ac88a"), + Name = "SystemAdministrator", + NormalizedName = "SYSTEMADMINISTRATOR" + }, + new + { + Id = new Guid("47de05ba-ff1e-46b6-9995-269084006c24"), + Name = "Administrator", + NormalizedName = "ADMINISTRATOR" + }, + new + { + Id = new Guid("5cc27946-5601-4a25-b9a9-75b8a11c0cf4"), + Name = "User", + NormalizedName = "USER" + }, + new + { + Id = new Guid("207bb0a3-ad50-49bb-bc41-b266fce66529"), + Name = "ReadOnly", + NormalizedName = "READONLY" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "dbo"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "dbo"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "dbo"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "dbo"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "dbo"); + }); + + modelBuilder.Entity("Database.Entities.Admonition", b => + { + b.HasOne("Database.Entities.Player", "Player") + .WithMany("Admonitions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Database.Entities.ApiKey", b => + { + b.HasOne("Database.Entities.Alliance", "Alliance") + .WithOne("ApiKey") + .HasForeignKey("Database.Entities.ApiKey", "AllianceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Alliance"); + }); + + modelBuilder.Entity("Database.Entities.CustomEvent", b => + { + b.HasOne("Database.Entities.Alliance", "Alliance") + .WithMany("CustomEvents") + .HasForeignKey("AllianceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Alliance"); + }); + + modelBuilder.Entity("Database.Entities.CustomEventParticipant", b => + { + b.HasOne("Database.Entities.CustomEvent", "CustomEvent") + .WithMany("CustomEventParticipants") + .HasForeignKey("CustomEventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Database.Entities.Player", "Player") + .WithMany("CustomEventParticipants") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CustomEvent"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Database.Entities.DesertStorm", b => + { + b.HasOne("Database.Entities.Alliance", "Alliance") + .WithMany("DesertStorms") + .HasForeignKey("AllianceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Alliance"); + }); + + modelBuilder.Entity("Database.Entities.DesertStormParticipant", b => + { + b.HasOne("Database.Entities.DesertStorm", "DesertStorm") + .WithMany("DesertStormParticipants") + .HasForeignKey("DesertStormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Database.Entities.Player", "Player") + .WithMany("DesertStormParticipants") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DesertStorm"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Database.Entities.MarshalGuard", b => + { + b.HasOne("Database.Entities.Alliance", "Alliance") + .WithMany("MarshalGuards") + .HasForeignKey("AllianceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Alliance"); + }); + + modelBuilder.Entity("Database.Entities.MarshalGuardParticipant", b => + { + b.HasOne("Database.Entities.MarshalGuard", "MarshalGuard") + .WithMany("MarshalGuardParticipants") + .HasForeignKey("MarshalGuardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Database.Entities.Player", "Player") + .WithMany("MarshalGuardParticipants") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("MarshalGuard"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Database.Entities.Note", b => + { + b.HasOne("Database.Entities.Player", "Player") + .WithMany("Notes") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Database.Entities.Player", b => + { + b.HasOne("Database.Entities.Alliance", "Alliance") + .WithMany("Players") + .HasForeignKey("AllianceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Database.Entities.Rank", "Rank") + .WithMany("Players") + .HasForeignKey("RankId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Alliance"); + + b.Navigation("Rank"); + }); + + modelBuilder.Entity("Database.Entities.User", b => + { + b.HasOne("Database.Entities.Alliance", "Alliance") + .WithMany("Users") + .HasForeignKey("AllianceId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Alliance"); + }); + + modelBuilder.Entity("Database.Entities.VsDuel", b => + { + b.HasOne("Database.Entities.Alliance", "Alliance") + .WithMany("VsDuels") + .HasForeignKey("AllianceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Database.Entities.VsDuelLeague", "VsDuelLeague") + .WithMany("VsDuels") + .HasForeignKey("VsDuelLeagueId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Alliance"); + + b.Navigation("VsDuelLeague"); + }); + + modelBuilder.Entity("Database.Entities.VsDuelParticipant", b => + { + b.HasOne("Database.Entities.Player", "Player") + .WithMany("VsDuelParticipants") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Database.Entities.VsDuel", "VsDuel") + .WithMany("VsDuelParticipants") + .HasForeignKey("VsDuelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("VsDuel"); + }); + + modelBuilder.Entity("Database.Entities.ZombieSiege", b => + { + b.HasOne("Database.Entities.Alliance", "Alliance") + .WithMany("ZombieSieges") + .HasForeignKey("AllianceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Alliance"); + }); + + modelBuilder.Entity("Database.Entities.ZombieSiegeParticipant", b => + { + b.HasOne("Database.Entities.Player", "Player") + .WithMany("ZombieSiegeParticipants") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Database.Entities.ZombieSiege", "ZombieSiege") + .WithMany("ZombieSiegeParticipants") + .HasForeignKey("ZombieSiegeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("ZombieSiege"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Database.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Database.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Database.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Database.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Database.Entities.Alliance", b => + { + b.Navigation("ApiKey"); + + b.Navigation("CustomEvents"); + + b.Navigation("DesertStorms"); + + b.Navigation("MarshalGuards"); + + b.Navigation("Players"); + + b.Navigation("Users"); + + b.Navigation("VsDuels"); + + b.Navigation("ZombieSieges"); + }); + + modelBuilder.Entity("Database.Entities.CustomEvent", b => + { + b.Navigation("CustomEventParticipants"); + }); + + modelBuilder.Entity("Database.Entities.DesertStorm", b => + { + b.Navigation("DesertStormParticipants"); + }); + + modelBuilder.Entity("Database.Entities.MarshalGuard", b => + { + b.Navigation("MarshalGuardParticipants"); + }); + + modelBuilder.Entity("Database.Entities.Player", b => + { + b.Navigation("Admonitions"); + + b.Navigation("CustomEventParticipants"); + + b.Navigation("DesertStormParticipants"); + + b.Navigation("MarshalGuardParticipants"); + + b.Navigation("Notes"); + + b.Navigation("VsDuelParticipants"); + + b.Navigation("ZombieSiegeParticipants"); + }); + + modelBuilder.Entity("Database.Entities.Rank", b => + { + b.Navigation("Players"); + }); + + modelBuilder.Entity("Database.Entities.VsDuel", b => + { + b.Navigation("VsDuelParticipants"); + }); + + modelBuilder.Entity("Database.Entities.VsDuelLeague", b => + { + b.Navigation("VsDuels"); + }); + + modelBuilder.Entity("Database.Entities.ZombieSiege", b => + { + b.Navigation("ZombieSiegeParticipants"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Database/Migrations/20250204093813_AddApiKey.cs b/Database/Migrations/20250204093813_AddApiKey.cs new file mode 100644 index 0000000..c1066cf --- /dev/null +++ b/Database/Migrations/20250204093813_AddApiKey.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Database.Migrations +{ + /// + public partial class AddApiKey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApiKeys", + schema: "dbo", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + AllianceId = table.Column(type: "uniqueidentifier", nullable: false), + EncryptedKey = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + CreatedOn = table.Column(type: "datetime2", nullable: false), + CreatedBy = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + ModifiedOn = table.Column(type: "datetime2", nullable: true), + ModifiedBy = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiKeys", x => x.Id); + table.ForeignKey( + name: "FK_ApiKeys_Alliances_AllianceId", + column: x => x.AllianceId, + principalSchema: "dbo", + principalTable: "Alliances", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApiKeys_AllianceId", + schema: "dbo", + table: "ApiKeys", + column: "AllianceId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiKeys", + schema: "dbo"); + } + } +} diff --git a/Database/Migrations/ApplicationContextModelSnapshot.cs b/Database/Migrations/ApplicationContextModelSnapshot.cs index ac2b5ea..a101861 100644 --- a/Database/Migrations/ApplicationContextModelSnapshot.cs +++ b/Database/Migrations/ApplicationContextModelSnapshot.cs @@ -91,6 +91,41 @@ namespace Database.Migrations b.ToTable("Alliances", "dbo"); }); + modelBuilder.Entity("Database.Entities.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("AllianceId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("EncryptedKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AllianceId") + .IsUnique(); + + b.ToTable("ApiKeys", "dbo"); + }); + modelBuilder.Entity("Database.Entities.CustomEvent", b => { b.Property("Id") @@ -588,19 +623,19 @@ namespace Database.Migrations b.HasData( new { - Id = new Guid("01946f11-c5f1-771e-8600-331582290457"), + Id = new Guid("0194d053-12b9-7818-9417-4d4eaa3b5ec1"), Code = 1, Name = "Silver League" }, new { - Id = new Guid("01946f11-c5f1-7576-b861-14df423f92f2"), + Id = new Guid("0194d053-12b9-7c64-9efb-e88745510c35"), Code = 2, Name = "Gold League" }, new { - Id = new Guid("01946f11-c5f1-750f-b3f5-61ec7a00f837"), + Id = new Guid("0194d053-12b9-7217-a040-88ba1bbc7d69"), Code = 3, Name = "Diamond League" }); @@ -856,6 +891,17 @@ namespace Database.Migrations b.Navigation("Player"); }); + modelBuilder.Entity("Database.Entities.ApiKey", b => + { + b.HasOne("Database.Entities.Alliance", "Alliance") + .WithOne("ApiKey") + .HasForeignKey("Database.Entities.ApiKey", "AllianceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Alliance"); + }); + modelBuilder.Entity("Database.Entities.CustomEvent", b => { b.HasOne("Database.Entities.Alliance", "Alliance") @@ -1107,6 +1153,8 @@ namespace Database.Migrations modelBuilder.Entity("Database.Entities.Alliance", b => { + b.Navigation("ApiKey"); + b.Navigation("CustomEvents"); b.Navigation("DesertStorms"); diff --git a/README.md b/README.md index 92e35b7..f6a4b31 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,17 @@ This project is currently in the **Beta Phase**. --- +### **[0.7.0]** - *2025-02-06* +#### ✨ Added +- **MVP Page**: A new page has been introduced where players can be loaded and a list of MVPs is displayed based on a calculation formula. +- **Filter Options**: You can now display the entire alliance, only R4/R5 members, or just players without R5/R4. +- **API Key**: A new tab has been added under the alliance section, allowing users to generate an API key to access endpoints and integrate them into their own systems. +- **MVP List Endpoint**: The API endpoint for fetching the MVP list is now available. + +#### 🛠️ Fixed +- *(N/A)* + + ### **[0.6.1]** - *2025-01-28* #### ✨ Added - *(N/A)* diff --git a/Ui/src/app/app-routing.module.ts b/Ui/src/app/app-routing.module.ts index f70d3a2..7f9f73c 100644 --- a/Ui/src/app/app-routing.module.ts +++ b/Ui/src/app/app-routing.module.ts @@ -23,12 +23,14 @@ import {ZombieSiegeComponent} from "./pages/zombie-siege/zombie-siege.component" import {ZombieSiegeDetailComponent} from "./pages/zombie-siege/zombie-siege-detail/zombie-siege-detail.component"; import {CustomEventDetailComponent} from "./pages/custom-event/custom-event-detail/custom-event-detail.component"; import {DismissPlayerComponent} from "./pages/dismiss-player/dismiss-player.component"; +import {MvpComponent} from "./pages/mvp/mvp.component"; const routes: Routes = [ {path: 'players', component: PlayerComponent, canActivate: [authGuard]}, {path: 'dismiss-players', component: DismissPlayerComponent, canActivate: [authGuard]}, {path: 'player-information/:id', component: PlayerInformationComponent, canActivate: [authGuard]}, {path: 'marshal-guard', component: MarshalGuardComponent, canActivate: [authGuard]}, + {path: 'mvp', component: MvpComponent, canActivate: [authGuard]}, {path: 'marshal-guard-detail/:id', component: MarshalGuardDetailComponent, canActivate: [authGuard]}, {path: 'vs-duel', component: VsDuelComponent, canActivate: [authGuard]}, {path: 'vs-duel-detail/:id', component: VsDuelDetailComponent, canActivate: [authGuard]}, diff --git a/Ui/src/app/app.module.ts b/Ui/src/app/app.module.ts index 45d1375..924ece0 100644 --- a/Ui/src/app/app.module.ts +++ b/Ui/src/app/app.module.ts @@ -56,6 +56,9 @@ import { PlayerDismissInformationModalComponent } from './modals/player-dismiss- import { PlayerExcelImportModalComponent } from './modals/player-excel-import-modal/player-excel-import-modal.component'; import {AgCharts} from "ag-charts-angular"; import {PlayerInfoVsDuelComponent} from "./pages/player-information/player-info-vs-duel/player-info-vs-duel.component"; +import { MvpComponent } from './pages/mvp/mvp.component'; +import { AllianceApiKeyComponent } from './pages/alliance/alliance-api-key/alliance-api-key.component'; +import { AllianceUserAdministrationComponent } from './pages/alliance/alliance-user-administration/alliance-user-administration.component'; @NgModule({ declarations: [ @@ -101,7 +104,10 @@ import {PlayerInfoVsDuelComponent} from "./pages/player-information/player-info- CustomEventDetailComponent, DismissPlayerComponent, PlayerDismissInformationModalComponent, - PlayerExcelImportModalComponent + PlayerExcelImportModalComponent, + MvpComponent, + AllianceApiKeyComponent, + AllianceUserAdministrationComponent ], imports: [ BrowserModule, diff --git a/Ui/src/app/models/apiKey.model.ts b/Ui/src/app/models/apiKey.model.ts new file mode 100644 index 0000000..b5a37d6 --- /dev/null +++ b/Ui/src/app/models/apiKey.model.ts @@ -0,0 +1,18 @@ +export interface ApiKeyModel { + id: string; + allianceId: string; + key: string; + createdOn: Date; + createdBy: string; + modifiedOn?: Date; + modifiedBy?: string; +} + +export interface CreateApiKeyModel { + allianceId: string; +} + +export interface UpdateApiKeyModel { + id: string; + allianceId: string; +} diff --git a/Ui/src/app/models/player.model.ts b/Ui/src/app/models/player.model.ts index 1c8f91b..92f5cca 100644 --- a/Ui/src/app/models/player.model.ts +++ b/Ui/src/app/models/player.model.ts @@ -1,8 +1,6 @@ import {NoteModel} from "./note.model"; import {AdmonitionModel} from "./admonition.model"; -import {DesertStormParticipantModel} from "./desertStormParticipant.model"; -import {MarshalGuardParticipantModel} from "./marshalGuardParticipant.model"; -import {VsDuelParticipantModel} from "./vsDuelParticipant.model"; + export interface PlayerModel { id: string; @@ -22,6 +20,16 @@ export interface PlayerModel { dismissalReason?: string; } +export interface PlayerMvpModel { + name: string; + allianceRank: string; + duelPointsLast3Weeks: number; + marshalParticipationCount: number; + desertStormParticipationCount: number; + hasParticipatedInOldestDuel: boolean; + mvpScore: number; +} + export interface CreatePlayerModel { playerName: string; rankId: string; diff --git a/Ui/src/app/navigation/navigation.component.html b/Ui/src/app/navigation/navigation.component.html index fe8eb7d..320204f 100644 --- a/Ui/src/app/navigation/navigation.component.html +++ b/Ui/src/app/navigation/navigation.component.html @@ -30,6 +30,11 @@ + +