Update to dotnet 9.0, add user roles, MatchStats and OpenApi/Scalar in dev
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutoMapper;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.DTO;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LaDOSE.Api.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
[Route("api/[controller]")]
|
||||
public class StatisticsController : Controller
|
||||
{
|
||||
private readonly IStatisticsService _service;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public StatisticsController(IMapper mapper, IStatisticsService service)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Match statistics for the given Event ids. Body is a bare JSON array of ids,
|
||||
/// like TournamentController.GetResults.
|
||||
/// A null or empty array returns a zeroed, empty MatchStatsDTO rather than an error.
|
||||
/// Read Coverage before trusting the numbers: many brackets have no set rows at all.
|
||||
/// </summary>
|
||||
[HttpPost("Matches")]
|
||||
public async Task<MatchStatsDTO> GetMatchStats([FromBody] List<int> ids)
|
||||
{
|
||||
var stats = await _service.GetMatchStats(ids);
|
||||
return _mapper.Map<MatchStatsDTO>(stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.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
|
||||
@@ -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" />
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LaDOSE.DTO
|
||||
{
|
||||
public class MatchStatsDTO
|
||||
{
|
||||
public MatchCoverageDTO Coverage { get; set; }
|
||||
public List<PlayerMatchStatsDTO> Players { get; set; }
|
||||
public List<HeadToHeadDTO> HeadToHead { get; set; }
|
||||
}
|
||||
|
||||
public class MatchCoverageDTO
|
||||
{
|
||||
/// <summary>Requested event ids that actually exist.</summary>
|
||||
public int Events { get; set; }
|
||||
|
||||
/// <summary>Tournaments (brackets) belonging to those events.</summary>
|
||||
public int Brackets { get; set; }
|
||||
|
||||
/// <summary>Of those brackets, how many have at least one set row.</summary>
|
||||
public int BracketsWithSets { get; set; }
|
||||
|
||||
/// <summary>Total set rows in scope, including unusable ones.</summary>
|
||||
public int Sets { get; set; }
|
||||
|
||||
/// <summary>Sets with a determinable winner.</summary>
|
||||
public int DecidedSets { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerMatchStatsDTO
|
||||
{
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||
public string Player { get; set; }
|
||||
|
||||
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
|
||||
public int Sets { get; set; }
|
||||
|
||||
public int Wins { get; set; }
|
||||
public int Losses { get; set; }
|
||||
public int GamesWon { get; set; }
|
||||
public int GamesLost { get; set; }
|
||||
}
|
||||
|
||||
public class HeadToHeadDTO
|
||||
{
|
||||
public int PlayerAId { get; set; }
|
||||
public string PlayerA { get; set; }
|
||||
public int PlayerBId { get; set; }
|
||||
public string PlayerB { get; set; }
|
||||
public int WinsA { get; set; }
|
||||
public int WinsB { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LaDOSE.Entity
|
||||
{
|
||||
/// <summary>
|
||||
/// The role names the API knows about. They must exist as rows in
|
||||
/// <c>applicationrole</c> — see Sql/2026-08-05_roles.sql.
|
||||
/// </summary>
|
||||
public static class Roles
|
||||
{
|
||||
/// <summary>May manage user accounts.</summary>
|
||||
public const string Admin = "Admin";
|
||||
|
||||
/// <summary>May use everything else. The absence of a role means the same thing.</summary>
|
||||
public const string User = "User";
|
||||
|
||||
public static readonly string[] All = { Admin, User };
|
||||
|
||||
/// <summary>Case-insensitive, so 'admin' typed into the SQL seed still counts.</summary>
|
||||
public static bool IsAdmin(this ApplicationUser user)
|
||||
{
|
||||
return user.Names().Any(name => Admin.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The user's role names. Empty when the user holds no role, and also when the
|
||||
/// query did not include them — callers that authorize must load them.
|
||||
/// </summary>
|
||||
public static List<string> Names(this ApplicationUser user)
|
||||
{
|
||||
return user?.UserRoles?
|
||||
.Select(userRole => userRole.Role?.Name)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.OrderBy(name => name)
|
||||
.ToList()
|
||||
?? new List<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LaDOSE.Entity
|
||||
{
|
||||
/// <summary>
|
||||
/// Aggregate over the persisted <see cref="Set"/> rows of one or more Events.
|
||||
/// Not an entity: it is never mapped by EF, it is computed in memory by
|
||||
/// StatisticsService and mapped to MatchStatsDTO by AutoMapper (same pattern as
|
||||
/// <see cref="Challonge.TournamentsResult"/>).
|
||||
/// Property names must stay identical to the DTO's, the mapping is by convention.
|
||||
/// </summary>
|
||||
public class MatchStats
|
||||
{
|
||||
/// <summary>How much of the requested scope actually has set data behind it.</summary>
|
||||
public MatchCoverage Coverage { get; set; } = new MatchCoverage();
|
||||
|
||||
public List<PlayerMatchStats> Players { get; set; } = new List<PlayerMatchStats>();
|
||||
|
||||
public List<HeadToHead> HeadToHead { get; set; } = new List<HeadToHead>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set coverage is sparse: Challonge-imported events have no sets at all, and events
|
||||
/// imported before SmashProvider.GetSets existed were never backfilled. These counters
|
||||
/// let a caller say "12 of 40 brackets have match data" instead of implying completeness.
|
||||
/// </summary>
|
||||
public class MatchCoverage
|
||||
{
|
||||
/// <summary>Requested event ids that actually exist.</summary>
|
||||
public int Events { get; set; }
|
||||
|
||||
/// <summary>Tournaments (brackets) belonging to those events.</summary>
|
||||
public int Brackets { get; set; }
|
||||
|
||||
/// <summary>Of those brackets, how many have at least one set row.</summary>
|
||||
public int BracketsWithSets { get; set; }
|
||||
|
||||
/// <summary>Total set rows in scope, including the ones skipped as unusable.</summary>
|
||||
public int Sets { get; set; }
|
||||
|
||||
/// <summary>Sets with a determinable winner (unequal scores, two distinct real players).</summary>
|
||||
public int DecidedSets { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerMatchStats
|
||||
{
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||
public string Player { get; set; }
|
||||
|
||||
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
|
||||
public int Sets { get; set; }
|
||||
|
||||
public int Wins { get; set; }
|
||||
public int Losses { get; set; }
|
||||
public int GamesWon { get; set; }
|
||||
public int GamesLost { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row per unordered pair of players with at least one decided set.
|
||||
/// A is always the lower PlayerId so WinsA / WinsB are unambiguous.
|
||||
/// </summary>
|
||||
public class HeadToHead
|
||||
{
|
||||
public int PlayerAId { get; set; }
|
||||
public string PlayerA { get; set; }
|
||||
public int PlayerBId { get; set; }
|
||||
public string PlayerB { get; set; }
|
||||
public int WinsA { get; set; }
|
||||
public int WinsB { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<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 "#<id>" (a set can reference a player row
|
||||
/// that no longer exists, and Gamertag is nullable in the database).
|
||||
/// </summary>
|
||||
private static string ResolveName(int playerId, Dictionary<int, string> nameById)
|
||||
{
|
||||
return nameById.TryGetValue(playerId, out var name) ? name : $"#{playerId}";
|
||||
}
|
||||
|
||||
private static string DisplayName(Player player)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(player.Gamertag))
|
||||
{
|
||||
return player.Gamertag;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(player.Name))
|
||||
{
|
||||
return player.Name;
|
||||
}
|
||||
|
||||
return $"#{player.Id}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user