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