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,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; }
}
}