Update to dotnet 9.0, add user roles, MatchStats and OpenApi/Scalar in dev
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using AutoMapper;
|
||||||
|
using LaDOSE.Business.Interface;
|
||||||
|
using LaDOSE.DTO;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace LaDOSE.Api.Controllers
|
||||||
|
{
|
||||||
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class StatisticsController : Controller
|
||||||
|
{
|
||||||
|
private readonly IStatisticsService _service;
|
||||||
|
private readonly IMapper _mapper;
|
||||||
|
|
||||||
|
public StatisticsController(IMapper mapper, IStatisticsService service)
|
||||||
|
{
|
||||||
|
_mapper = mapper;
|
||||||
|
_service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Match statistics for the given Event ids. Body is a bare JSON array of ids,
|
||||||
|
/// like TournamentController.GetResults.
|
||||||
|
/// A null or empty array returns a zeroed, empty MatchStatsDTO rather than an error.
|
||||||
|
/// Read Coverage before trusting the numbers: many brackets have no set rows at all.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("Matches")]
|
||||||
|
public async Task<MatchStatsDTO> GetMatchStats([FromBody] List<int> ids)
|
||||||
|
{
|
||||||
|
var stats = await _service.GetMatchStats(ids);
|
||||||
|
return _mapper.Map<MatchStatsDTO>(stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -9,6 +9,7 @@ using LaDOSE.Business.Interface;
|
|||||||
using LaDOSE.DTO;
|
using LaDOSE.DTO;
|
||||||
using LaDOSE.Entity;
|
using LaDOSE.Entity;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -33,12 +34,32 @@ namespace LaDOSE.Api.Controllers
|
|||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Public view of a user: no password, no hash, no salt.</summary>
|
||||||
|
private static ApplicationUserDTO ToDto(ApplicationUser user)
|
||||||
|
{
|
||||||
|
return new ApplicationUserDTO
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
Username = user.Username,
|
||||||
|
FirstName = user.FirstName,
|
||||||
|
LastName = user.LastName,
|
||||||
|
Roles = user.Names()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The id the JWT was issued for; null if the request is not authenticated.</summary>
|
||||||
|
private int? CurrentUserId()
|
||||||
|
{
|
||||||
|
return int.TryParse(User?.Identity?.Name, out var id) ? id : (int?)null;
|
||||||
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("auth")]
|
[HttpPost("auth")]
|
||||||
public IActionResult Authenticate([FromBody]ApplicationUser userDto)
|
[ProducesResponseType(typeof(ApplicationUserDTO), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public IActionResult Authenticate([FromBody]ApplicationUserDTO userDto)
|
||||||
{
|
{
|
||||||
var user = _userService.Authenticate(userDto.Username, userDto.Password);
|
var user = _userService.Authenticate(userDto?.Username, userDto?.Password);
|
||||||
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return BadRequest(new { message = "Username or password is incorrect" });
|
return BadRequest(new { message = "Username or password is incorrect" });
|
||||||
@@ -47,6 +68,9 @@ namespace LaDOSE.Api.Controllers
|
|||||||
var key = Encoding.ASCII.GetBytes(this._configuration["JWTTokenSecret"]);
|
var key = Encoding.ASCII.GetBytes(this._configuration["JWTTokenSecret"]);
|
||||||
var tokenDescriptor = new SecurityTokenDescriptor
|
var tokenDescriptor = new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
|
// Only the user id goes in the token. Roles are read from the database on
|
||||||
|
// every request instead, so granting or revoking Admin takes effect at
|
||||||
|
// once rather than whenever the current token happens to expire.
|
||||||
Subject = new ClaimsIdentity(new Claim[]
|
Subject = new ClaimsIdentity(new Claim[]
|
||||||
{
|
{
|
||||||
new Claim(ClaimTypes.Name, user.Id.ToString()),
|
new Claim(ClaimTypes.Name, user.Id.ToString()),
|
||||||
@@ -60,29 +84,62 @@ namespace LaDOSE.Api.Controllers
|
|||||||
var tokenString = tokenHandler.WriteToken(token);
|
var tokenString = tokenHandler.WriteToken(token);
|
||||||
|
|
||||||
// return basic user info (without password) and token to store client side
|
// return basic user info (without password) and token to store client side
|
||||||
return Ok(new ApplicationUserDTO
|
var dto = ToDto(user);
|
||||||
{
|
dto.Token = tokenString;
|
||||||
Id = user.Id,
|
dto.Expire = token.ValidTo;
|
||||||
Username = user.Username,
|
return Ok(dto);
|
||||||
FirstName = user.FirstName,
|
|
||||||
LastName = user.LastName,
|
|
||||||
Token = tokenString,
|
|
||||||
Expire = token.ValidTo
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
/// <summary>Every account, for the admin user-management screen.</summary>
|
||||||
[HttpPost("register")]
|
[Authorize(Roles = Roles.Admin)]
|
||||||
public IActionResult Register([FromBody]ApplicationUser userDto)
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<ApplicationUserDTO>), StatusCodes.Status200OK)]
|
||||||
|
public IActionResult GetUsers()
|
||||||
{
|
{
|
||||||
// map dto to entity
|
var users = _userService.GetAll()
|
||||||
|
.OrderBy(user => user.Username)
|
||||||
|
.Select(ToDto)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The role names that may be assigned, from the applicationrole table.</summary>
|
||||||
|
[Authorize(Roles = Roles.Admin)]
|
||||||
|
[HttpGet("Roles")]
|
||||||
|
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
|
||||||
|
public IActionResult GetRoles()
|
||||||
|
{
|
||||||
|
return Ok(_userService.GetAllRoles().Select(role => role.Name).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an account. This replaces the old anonymous <c>register</c> endpoint —
|
||||||
|
/// only an admin may create users now, so the very first admin has to be promoted
|
||||||
|
/// directly in the database (see Sql/2026-08-05_roles.sql).
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Roles = Roles.Admin)]
|
||||||
|
[HttpPost("AddUser")]
|
||||||
|
[ProducesResponseType(typeof(ApplicationUserDTO), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public IActionResult AddUser([FromBody]ApplicationUserDTO userDto)
|
||||||
|
{
|
||||||
|
if (userDto == null)
|
||||||
|
return BadRequest(new { message = "No user supplied" });
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// save
|
var created = _userService.Create(
|
||||||
_userService.Create(userDto, userDto.Password);
|
new ApplicationUser
|
||||||
return Ok();
|
{
|
||||||
|
Username = userDto.Username?.Trim(),
|
||||||
|
FirstName = userDto.FirstName,
|
||||||
|
LastName = userDto.LastName
|
||||||
|
},
|
||||||
|
userDto.Password,
|
||||||
|
userDto.Roles);
|
||||||
|
|
||||||
|
return Ok(ToDto(created));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -91,7 +148,35 @@ namespace LaDOSE.Api.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes an account. Refuses to delete the caller: since only an admin can reach
|
||||||
|
/// this, and an admin cannot remove themselves, at least one admin always survives
|
||||||
|
/// — which matters because there is no anonymous way back in any more.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Roles = Roles.Admin)]
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult DeleteUser(int id)
|
||||||
|
{
|
||||||
|
var user = _userService.GetById(id);
|
||||||
|
if (user == null)
|
||||||
|
return NotFound(new { message = "User not found" });
|
||||||
|
|
||||||
|
if (CurrentUserId() == id)
|
||||||
|
return BadRequest(new { message = "You cannot delete your own account" });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_userService.Delete(id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#if DEBUG
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Routing;
|
||||||
|
|
||||||
|
namespace LaDOSE.Api.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The controllers in this project are attribute-routed but do not carry [ApiController].
|
||||||
|
/// Without it MVC never sets ApiExplorer visibility, so ApiExplorer yields no descriptions
|
||||||
|
/// and the generated OpenAPI document comes out with an empty "paths" object.
|
||||||
|
/// This convention opts the attribute-routed actions into ApiExplorer for the
|
||||||
|
/// OpenAPI/Scalar tooling only, without pulling in the [ApiController] behaviours
|
||||||
|
/// (automatic 400 responses, [FromBody] inference) that would change runtime binding.
|
||||||
|
/// </summary>
|
||||||
|
public class ApiExplorerVisibilityConvention : IControllerModelConvention
|
||||||
|
{
|
||||||
|
public void Apply(ControllerModel controller)
|
||||||
|
{
|
||||||
|
// Default the controller to hidden, then opt in action by action.
|
||||||
|
controller.ApiExplorer.IsVisible ??= false;
|
||||||
|
|
||||||
|
foreach (var action in controller.Actions)
|
||||||
|
{
|
||||||
|
if (action.ApiExplorer.IsVisible != null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An action with a [Route] but no verb attribute (e.g. BotEventController's
|
||||||
|
// CreateBotEvent) matches every HTTP method, so ApiExplorer reports an empty
|
||||||
|
// method and OpenAPI generation throws "Unsupported HTTP method".
|
||||||
|
// Only document actions that pin down a verb.
|
||||||
|
action.ApiExplorer.IsVisible = action.Attributes
|
||||||
|
.OfType<IActionHttpMethodProvider>()
|
||||||
|
.Any(provider => provider.HttpMethods?.Any() == true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
<LangVersion>12</LangVersion>
|
<LangVersion>12</LangVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.12" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.12" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.12" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.12" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.OpenApi" Version="1.6.17" Condition="'$(Configuration)' == 'Debug'" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.18" Condition="'$(Configuration)' == 'Debug'" />
|
||||||
|
<PackageReference Include="Scalar.AspNetCore" Version="2.16.17" Condition="'$(Configuration)' == 'Debug'" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
|
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using LaDOSE.Business.Interface;
|
using LaDOSE.Business.Interface;
|
||||||
@@ -25,6 +26,9 @@ using Result = LaDOSE.Entity.Challonge.Result;
|
|||||||
using LaDOSE.Entity.BotEvent;
|
using LaDOSE.Entity.BotEvent;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
#if DEBUG
|
||||||
|
using Scalar.AspNetCore;
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace LaDOSE.Api
|
namespace LaDOSE.Api
|
||||||
{
|
{
|
||||||
@@ -55,11 +59,21 @@ namespace LaDOSE.Api
|
|||||||
}
|
}
|
||||||
|
|
||||||
services.AddCors();
|
services.AddCors();
|
||||||
services.AddMvc().AddNewtonsoftJson(x =>
|
services.AddMvc(options =>
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
// Make the attribute-routed controllers visible to ApiExplorer so the
|
||||||
|
// OpenAPI document is actually populated. See ApiExplorerVisibilityConvention.
|
||||||
|
options.Conventions.Add(new ApiExplorerVisibilityConvention());
|
||||||
|
#endif
|
||||||
|
}).AddNewtonsoftJson(x =>
|
||||||
{
|
{
|
||||||
x.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
|
x.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
|
||||||
x.SerializerSettings.MaxDepth= 4;
|
x.SerializerSettings.MaxDepth= 4;
|
||||||
});
|
});
|
||||||
|
#if DEBUG
|
||||||
|
services.AddOpenApi();
|
||||||
|
#endif
|
||||||
// services.AddDbContextPool<LaDOSEDbContext>( // replace "YourDbContext" with the class name of your DbContext
|
// services.AddDbContextPool<LaDOSEDbContext>( // replace "YourDbContext" with the class name of your DbContext
|
||||||
//
|
//
|
||||||
// options => options.UseMySql($"Server={MySqlServer};Database={MySqlDatabase};User={MySqlUser};Password={MySqlPassword};", // replace with your Connection String
|
// options => options.UseMySql($"Server={MySqlServer};Database={MySqlDatabase};User={MySqlUser};Password={MySqlPassword};", // replace with your Connection String
|
||||||
@@ -92,6 +106,18 @@ namespace LaDOSE.Api
|
|||||||
{
|
{
|
||||||
// return unauthorized if user no longer exists
|
// return unauthorized if user no longer exists
|
||||||
context.Fail("Unauthorized");
|
context.Fail("Unauthorized");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles are attached here, from the database, rather than being
|
||||||
|
// signed into the token: a promotion or demotion then applies to
|
||||||
|
// the caller's very next request instead of waiting 16 minutes.
|
||||||
|
if (context.Principal.Identity is ClaimsIdentity identity)
|
||||||
|
{
|
||||||
|
foreach (var role in user.Names())
|
||||||
|
{
|
||||||
|
identity.AddClaim(new Claim(identity.RoleClaimType, role));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -130,6 +156,13 @@ namespace LaDOSE.Api
|
|||||||
cfg.CreateMapTwoWay<Game, LaDOSE.DTO.GameDTO>();
|
cfg.CreateMapTwoWay<Game, LaDOSE.DTO.GameDTO>();
|
||||||
cfg.CreateMapTwoWay<Todo, LaDOSE.DTO.TodoDTO>();
|
cfg.CreateMapTwoWay<Todo, LaDOSE.DTO.TodoDTO>();
|
||||||
|
|
||||||
|
// Match statistics: plain POCO aggregates computed by StatisticsService,
|
||||||
|
// mapped by name (same pattern as TournamentsResult above).
|
||||||
|
cfg.CreateMap<MatchStats, LaDOSE.DTO.MatchStatsDTO>();
|
||||||
|
cfg.CreateMap<MatchCoverage, LaDOSE.DTO.MatchCoverageDTO>();
|
||||||
|
cfg.CreateMap<PlayerMatchStats, LaDOSE.DTO.PlayerMatchStatsDTO>();
|
||||||
|
cfg.CreateMap<HeadToHead, LaDOSE.DTO.HeadToHeadDTO>();
|
||||||
|
|
||||||
});
|
});
|
||||||
IMapper mapper = mapperConfig.CreateMapper();
|
IMapper mapper = mapperConfig.CreateMapper();
|
||||||
services.AddSingleton(mapper);
|
services.AddSingleton(mapper);
|
||||||
@@ -149,6 +182,7 @@ namespace LaDOSE.Api
|
|||||||
services.AddScoped<IBotEventService, BotEventService>();
|
services.AddScoped<IBotEventService, BotEventService>();
|
||||||
|
|
||||||
services.AddScoped<IPlayerService, PlayerService>();
|
services.AddScoped<IPlayerService, PlayerService>();
|
||||||
|
services.AddScoped<IStatisticsService, StatisticsService>();
|
||||||
services.AddTransient<IChallongeProvider>(p => new ChallongeProvider( p.GetRequiredService<IGameService>(),
|
services.AddTransient<IChallongeProvider>(p => new ChallongeProvider( p.GetRequiredService<IGameService>(),
|
||||||
p.GetRequiredService<IEventService>(),
|
p.GetRequiredService<IEventService>(),
|
||||||
p.GetRequiredService<IPlayerService>(),
|
p.GetRequiredService<IPlayerService>(),
|
||||||
@@ -185,7 +219,17 @@ namespace LaDOSE.Api
|
|||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseEndpoints(x => x.MapControllers());
|
app.UseEndpoints(x =>
|
||||||
|
{
|
||||||
|
x.MapControllers();
|
||||||
|
#if DEBUG
|
||||||
|
if (env.IsDevelopment())
|
||||||
|
{
|
||||||
|
x.MapOpenApi();
|
||||||
|
x.MapScalarApiReference();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace LaDOSE.DTO
|
namespace LaDOSE.DTO
|
||||||
{
|
{
|
||||||
@@ -9,8 +10,13 @@ namespace LaDOSE.DTO
|
|||||||
public string FirstName { get; set; }
|
public string FirstName { get; set; }
|
||||||
public string LastName { get; set; }
|
public string LastName { get; set; }
|
||||||
public string Username { get; set; }
|
public string Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Only ever read from a request; never populated on a response.</summary>
|
||||||
public string Password { get; set; }
|
public string Password { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Role names held by the user, e.g. <c>["Admin"]</c>. Empty means a plain user.</summary>
|
||||||
|
public List<string> Roles { get; set; }
|
||||||
|
|
||||||
public string Token { get; set; }
|
public string Token { get; set; }
|
||||||
public DateTime Expire { get; set; }
|
public DateTime Expire { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
namespace LaDOSE.DTO
|
using System;
|
||||||
|
|
||||||
|
namespace LaDOSE.DTO
|
||||||
{
|
{
|
||||||
public class EventDTO
|
public class EventDTO
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Event date, mapped by convention from Event.Date. Used for time-series charts.</summary>
|
||||||
|
public DateTime Date { get; set; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LaDOSE.DTO
|
||||||
|
{
|
||||||
|
public class MatchStatsDTO
|
||||||
|
{
|
||||||
|
public MatchCoverageDTO Coverage { get; set; }
|
||||||
|
public List<PlayerMatchStatsDTO> Players { get; set; }
|
||||||
|
public List<HeadToHeadDTO> HeadToHead { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MatchCoverageDTO
|
||||||
|
{
|
||||||
|
/// <summary>Requested event ids that actually exist.</summary>
|
||||||
|
public int Events { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Tournaments (brackets) belonging to those events.</summary>
|
||||||
|
public int Brackets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Of those brackets, how many have at least one set row.</summary>
|
||||||
|
public int BracketsWithSets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total set rows in scope, including unusable ones.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Sets with a determinable winner.</summary>
|
||||||
|
public int DecidedSets { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlayerMatchStatsDTO
|
||||||
|
{
|
||||||
|
public int PlayerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||||
|
public string Player { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
public int Wins { get; set; }
|
||||||
|
public int Losses { get; set; }
|
||||||
|
public int GamesWon { get; set; }
|
||||||
|
public int GamesLost { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HeadToHeadDTO
|
||||||
|
{
|
||||||
|
public int PlayerAId { get; set; }
|
||||||
|
public string PlayerA { get; set; }
|
||||||
|
public int PlayerBId { get; set; }
|
||||||
|
public string PlayerB { get; set; }
|
||||||
|
public int WinsA { get; set; }
|
||||||
|
public int WinsB { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace LaDOSE.Entity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A role a user can hold, stored in the pre-existing <c>applicationrole</c> table.
|
||||||
|
/// Rows are reference data seeded by Sql/2026-08-05_roles.sql, not created at runtime.
|
||||||
|
/// </summary>
|
||||||
|
public class ApplicationRole
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string Name { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,14 @@ namespace LaDOSE.Entity
|
|||||||
public string Password { get; set; }
|
public string Password { get; set; }
|
||||||
public byte[] PasswordHash { get; set; }
|
public byte[] PasswordHash { get; set; }
|
||||||
public byte[] PasswordSalt { get; set; }
|
public byte[] PasswordSalt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rows of the <c>applicationuserrole</c> join table for this user. Only populated
|
||||||
|
/// when the query asks for it — see UserService, which includes it (and the role
|
||||||
|
/// itself) everywhere the role matters; authorization reads this on every request.
|
||||||
|
/// Prefer the <see cref="Roles.Names"/> / <see cref="Roles.IsAdmin"/> helpers.
|
||||||
|
/// </summary>
|
||||||
|
public List<ApplicationUserRole> UserRoles { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace LaDOSE.Entity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>applicationuserrole</c> join table. Declared explicitly rather than left
|
||||||
|
/// implicit so its columns match the table that already exists in the database
|
||||||
|
/// (<c>userid</c>, <c>roleid</c>, no surrogate key).
|
||||||
|
/// </summary>
|
||||||
|
public class ApplicationUserRole
|
||||||
|
{
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public ApplicationUser User { get; set; }
|
||||||
|
|
||||||
|
public int RoleId { get; set; }
|
||||||
|
public ApplicationRole Role { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ namespace LaDOSE.Entity.Context
|
|||||||
{
|
{
|
||||||
public DbSet<Game> Game { get; set; }
|
public DbSet<Game> Game { get; set; }
|
||||||
public DbSet<ApplicationUser> ApplicationUser { get; set; }
|
public DbSet<ApplicationUser> ApplicationUser { get; set; }
|
||||||
|
public DbSet<ApplicationRole> ApplicationRole { get; set; }
|
||||||
|
|
||||||
public DbSet<Todo> Todo { get; set; }
|
public DbSet<Todo> Todo { get; set; }
|
||||||
|
|
||||||
@@ -49,6 +50,27 @@ namespace LaDOSE.Entity.Context
|
|||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
|
||||||
|
#region Users and roles
|
||||||
|
|
||||||
|
// Maps onto the applicationrole / applicationuserrole tables that already exist
|
||||||
|
// in the schema. The join table is configured explicitly rather than as a
|
||||||
|
// many-to-many skip navigation so its columns are exactly userid + roleid,
|
||||||
|
// with the pair as the key and no surrogate id.
|
||||||
|
modelBuilder.Entity<ApplicationUserRole>(join =>
|
||||||
|
{
|
||||||
|
join.HasKey(ur => new { ur.UserId, ur.RoleId });
|
||||||
|
|
||||||
|
join.HasOne(ur => ur.User)
|
||||||
|
.WithMany(u => u.UserRoles)
|
||||||
|
.HasForeignKey(ur => ur.UserId);
|
||||||
|
|
||||||
|
join.HasOne(ur => ur.Role)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(ur => ur.RoleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
modelBuilder.Entity<Event>()
|
modelBuilder.Entity<Event>()
|
||||||
.HasMany(s => s.Tournaments);
|
.HasMany(s => s.Tournaments);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace LaDOSE.Entity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The role names the API knows about. They must exist as rows in
|
||||||
|
/// <c>applicationrole</c> — see Sql/2026-08-05_roles.sql.
|
||||||
|
/// </summary>
|
||||||
|
public static class Roles
|
||||||
|
{
|
||||||
|
/// <summary>May manage user accounts.</summary>
|
||||||
|
public const string Admin = "Admin";
|
||||||
|
|
||||||
|
/// <summary>May use everything else. The absence of a role means the same thing.</summary>
|
||||||
|
public const string User = "User";
|
||||||
|
|
||||||
|
public static readonly string[] All = { Admin, User };
|
||||||
|
|
||||||
|
/// <summary>Case-insensitive, so 'admin' typed into the SQL seed still counts.</summary>
|
||||||
|
public static bool IsAdmin(this ApplicationUser user)
|
||||||
|
{
|
||||||
|
return user.Names().Any(name => Admin.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's role names. Empty when the user holds no role, and also when the
|
||||||
|
/// query did not include them — callers that authorize must load them.
|
||||||
|
/// </summary>
|
||||||
|
public static List<string> Names(this ApplicationUser user)
|
||||||
|
{
|
||||||
|
return user?.UserRoles?
|
||||||
|
.Select(userRole => userRole.Role?.Name)
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.OrderBy(name => name)
|
||||||
|
.ToList()
|
||||||
|
?? new List<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LaDOSE.Entity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate over the persisted <see cref="Set"/> rows of one or more Events.
|
||||||
|
/// Not an entity: it is never mapped by EF, it is computed in memory by
|
||||||
|
/// StatisticsService and mapped to MatchStatsDTO by AutoMapper (same pattern as
|
||||||
|
/// <see cref="Challonge.TournamentsResult"/>).
|
||||||
|
/// Property names must stay identical to the DTO's, the mapping is by convention.
|
||||||
|
/// </summary>
|
||||||
|
public class MatchStats
|
||||||
|
{
|
||||||
|
/// <summary>How much of the requested scope actually has set data behind it.</summary>
|
||||||
|
public MatchCoverage Coverage { get; set; } = new MatchCoverage();
|
||||||
|
|
||||||
|
public List<PlayerMatchStats> Players { get; set; } = new List<PlayerMatchStats>();
|
||||||
|
|
||||||
|
public List<HeadToHead> HeadToHead { get; set; } = new List<HeadToHead>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set coverage is sparse: Challonge-imported events have no sets at all, and events
|
||||||
|
/// imported before SmashProvider.GetSets existed were never backfilled. These counters
|
||||||
|
/// let a caller say "12 of 40 brackets have match data" instead of implying completeness.
|
||||||
|
/// </summary>
|
||||||
|
public class MatchCoverage
|
||||||
|
{
|
||||||
|
/// <summary>Requested event ids that actually exist.</summary>
|
||||||
|
public int Events { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Tournaments (brackets) belonging to those events.</summary>
|
||||||
|
public int Brackets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Of those brackets, how many have at least one set row.</summary>
|
||||||
|
public int BracketsWithSets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total set rows in scope, including the ones skipped as unusable.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Sets with a determinable winner (unequal scores, two distinct real players).</summary>
|
||||||
|
public int DecidedSets { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlayerMatchStats
|
||||||
|
{
|
||||||
|
public int PlayerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||||
|
public string Player { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
public int Wins { get; set; }
|
||||||
|
public int Losses { get; set; }
|
||||||
|
public int GamesWon { get; set; }
|
||||||
|
public int GamesLost { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per unordered pair of players with at least one decided set.
|
||||||
|
/// A is always the lower PlayerId so WinsA / WinsB are unambiguous.
|
||||||
|
/// </summary>
|
||||||
|
public class HeadToHead
|
||||||
|
{
|
||||||
|
public int PlayerAId { get; set; }
|
||||||
|
public string PlayerA { get; set; }
|
||||||
|
public int PlayerBId { get; set; }
|
||||||
|
public string PlayerB { get; set; }
|
||||||
|
public int WinsA { get; set; }
|
||||||
|
public int WinsB { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
<LangVersion>12</LangVersion>
|
<LangVersion>12</LangVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LaDOSE.Entity;
|
||||||
|
|
||||||
|
namespace LaDOSE.Business.Interface
|
||||||
|
{
|
||||||
|
public interface IStatisticsService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate the persisted sets of the given Events into per-player records,
|
||||||
|
/// head-to-head records and a coverage report.
|
||||||
|
/// A null or empty id list yields a well-formed, zeroed <see cref="MatchStats"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task<MatchStats> GetMatchStats(List<int> eventIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,11 @@ namespace LaDOSE.Business.Interface
|
|||||||
ApplicationUser Authenticate(string username, string password);
|
ApplicationUser Authenticate(string username, string password);
|
||||||
IEnumerable<ApplicationUser> GetAll();
|
IEnumerable<ApplicationUser> GetAll();
|
||||||
ApplicationUser GetById(int id);
|
ApplicationUser GetById(int id);
|
||||||
ApplicationUser Create(ApplicationUser user, string password);
|
ApplicationUser Create(ApplicationUser user, string password, IEnumerable<string> roleNames = null);
|
||||||
void Update(ApplicationUser user, string password = null);
|
void Update(ApplicationUser user, string password = null);
|
||||||
void Delete(int id);
|
void Delete(int id);
|
||||||
|
|
||||||
|
/// <summary>The roles that exist in the database — reference data, not created at runtime.</summary>
|
||||||
|
IEnumerable<ApplicationRole> GetAllRoles();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<AssemblyName>LaDOSE.Business</AssemblyName>
|
<AssemblyName>LaDOSE.Business</AssemblyName>
|
||||||
<RootNamespace>LaDOSE.Business</RootNamespace>
|
<RootNamespace>LaDOSE.Business</RootNamespace>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LaDOSE.Business.Interface;
|
||||||
|
using LaDOSE.Entity;
|
||||||
|
using LaDOSE.Entity.Context;
|
||||||
|
|
||||||
|
namespace LaDOSE.Business.Service
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Match statistics over the persisted Set rows.
|
||||||
|
///
|
||||||
|
/// Two things to know about the data:
|
||||||
|
/// - There is no Set -> Player relationship in the EF model (the navigation properties
|
||||||
|
/// and their configuration are commented out), so player names are resolved with a
|
||||||
|
/// separate query plus an in-memory dictionary. Nothing here Includes a player.
|
||||||
|
/// - There is no winner column. The winner is inferred from the scores, and start.gg
|
||||||
|
/// encodes a DQ as -1, so games are clamped at 0.
|
||||||
|
///
|
||||||
|
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
|
||||||
|
/// issues one query per table and hands the loaded lists to the pure static
|
||||||
|
/// <see cref="Aggregate"/>, which is unit-testable without a database.
|
||||||
|
/// </summary>
|
||||||
|
public class StatisticsService : IStatisticsService
|
||||||
|
{
|
||||||
|
protected LaDOSEDbContext _context;
|
||||||
|
|
||||||
|
public StatisticsService(LaDOSEDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MatchStats> GetMatchStats(List<int> eventIds)
|
||||||
|
{
|
||||||
|
var requested = (eventIds ?? new List<int>()).Distinct().ToList();
|
||||||
|
if (requested.Count == 0)
|
||||||
|
{
|
||||||
|
// Well-formed and empty, never null and never a 500.
|
||||||
|
return Task.FromResult(new MatchStats());
|
||||||
|
}
|
||||||
|
|
||||||
|
// One query per table. No query per set and no query per player.
|
||||||
|
var events = _context.Event
|
||||||
|
.Where(e => requested.Contains(e.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var existingEventIds = events.Select(e => e.Id).ToList();
|
||||||
|
|
||||||
|
var tournaments = existingEventIds.Count == 0
|
||||||
|
? new List<Tournament>()
|
||||||
|
: _context.Tournament
|
||||||
|
.Where(t => existingEventIds.Contains(t.EventId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var tournamentIds = tournaments.Select(t => t.Id).Distinct().ToList();
|
||||||
|
|
||||||
|
// _context.Set is the DbSet<Set> property, not DbContext.Set<T>().
|
||||||
|
var sets = tournamentIds.Count == 0
|
||||||
|
? new List<Set>()
|
||||||
|
: _context.Set
|
||||||
|
.Where(s => tournamentIds.Contains(s.TournamentId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var playerIds = sets.Select(s => s.Player1Id)
|
||||||
|
.Concat(sets.Select(s => s.Player2Id))
|
||||||
|
.Where(id => id != 0)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var players = playerIds.Count == 0
|
||||||
|
? new List<Player>()
|
||||||
|
: _context.Player
|
||||||
|
.Where(p => playerIds.Contains(p.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Task.FromResult(Aggregate(requested, events, tournaments, sets, players));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure aggregation over already-loaded rows. No database, no I/O, deterministic.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="requestedEventIds">The event ids the caller asked for.</param>
|
||||||
|
/// <param name="events">Events that exist (used for the coverage count).</param>
|
||||||
|
/// <param name="tournaments">Candidate tournaments; filtered here on EventId.</param>
|
||||||
|
/// <param name="sets">Candidate sets; filtered here on TournamentId.</param>
|
||||||
|
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
|
||||||
|
public static MatchStats Aggregate(
|
||||||
|
IEnumerable<int> requestedEventIds,
|
||||||
|
IEnumerable<Event> events,
|
||||||
|
IEnumerable<Tournament> tournaments,
|
||||||
|
IEnumerable<Set> sets,
|
||||||
|
IEnumerable<Player> players)
|
||||||
|
{
|
||||||
|
var result = new MatchStats();
|
||||||
|
|
||||||
|
var requested = new HashSet<int>((requestedEventIds ?? Enumerable.Empty<int>()));
|
||||||
|
if (requested.Count == 0)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coverage: events -------------------------------------------------------
|
||||||
|
var existingEventIds = new HashSet<int>((events ?? Enumerable.Empty<Event>())
|
||||||
|
.Where(e => e != null && requested.Contains(e.Id))
|
||||||
|
.Select(e => e.Id));
|
||||||
|
result.Coverage.Events = existingEventIds.Count;
|
||||||
|
|
||||||
|
// Coverage: brackets ----------------------------------------------------
|
||||||
|
var scopedTournamentIds = new HashSet<int>((tournaments ?? Enumerable.Empty<Tournament>())
|
||||||
|
.Where(t => t != null && existingEventIds.Contains(t.EventId))
|
||||||
|
.Select(t => t.Id));
|
||||||
|
result.Coverage.Brackets = scopedTournamentIds.Count;
|
||||||
|
|
||||||
|
var scopedSets = (sets ?? Enumerable.Empty<Set>())
|
||||||
|
.Where(s => s != null && scopedTournamentIds.Contains(s.TournamentId))
|
||||||
|
.ToList();
|
||||||
|
result.Coverage.Sets = scopedSets.Count;
|
||||||
|
result.Coverage.BracketsWithSets = scopedSets.Select(s => s.TournamentId).Distinct().Count();
|
||||||
|
|
||||||
|
// Name resolution -------------------------------------------------------
|
||||||
|
var nameById = new Dictionary<int, string>();
|
||||||
|
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
|
||||||
|
{
|
||||||
|
nameById[player.Id] = DisplayName(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats = new Dictionary<int, PlayerMatchStats>();
|
||||||
|
var h2h = new Dictionary<(int, int), HeadToHead>();
|
||||||
|
|
||||||
|
foreach (var set in scopedSets)
|
||||||
|
{
|
||||||
|
// Unusable rows still count in Coverage.Sets but nowhere else.
|
||||||
|
if (set.Player1Id == 0 || set.Player2Id == 0 || set.Player1Id == set.Player2Id)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No winner column: equal scores (including 0-0 and -1 / -1) are undecided.
|
||||||
|
if (set.Player1Score == set.Player2Score)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Coverage.DecidedSets++;
|
||||||
|
|
||||||
|
var p1 = set.Player1Id;
|
||||||
|
var p2 = set.Player2Id;
|
||||||
|
|
||||||
|
// A DQ is stored as -1. Clamp so it never produces negative games.
|
||||||
|
var games1 = Math.Max(0, set.Player1Score);
|
||||||
|
var games2 = Math.Max(0, set.Player2Score);
|
||||||
|
|
||||||
|
var stat1 = GetOrAdd(stats, p1, nameById);
|
||||||
|
var stat2 = GetOrAdd(stats, p2, nameById);
|
||||||
|
|
||||||
|
stat1.GamesWon += games1;
|
||||||
|
stat1.GamesLost += games2;
|
||||||
|
stat2.GamesWon += games2;
|
||||||
|
stat2.GamesLost += games1;
|
||||||
|
|
||||||
|
var winnerId = set.Player1Score > set.Player2Score ? p1 : p2;
|
||||||
|
if (winnerId == p1)
|
||||||
|
{
|
||||||
|
stat1.Wins++;
|
||||||
|
stat2.Losses++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stat2.Wins++;
|
||||||
|
stat1.Losses++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lower PlayerId is always A, so WinsA / WinsB are unambiguous.
|
||||||
|
var aId = Math.Min(p1, p2);
|
||||||
|
var bId = Math.Max(p1, p2);
|
||||||
|
var key = (aId, bId);
|
||||||
|
if (!h2h.TryGetValue(key, out var pair))
|
||||||
|
{
|
||||||
|
pair = new HeadToHead
|
||||||
|
{
|
||||||
|
PlayerAId = aId,
|
||||||
|
PlayerA = ResolveName(aId, nameById),
|
||||||
|
PlayerBId = bId,
|
||||||
|
PlayerB = ResolveName(bId, nameById)
|
||||||
|
};
|
||||||
|
h2h[key] = pair;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winnerId == aId)
|
||||||
|
{
|
||||||
|
pair.WinsA++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pair.WinsB++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets is derived, so Sets == Wins + Losses cannot drift.
|
||||||
|
foreach (var stat in stats.Values)
|
||||||
|
{
|
||||||
|
stat.Sets = stat.Wins + stat.Losses;
|
||||||
|
Debug.Assert(stat.Sets == stat.Wins + stat.Losses, "Sets must equal Wins + Losses");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Players = stats.Values
|
||||||
|
.OrderByDescending(p => p.Wins)
|
||||||
|
.ThenByDescending(p => p.Sets)
|
||||||
|
.ThenBy(p => p.Player, StringComparer.Ordinal)
|
||||||
|
.ThenBy(p => p.PlayerId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Not capped: bounded by the number of sets in scope.
|
||||||
|
result.HeadToHead = h2h.Values
|
||||||
|
.OrderByDescending(p => p.WinsA + p.WinsB)
|
||||||
|
.ThenBy(p => p.PlayerA, StringComparer.Ordinal)
|
||||||
|
.ThenBy(p => p.PlayerB, StringComparer.Ordinal)
|
||||||
|
.ThenBy(p => p.PlayerAId)
|
||||||
|
.ThenBy(p => p.PlayerBId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerMatchStats GetOrAdd(Dictionary<int, PlayerMatchStats> stats, int playerId,
|
||||||
|
Dictionary<int, string> nameById)
|
||||||
|
{
|
||||||
|
if (!stats.TryGetValue(playerId, out var stat))
|
||||||
|
{
|
||||||
|
stat = new PlayerMatchStats
|
||||||
|
{
|
||||||
|
PlayerId = playerId,
|
||||||
|
Player = ResolveName(playerId, nameById)
|
||||||
|
};
|
||||||
|
stats[playerId] = stat;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gamertag, falling back to Name, else "#<id>" (a set can reference a player row
|
||||||
|
/// that no longer exists, and Gamertag is nullable in the database).
|
||||||
|
/// </summary>
|
||||||
|
private static string ResolveName(int playerId, Dictionary<int, string> nameById)
|
||||||
|
{
|
||||||
|
return nameById.TryGetValue(playerId, out var name) ? name : $"#{playerId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DisplayName(Player player)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.Gamertag))
|
||||||
|
{
|
||||||
|
return player.Gamertag;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.Name))
|
||||||
|
{
|
||||||
|
return player.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"#{player.Id}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using System.Linq;
|
|||||||
using LaDOSE.Business.Interface;
|
using LaDOSE.Business.Interface;
|
||||||
using LaDOSE.Entity;
|
using LaDOSE.Entity;
|
||||||
using LaDOSE.Entity.Context;
|
using LaDOSE.Entity.Context;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace LaDOSE.Business.Service
|
namespace LaDOSE.Business.Service
|
||||||
{
|
{
|
||||||
@@ -20,8 +21,9 @@ namespace LaDOSE.Business.Service
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
|
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
|
||||||
return null;
|
return null;
|
||||||
var p = _context.ApplicationUser.ToList();
|
var user = _context.ApplicationUser
|
||||||
var user = _context.ApplicationUser.SingleOrDefault(x => x.Username == username);
|
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||||
|
.SingleOrDefault(x => x.Username == username);
|
||||||
|
|
||||||
// check if username exists
|
// check if username exists
|
||||||
if (user == null)
|
if (user == null)
|
||||||
@@ -37,23 +39,44 @@ namespace LaDOSE.Business.Service
|
|||||||
|
|
||||||
public IEnumerable<ApplicationUser> GetAll()
|
public IEnumerable<ApplicationUser> GetAll()
|
||||||
{
|
{
|
||||||
return _context.ApplicationUser;
|
return _context.ApplicationUser
|
||||||
|
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Roles are included because authorization reads them on every authenticated
|
||||||
|
/// request (see the OnTokenValidated handler in Startup).
|
||||||
|
/// </summary>
|
||||||
public ApplicationUser GetById(int id)
|
public ApplicationUser GetById(int id)
|
||||||
{
|
{
|
||||||
return _context.ApplicationUser.Find(id);
|
return _context.ApplicationUser
|
||||||
|
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||||
|
.SingleOrDefault(x => x.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplicationUser Create(ApplicationUser user, string password)
|
public IEnumerable<ApplicationRole> GetAllRoles()
|
||||||
|
{
|
||||||
|
return _context.ApplicationRole.OrderBy(x => x.Name).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApplicationUser Create(ApplicationUser user, string password, IEnumerable<string> roleNames = null)
|
||||||
{
|
{
|
||||||
// validation
|
// validation
|
||||||
|
if (string.IsNullOrWhiteSpace(user?.Username))
|
||||||
|
throw new Exception("Username is required");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
throw new Exception("Password is required");
|
throw new Exception("Password is required");
|
||||||
|
|
||||||
if (_context.ApplicationUser.Any(x => x.Username == user.Username))
|
if (_context.ApplicationUser.Any(x => x.Username == user.Username))
|
||||||
throw new Exception("Username \"" + user.Username + "\" is already taken");
|
throw new Exception("Username \"" + user.Username + "\" is already taken");
|
||||||
|
|
||||||
|
// EF fills userid/roleid on the join rows from these navigations when it saves.
|
||||||
|
user.UserRoles = ResolveRoles(roleNames)
|
||||||
|
.Select(role => new ApplicationUserRole { Role = role })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
byte[] passwordHash, passwordSalt;
|
byte[] passwordHash, passwordSalt;
|
||||||
CreatePasswordHash(password, out passwordHash, out passwordSalt);
|
CreatePasswordHash(password, out passwordHash, out passwordSalt);
|
||||||
|
|
||||||
@@ -66,6 +89,35 @@ namespace LaDOSE.Business.Service
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns role names into the existing rows of <c>applicationrole</c>. An unknown
|
||||||
|
/// name is an error rather than a new role, so a typo cannot quietly produce an
|
||||||
|
/// account with no privileges — or, worse, a second spelling of "Admin".
|
||||||
|
/// </summary>
|
||||||
|
private List<ApplicationRole> ResolveRoles(IEnumerable<string> roleNames)
|
||||||
|
{
|
||||||
|
var wanted = (roleNames ?? Enumerable.Empty<string>())
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.Select(name => name.Trim())
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (wanted.Count == 0)
|
||||||
|
return new List<ApplicationRole>();
|
||||||
|
|
||||||
|
var known = _context.ApplicationRole.ToList();
|
||||||
|
var resolved = new List<ApplicationRole>();
|
||||||
|
foreach (var name in wanted)
|
||||||
|
{
|
||||||
|
var role = known.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (role == null)
|
||||||
|
throw new Exception($"Unknown role \"{name}\"");
|
||||||
|
resolved.Add(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
public void Update(ApplicationUser userParam, string password = null)
|
public void Update(ApplicationUser userParam, string password = null)
|
||||||
{
|
{
|
||||||
var user = _context.ApplicationUser.Find(userParam.Id);
|
var user = _context.ApplicationUser.Find(userParam.Id);
|
||||||
@@ -101,12 +153,18 @@ namespace LaDOSE.Business.Service
|
|||||||
|
|
||||||
public void Delete(int id)
|
public void Delete(int id)
|
||||||
{
|
{
|
||||||
var user = _context.ApplicationUser.Find(id);
|
// applicationuserrole's foreign keys are ON DELETE RESTRICT, so the join rows
|
||||||
if (user != null)
|
// have to go first — clearing the collection makes EF delete them.
|
||||||
{
|
var user = _context.ApplicationUser
|
||||||
_context.ApplicationUser.Remove(user);
|
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||||
_context.SaveChanges();
|
.SingleOrDefault(x => x.Id == id);
|
||||||
}
|
|
||||||
|
if (user == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
user.UserRoles?.Clear();
|
||||||
|
_context.ApplicationUser.Remove(user);
|
||||||
|
_context.SaveChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
// private helper methods
|
// private helper methods
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<LangVersion>12</LangVersion>
|
<LangVersion>12</LangVersion>
|
||||||
|
|||||||
Reference in New Issue
Block a user