From d9e05fb48708639da547c236585be45b34d12c1a Mon Sep 17 00:00:00 2001 From: Darkstack <1835601+darkstack@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:56:07 +0200 Subject: [PATCH] Update to dotnet 9.0, add user roles, MatchStats and OpenApi/Scalar in dev --- .../Controllers/StatisticsController.cs | 38 +++ .../LaDOSE.Api/Controllers/UsersController.cs | 125 ++++++-- .../ApiExplorerVisibilityConvention.cs | 41 +++ LaDOSE.Src/LaDOSE.Api/LaDOSE.Api.csproj | 5 +- LaDOSE.Src/LaDOSE.Api/Startup.cs | 48 +++- LaDOSE.Src/LaDOSE.DTO/ApplicationUserDTO.cs | 6 + LaDOSE.Src/LaDOSE.DTO/EventDTO.cs | 7 +- LaDOSE.Src/LaDOSE.DTO/LaDOSE.DTO.csproj | 2 +- LaDOSE.Src/LaDOSE.DTO/MatchStatsDTO.cs | 55 ++++ .../LaDOSE.DesktopApp.Avalonia.csproj | 2 +- .../LaDOSE.DiscordBot.csproj | 2 +- LaDOSE.Src/LaDOSE.Entity/ApplicationRole.cs | 17 ++ LaDOSE.Src/LaDOSE.Entity/ApplicationUser.cs | 11 +- .../LaDOSE.Entity/ApplicationUserRole.cs | 16 ++ .../LaDOSE.Entity/Context/LaDOSEDbContext.cs | 22 ++ LaDOSE.Src/LaDOSE.Entity/LaDOSE.Entity.csproj | 2 +- LaDOSE.Src/LaDOSE.Entity/Roles.cs | 41 +++ .../TournamentEntities/MatchStats.cs | 74 +++++ LaDOSE.Src/LaDOSE.REST/LaDOSE.REST.csproj | 2 +- .../Interface/IStatisticsService.cs | 16 ++ .../LaDOSE.Service/Interface/IUserService.cs | 5 +- .../LaDOSE.Service/LaDOSE.Business.csproj | 2 +- .../Service/StatisticsService.cs | 267 ++++++++++++++++++ .../LaDOSE.Service/Service/UserService.cs | 80 +++++- LaDOSE.Src/LinuxTest/LinuxTest.csproj | 2 +- 25 files changed, 844 insertions(+), 44 deletions(-) create mode 100644 LaDOSE.Src/LaDOSE.Api/Controllers/StatisticsController.cs create mode 100644 LaDOSE.Src/LaDOSE.Api/Helpers/ApiExplorerVisibilityConvention.cs create mode 100644 LaDOSE.Src/LaDOSE.DTO/MatchStatsDTO.cs create mode 100644 LaDOSE.Src/LaDOSE.Entity/ApplicationRole.cs create mode 100644 LaDOSE.Src/LaDOSE.Entity/ApplicationUserRole.cs create mode 100644 LaDOSE.Src/LaDOSE.Entity/Roles.cs create mode 100644 LaDOSE.Src/LaDOSE.Entity/TournamentEntities/MatchStats.cs create mode 100644 LaDOSE.Src/LaDOSE.Service/Interface/IStatisticsService.cs create mode 100644 LaDOSE.Src/LaDOSE.Service/Service/StatisticsService.cs diff --git a/LaDOSE.Src/LaDOSE.Api/Controllers/StatisticsController.cs b/LaDOSE.Src/LaDOSE.Api/Controllers/StatisticsController.cs new file mode 100644 index 0000000..9a07cbb --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Api/Controllers/StatisticsController.cs @@ -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; + } + + /// + /// 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. + /// + [HttpPost("Matches")] + public async Task GetMatchStats([FromBody] List ids) + { + var stats = await _service.GetMatchStats(ids); + return _mapper.Map(stats); + } + } +} diff --git a/LaDOSE.Src/LaDOSE.Api/Controllers/UsersController.cs b/LaDOSE.Src/LaDOSE.Api/Controllers/UsersController.cs index 78b0dae..1c3f392 100644 --- a/LaDOSE.Src/LaDOSE.Api/Controllers/UsersController.cs +++ b/LaDOSE.Src/LaDOSE.Api/Controllers/UsersController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Linq; @@ -9,6 +9,7 @@ using LaDOSE.Business.Interface; using LaDOSE.DTO; using LaDOSE.Entity; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; @@ -33,12 +34,32 @@ namespace LaDOSE.Api.Controllers _configuration = configuration; } + /// Public view of a user: no password, no hash, no salt. + private static ApplicationUserDTO ToDto(ApplicationUser user) + { + return new ApplicationUserDTO + { + Id = user.Id, + Username = user.Username, + FirstName = user.FirstName, + LastName = user.LastName, + Roles = user.Names() + }; + } + + /// The id the JWT was issued for; null if the request is not authenticated. + private int? CurrentUserId() + { + return int.TryParse(User?.Identity?.Name, out var id) ? id : (int?)null; + } [AllowAnonymous] [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) return BadRequest(new { message = "Username or password is incorrect" }); @@ -47,10 +68,13 @@ namespace LaDOSE.Api.Controllers var key = Encoding.ASCII.GetBytes(this._configuration["JWTTokenSecret"]); 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[] { new Claim(ClaimTypes.Name, user.Id.ToString()), - + }), Expires = DateTime.UtcNow.AddMinutes(16), @@ -60,29 +84,62 @@ namespace LaDOSE.Api.Controllers var tokenString = tokenHandler.WriteToken(token); // return basic user info (without password) and token to store client side - return Ok(new ApplicationUserDTO - { - Id = user.Id, - Username = user.Username, - FirstName = user.FirstName, - LastName = user.LastName, - Token = tokenString, - Expire = token.ValidTo - }); + var dto = ToDto(user); + dto.Token = tokenString; + dto.Expire = token.ValidTo; + return Ok(dto); } - [AllowAnonymous] - [HttpPost("register")] - public IActionResult Register([FromBody]ApplicationUser userDto) + /// Every account, for the admin user-management screen. + [Authorize(Roles = Roles.Admin)] + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public IActionResult GetUsers() { - // map dto to entity + var users = _userService.GetAll() + .OrderBy(user => user.Username) + .Select(ToDto) + .ToList(); + return Ok(users); + } + + /// The role names that may be assigned, from the applicationrole table. + [Authorize(Roles = Roles.Admin)] + [HttpGet("Roles")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public IActionResult GetRoles() + { + return Ok(_userService.GetAllRoles().Select(role => role.Name).ToList()); + } + + /// + /// Creates an account. This replaces the old anonymous register 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). + /// + [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 { - // save - _userService.Create(userDto, userDto.Password); - return Ok(); + var created = _userService.Create( + new ApplicationUser + { + Username = userDto.Username?.Trim(), + FirstName = userDto.FirstName, + LastName = userDto.LastName + }, + userDto.Password, + userDto.Roles); + + return Ok(ToDto(created)); } catch (Exception ex) { @@ -91,7 +148,35 @@ namespace LaDOSE.Api.Controllers } } + /// + /// 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. + /// + [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 }); + } + } } } diff --git a/LaDOSE.Src/LaDOSE.Api/Helpers/ApiExplorerVisibilityConvention.cs b/LaDOSE.Src/LaDOSE.Api/Helpers/ApiExplorerVisibilityConvention.cs new file mode 100644 index 0000000..c56fdca --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Api/Helpers/ApiExplorerVisibilityConvention.cs @@ -0,0 +1,41 @@ +#if DEBUG +using System.Linq; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace LaDOSE.Api.Helpers +{ + /// + /// 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. + /// + 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() + .Any(provider => provider.HttpMethods?.Any() == true); + } + } + } +} +#endif diff --git a/LaDOSE.Src/LaDOSE.Api/LaDOSE.Api.csproj b/LaDOSE.Src/LaDOSE.Api/LaDOSE.Api.csproj index cb92e74..b2e6f39 100644 --- a/LaDOSE.Src/LaDOSE.Api/LaDOSE.Api.csproj +++ b/LaDOSE.Src/LaDOSE.Api/LaDOSE.Api.csproj @@ -1,7 +1,7 @@  - net8.0 + net9.0 AnyCPU;x64 12 @@ -15,6 +15,9 @@ + + + diff --git a/LaDOSE.Src/LaDOSE.Api/Startup.cs b/LaDOSE.Src/LaDOSE.Api/Startup.cs index 70ba219..5647462 100644 --- a/LaDOSE.Src/LaDOSE.Api/Startup.cs +++ b/LaDOSE.Src/LaDOSE.Api/Startup.cs @@ -1,5 +1,6 @@ using System; using System.Reflection; +using System.Security.Claims; using System.Text; using System.Threading.Tasks; using LaDOSE.Business.Interface; @@ -25,6 +26,9 @@ using Result = LaDOSE.Entity.Challonge.Result; using LaDOSE.Entity.BotEvent; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Hosting; +#if DEBUG +using Scalar.AspNetCore; +#endif namespace LaDOSE.Api { @@ -55,11 +59,21 @@ namespace LaDOSE.Api } 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.MaxDepth= 4; }); +#if DEBUG + services.AddOpenApi(); +#endif // services.AddDbContextPool( // 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 @@ -92,6 +106,18 @@ namespace LaDOSE.Api { // return unauthorized if user no longer exists 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; @@ -130,6 +156,13 @@ namespace LaDOSE.Api cfg.CreateMapTwoWay(); cfg.CreateMapTwoWay(); + // Match statistics: plain POCO aggregates computed by StatisticsService, + // mapped by name (same pattern as TournamentsResult above). + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + }); IMapper mapper = mapperConfig.CreateMapper(); services.AddSingleton(mapper); @@ -149,6 +182,7 @@ namespace LaDOSE.Api services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddTransient(p => new ChallongeProvider( p.GetRequiredService(), p.GetRequiredService(), p.GetRequiredService(), @@ -185,7 +219,17 @@ namespace LaDOSE.Api app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); - app.UseEndpoints(x => x.MapControllers()); + app.UseEndpoints(x => + { + x.MapControllers(); +#if DEBUG + if (env.IsDevelopment()) + { + x.MapOpenApi(); + x.MapScalarApiReference(); + } +#endif + }); } } } diff --git a/LaDOSE.Src/LaDOSE.DTO/ApplicationUserDTO.cs b/LaDOSE.Src/LaDOSE.DTO/ApplicationUserDTO.cs index 30ba9a0..069029e 100644 --- a/LaDOSE.Src/LaDOSE.DTO/ApplicationUserDTO.cs +++ b/LaDOSE.Src/LaDOSE.DTO/ApplicationUserDTO.cs @@ -1,5 +1,6 @@  using System; +using System.Collections.Generic; namespace LaDOSE.DTO { @@ -9,8 +10,13 @@ namespace LaDOSE.DTO public string FirstName { get; set; } public string LastName { get; set; } public string Username { get; set; } + + /// Only ever read from a request; never populated on a response. public string Password { get; set; } + /// Role names held by the user, e.g. ["Admin"]. Empty means a plain user. + public List Roles { get; set; } + public string Token { get; set; } public DateTime Expire { get; set; } } diff --git a/LaDOSE.Src/LaDOSE.DTO/EventDTO.cs b/LaDOSE.Src/LaDOSE.DTO/EventDTO.cs index 8cd29a0..4afa823 100644 --- a/LaDOSE.Src/LaDOSE.DTO/EventDTO.cs +++ b/LaDOSE.Src/LaDOSE.DTO/EventDTO.cs @@ -1,8 +1,13 @@ -namespace LaDOSE.DTO +using System; + +namespace LaDOSE.DTO { public class EventDTO { public int Id { get; set; } public string Name { get; set; } + + /// Event date, mapped by convention from Event.Date. Used for time-series charts. + public DateTime Date { get; set; } }; } \ No newline at end of file diff --git a/LaDOSE.Src/LaDOSE.DTO/LaDOSE.DTO.csproj b/LaDOSE.Src/LaDOSE.DTO/LaDOSE.DTO.csproj index 7532cee..30e2de5 100644 --- a/LaDOSE.Src/LaDOSE.DTO/LaDOSE.DTO.csproj +++ b/LaDOSE.Src/LaDOSE.DTO/LaDOSE.DTO.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 AnyCPU;x64 diff --git a/LaDOSE.Src/LaDOSE.DTO/MatchStatsDTO.cs b/LaDOSE.Src/LaDOSE.DTO/MatchStatsDTO.cs new file mode 100644 index 0000000..e251068 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.DTO/MatchStatsDTO.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; + +namespace LaDOSE.DTO +{ + public class MatchStatsDTO + { + public MatchCoverageDTO Coverage { get; set; } + public List Players { get; set; } + public List HeadToHead { get; set; } + } + + public class MatchCoverageDTO + { + /// Requested event ids that actually exist. + public int Events { get; set; } + + /// Tournaments (brackets) belonging to those events. + public int Brackets { get; set; } + + /// Of those brackets, how many have at least one set row. + public int BracketsWithSets { get; set; } + + /// Total set rows in scope, including unusable ones. + public int Sets { get; set; } + + /// Sets with a determinable winner. + public int DecidedSets { get; set; } + } + + public class PlayerMatchStatsDTO + { + public int PlayerId { get; set; } + + /// Gamertag, falling back to Name, else "#<id>". + public string Player { get; set; } + + /// Decided sets only. Always equals Wins + Losses. + 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; } + } +} diff --git a/LaDOSE.Src/LaDOSE.DesktopApp.Avalonia/LaDOSE.DesktopApp.Avalonia.csproj b/LaDOSE.Src/LaDOSE.DesktopApp.Avalonia/LaDOSE.DesktopApp.Avalonia.csproj index f23ba79..a30e23e 100644 --- a/LaDOSE.Src/LaDOSE.DesktopApp.Avalonia/LaDOSE.DesktopApp.Avalonia.csproj +++ b/LaDOSE.Src/LaDOSE.DesktopApp.Avalonia/LaDOSE.DesktopApp.Avalonia.csproj @@ -1,7 +1,7 @@  WinExe - net8.0 + net9.0 enable true app.manifest diff --git a/LaDOSE.Src/LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj b/LaDOSE.Src/LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj index ebcfbfd..a44ba00 100644 --- a/LaDOSE.Src/LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj +++ b/LaDOSE.Src/LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net9.0 AnyCPU;x64 diff --git a/LaDOSE.Src/LaDOSE.Entity/ApplicationRole.cs b/LaDOSE.Src/LaDOSE.Entity/ApplicationRole.cs new file mode 100644 index 0000000..f40b9bf --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Entity/ApplicationRole.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace LaDOSE.Entity +{ + /// + /// A role a user can hold, stored in the pre-existing applicationrole table. + /// Rows are reference data seeded by Sql/2026-08-05_roles.sql, not created at runtime. + /// + public class ApplicationRole + { + public int Id { get; set; } + + [Required] + [MaxLength(50)] + public string Name { get; set; } + } +} diff --git a/LaDOSE.Src/LaDOSE.Entity/ApplicationUser.cs b/LaDOSE.Src/LaDOSE.Entity/ApplicationUser.cs index 2091b39..ba0c537 100644 --- a/LaDOSE.Src/LaDOSE.Entity/ApplicationUser.cs +++ b/LaDOSE.Src/LaDOSE.Entity/ApplicationUser.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; @@ -14,6 +15,14 @@ namespace LaDOSE.Entity public string Password { get; set; } public byte[] PasswordHash { get; set; } public byte[] PasswordSalt { get; set; } + + /// + /// Rows of the applicationuserrole 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 / helpers. + /// + public List UserRoles { get; set; } } } \ No newline at end of file diff --git a/LaDOSE.Src/LaDOSE.Entity/ApplicationUserRole.cs b/LaDOSE.Src/LaDOSE.Entity/ApplicationUserRole.cs new file mode 100644 index 0000000..8453443 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Entity/ApplicationUserRole.cs @@ -0,0 +1,16 @@ +namespace LaDOSE.Entity +{ + /// + /// The applicationuserrole join table. Declared explicitly rather than left + /// implicit so its columns match the table that already exists in the database + /// (userid, roleid, no surrogate key). + /// + public class ApplicationUserRole + { + public int UserId { get; set; } + public ApplicationUser User { get; set; } + + public int RoleId { get; set; } + public ApplicationRole Role { get; set; } + } +} diff --git a/LaDOSE.Src/LaDOSE.Entity/Context/LaDOSEDbContext.cs b/LaDOSE.Src/LaDOSE.Entity/Context/LaDOSEDbContext.cs index 3572ed7..8a29cb6 100644 --- a/LaDOSE.Src/LaDOSE.Entity/Context/LaDOSEDbContext.cs +++ b/LaDOSE.Src/LaDOSE.Entity/Context/LaDOSEDbContext.cs @@ -9,6 +9,7 @@ namespace LaDOSE.Entity.Context { public DbSet Game { get; set; } public DbSet ApplicationUser { get; set; } + public DbSet ApplicationRole { get; set; } public DbSet Todo { get; set; } @@ -49,6 +50,27 @@ namespace LaDOSE.Entity.Context 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(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() .HasMany(s => s.Tournaments); diff --git a/LaDOSE.Src/LaDOSE.Entity/LaDOSE.Entity.csproj b/LaDOSE.Src/LaDOSE.Entity/LaDOSE.Entity.csproj index e0fff87..11a1002 100644 --- a/LaDOSE.Src/LaDOSE.Entity/LaDOSE.Entity.csproj +++ b/LaDOSE.Src/LaDOSE.Entity/LaDOSE.Entity.csproj @@ -2,7 +2,7 @@ AnyCPU;x64 - net8.0 + net9.0 diff --git a/LaDOSE.Src/LaDOSE.Entity/Roles.cs b/LaDOSE.Src/LaDOSE.Entity/Roles.cs new file mode 100644 index 0000000..69b87c7 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Entity/Roles.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LaDOSE.Entity +{ + /// + /// The role names the API knows about. They must exist as rows in + /// applicationrole — see Sql/2026-08-05_roles.sql. + /// + public static class Roles + { + /// May manage user accounts. + public const string Admin = "Admin"; + + /// May use everything else. The absence of a role means the same thing. + public const string User = "User"; + + public static readonly string[] All = { Admin, User }; + + /// Case-insensitive, so 'admin' typed into the SQL seed still counts. + public static bool IsAdmin(this ApplicationUser user) + { + return user.Names().Any(name => Admin.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// 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. + /// + public static List Names(this ApplicationUser user) + { + return user?.UserRoles? + .Select(userRole => userRole.Role?.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .OrderBy(name => name) + .ToList() + ?? new List(); + } + } +} diff --git a/LaDOSE.Src/LaDOSE.Entity/TournamentEntities/MatchStats.cs b/LaDOSE.Src/LaDOSE.Entity/TournamentEntities/MatchStats.cs new file mode 100644 index 0000000..5fdd954 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Entity/TournamentEntities/MatchStats.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; + +namespace LaDOSE.Entity +{ + /// + /// Aggregate over the persisted 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 + /// ). + /// Property names must stay identical to the DTO's, the mapping is by convention. + /// + public class MatchStats + { + /// How much of the requested scope actually has set data behind it. + public MatchCoverage Coverage { get; set; } = new MatchCoverage(); + + public List Players { get; set; } = new List(); + + public List HeadToHead { get; set; } = new List(); + } + + /// + /// 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. + /// + public class MatchCoverage + { + /// Requested event ids that actually exist. + public int Events { get; set; } + + /// Tournaments (brackets) belonging to those events. + public int Brackets { get; set; } + + /// Of those brackets, how many have at least one set row. + public int BracketsWithSets { get; set; } + + /// Total set rows in scope, including the ones skipped as unusable. + public int Sets { get; set; } + + /// Sets with a determinable winner (unequal scores, two distinct real players). + public int DecidedSets { get; set; } + } + + public class PlayerMatchStats + { + public int PlayerId { get; set; } + + /// Gamertag, falling back to Name, else "#<id>". + public string Player { get; set; } + + /// Decided sets only. Always equals Wins + Losses. + 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; } + } + + /// + /// One row per unordered pair of players with at least one decided set. + /// A is always the lower PlayerId so WinsA / WinsB are unambiguous. + /// + 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; } + } +} diff --git a/LaDOSE.Src/LaDOSE.REST/LaDOSE.REST.csproj b/LaDOSE.Src/LaDOSE.REST/LaDOSE.REST.csproj index 13ceb2c..e0cc132 100644 --- a/LaDOSE.Src/LaDOSE.REST/LaDOSE.REST.csproj +++ b/LaDOSE.Src/LaDOSE.REST/LaDOSE.REST.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 AnyCPU;x64 12 diff --git a/LaDOSE.Src/LaDOSE.Service/Interface/IStatisticsService.cs b/LaDOSE.Src/LaDOSE.Service/Interface/IStatisticsService.cs new file mode 100644 index 0000000..a71ff05 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Service/Interface/IStatisticsService.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using LaDOSE.Entity; + +namespace LaDOSE.Business.Interface +{ + public interface IStatisticsService + { + /// + /// 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 . + /// + Task GetMatchStats(List eventIds); + } +} diff --git a/LaDOSE.Src/LaDOSE.Service/Interface/IUserService.cs b/LaDOSE.Src/LaDOSE.Service/Interface/IUserService.cs index 6e501b4..573a814 100644 --- a/LaDOSE.Src/LaDOSE.Service/Interface/IUserService.cs +++ b/LaDOSE.Src/LaDOSE.Service/Interface/IUserService.cs @@ -8,8 +8,11 @@ namespace LaDOSE.Business.Interface ApplicationUser Authenticate(string username, string password); IEnumerable GetAll(); ApplicationUser GetById(int id); - ApplicationUser Create(ApplicationUser user, string password); + ApplicationUser Create(ApplicationUser user, string password, IEnumerable roleNames = null); void Update(ApplicationUser user, string password = null); void Delete(int id); + + /// The roles that exist in the database — reference data, not created at runtime. + IEnumerable GetAllRoles(); } } \ No newline at end of file diff --git a/LaDOSE.Src/LaDOSE.Service/LaDOSE.Business.csproj b/LaDOSE.Src/LaDOSE.Service/LaDOSE.Business.csproj index 3b99468..2db5331 100644 --- a/LaDOSE.Src/LaDOSE.Service/LaDOSE.Business.csproj +++ b/LaDOSE.Src/LaDOSE.Service/LaDOSE.Business.csproj @@ -1,7 +1,7 @@  - net8.0 + net9.0 LaDOSE.Business LaDOSE.Business AnyCPU;x64 diff --git a/LaDOSE.Src/LaDOSE.Service/Service/StatisticsService.cs b/LaDOSE.Src/LaDOSE.Service/Service/StatisticsService.cs new file mode 100644 index 0000000..173db8e --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Service/Service/StatisticsService.cs @@ -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 +{ + /// + /// 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: + /// issues one query per table and hands the loaded lists to the pure static + /// , which is unit-testable without a database. + /// + public class StatisticsService : IStatisticsService + { + protected LaDOSEDbContext _context; + + public StatisticsService(LaDOSEDbContext context) + { + _context = context; + } + + public Task GetMatchStats(List eventIds) + { + var requested = (eventIds ?? new List()).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() + : _context.Tournament + .Where(t => existingEventIds.Contains(t.EventId)) + .ToList(); + + var tournamentIds = tournaments.Select(t => t.Id).Distinct().ToList(); + + // _context.Set is the DbSet property, not DbContext.Set(). + var sets = tournamentIds.Count == 0 + ? new List() + : _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() + : _context.Player + .Where(p => playerIds.Contains(p.Id)) + .ToList(); + + return Task.FromResult(Aggregate(requested, events, tournaments, sets, players)); + } + + /// + /// Pure aggregation over already-loaded rows. No database, no I/O, deterministic. + /// + /// The event ids the caller asked for. + /// Events that exist (used for the coverage count). + /// Candidate tournaments; filtered here on EventId. + /// Candidate sets; filtered here on TournamentId. + /// Players used to resolve display names; may be incomplete. + public static MatchStats Aggregate( + IEnumerable requestedEventIds, + IEnumerable events, + IEnumerable tournaments, + IEnumerable sets, + IEnumerable players) + { + var result = new MatchStats(); + + var requested = new HashSet((requestedEventIds ?? Enumerable.Empty())); + if (requested.Count == 0) + { + return result; + } + + // Coverage: events ------------------------------------------------------- + var existingEventIds = new HashSet((events ?? Enumerable.Empty()) + .Where(e => e != null && requested.Contains(e.Id)) + .Select(e => e.Id)); + result.Coverage.Events = existingEventIds.Count; + + // Coverage: brackets ---------------------------------------------------- + var scopedTournamentIds = new HashSet((tournaments ?? Enumerable.Empty()) + .Where(t => t != null && existingEventIds.Contains(t.EventId)) + .Select(t => t.Id)); + result.Coverage.Brackets = scopedTournamentIds.Count; + + var scopedSets = (sets ?? Enumerable.Empty()) + .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(); + foreach (var player in (players ?? Enumerable.Empty()).Where(p => p != null)) + { + nameById[player.Id] = DisplayName(player); + } + + var stats = new Dictionary(); + 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 stats, int playerId, + Dictionary nameById) + { + if (!stats.TryGetValue(playerId, out var stat)) + { + stat = new PlayerMatchStats + { + PlayerId = playerId, + Player = ResolveName(playerId, nameById) + }; + stats[playerId] = stat; + } + + return stat; + } + + /// + /// 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). + /// + private static string ResolveName(int playerId, Dictionary 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}"; + } + } +} diff --git a/LaDOSE.Src/LaDOSE.Service/Service/UserService.cs b/LaDOSE.Src/LaDOSE.Service/Service/UserService.cs index a174c08..f2b8a5d 100644 --- a/LaDOSE.Src/LaDOSE.Service/Service/UserService.cs +++ b/LaDOSE.Src/LaDOSE.Service/Service/UserService.cs @@ -4,6 +4,7 @@ using System.Linq; using LaDOSE.Business.Interface; using LaDOSE.Entity; using LaDOSE.Entity.Context; +using Microsoft.EntityFrameworkCore; namespace LaDOSE.Business.Service { @@ -20,8 +21,9 @@ namespace LaDOSE.Business.Service { if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) return null; - var p = _context.ApplicationUser.ToList(); - var user = _context.ApplicationUser.SingleOrDefault(x => x.Username == username); + var user = _context.ApplicationUser + .Include(x => x.UserRoles).ThenInclude(ur => ur.Role) + .SingleOrDefault(x => x.Username == username); // check if username exists if (user == null) @@ -37,23 +39,44 @@ namespace LaDOSE.Business.Service public IEnumerable GetAll() { - return _context.ApplicationUser; + return _context.ApplicationUser + .Include(x => x.UserRoles).ThenInclude(ur => ur.Role) + .ToList(); } + /// + /// Roles are included because authorization reads them on every authenticated + /// request (see the OnTokenValidated handler in Startup). + /// 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 GetAllRoles() + { + return _context.ApplicationRole.OrderBy(x => x.Name).ToList(); + } + + public ApplicationUser Create(ApplicationUser user, string password, IEnumerable roleNames = null) { // validation + if (string.IsNullOrWhiteSpace(user?.Username)) + throw new Exception("Username is required"); + if (string.IsNullOrWhiteSpace(password)) throw new Exception("Password is required"); if (_context.ApplicationUser.Any(x => x.Username == user.Username)) 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; CreatePasswordHash(password, out passwordHash, out passwordSalt); @@ -66,6 +89,35 @@ namespace LaDOSE.Business.Service return user; } + /// + /// Turns role names into the existing rows of applicationrole. 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". + /// + private List ResolveRoles(IEnumerable roleNames) + { + var wanted = (roleNames ?? Enumerable.Empty()) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Select(name => name.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (wanted.Count == 0) + return new List(); + + var known = _context.ApplicationRole.ToList(); + var resolved = new List(); + 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) { var user = _context.ApplicationUser.Find(userParam.Id); @@ -101,12 +153,18 @@ namespace LaDOSE.Business.Service public void Delete(int id) { - var user = _context.ApplicationUser.Find(id); - if (user != null) - { - _context.ApplicationUser.Remove(user); - _context.SaveChanges(); - } + // applicationuserrole's foreign keys are ON DELETE RESTRICT, so the join rows + // have to go first — clearing the collection makes EF delete them. + var user = _context.ApplicationUser + .Include(x => x.UserRoles).ThenInclude(ur => ur.Role) + .SingleOrDefault(x => x.Id == id); + + if (user == null) + return; + + user.UserRoles?.Clear(); + _context.ApplicationUser.Remove(user); + _context.SaveChanges(); } // private helper methods diff --git a/LaDOSE.Src/LinuxTest/LinuxTest.csproj b/LaDOSE.Src/LinuxTest/LinuxTest.csproj index 3f8a58b..671119f 100644 --- a/LaDOSE.Src/LinuxTest/LinuxTest.csproj +++ b/LaDOSE.Src/LinuxTest/LinuxTest.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net9.0 enable enable 12