Update to dotnet 9.0, add user roles, MatchStats and OpenApi/Scalar in dev

This commit is contained in:
2026-08-06 09:56:07 +02:00
parent e10663c8c0
commit d9e05fb487
25 changed files with 844 additions and 44 deletions
@@ -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.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;
}
/// <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]
[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,6 +68,9 @@ 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()),
@@ -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)
/// <summary>Every account, for the admin user-management screen.</summary>
[Authorize(Roles = Roles.Admin)]
[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
{
// 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
}
}
/// <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
+4 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
<LangVersion>12</LangVersion>
</PropertyGroup>
@@ -15,6 +15,9 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" 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.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="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
+46 -2
View File
@@ -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<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
@@ -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<Game, LaDOSE.DTO.GameDTO>();
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();
services.AddSingleton(mapper);
@@ -149,6 +182,7 @@ namespace LaDOSE.Api
services.AddScoped<IBotEventService, BotEventService>();
services.AddScoped<IPlayerService, PlayerService>();
services.AddScoped<IStatisticsService, StatisticsService>();
services.AddTransient<IChallongeProvider>(p => new ChallongeProvider( p.GetRequiredService<IGameService>(),
p.GetRequiredService<IEventService>(),
p.GetRequiredService<IPlayerService>(),
@@ -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
});
}
}
}
@@ -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; }
/// <summary>Only ever read from a request; never populated on a response.</summary>
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 DateTime Expire { get; set; }
}
+6 -1
View File
@@ -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; }
/// <summary>Event date, mapped by convention from Event.Date. Used for time-series charts.</summary>
public DateTime Date { get; set; }
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
+55
View File
@@ -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 "#&lt;id&gt;".</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">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
</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; }
}
}
+10 -1
View File
@@ -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; }
/// <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<ApplicationUser> ApplicationUser { get; set; }
public DbSet<ApplicationRole> ApplicationRole { get; set; }
public DbSet<Todo> 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<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>()
.HasMany(s => s.Tournaments);
@@ -2,7 +2,7 @@
<PropertyGroup>
<Platforms>AnyCPU;x64</Platforms>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
+41
View File
@@ -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 "#&lt;id&gt;".</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 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
<LangVersion>12</LangVersion>
</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);
IEnumerable<ApplicationUser> GetAll();
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 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">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<AssemblyName>LaDOSE.Business</AssemblyName>
<RootNamespace>LaDOSE.Business</RootNamespace>
<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 "#&lt;id&gt;" (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.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<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)
{
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
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;
}
/// <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)
{
var user = _context.ApplicationUser.Find(userParam.Id);
@@ -101,13 +153,19 @@ namespace LaDOSE.Business.Service
public void Delete(int id)
{
var user = _context.ApplicationUser.Find(id);
if (user != null)
{
// 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
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12</LangVersion>