12 Commits
Author SHA1 Message Date
darkstack f934e69c90 Fix code smells
Build App / Build (push) Failing after 2s
2026-08-07 13:22:53 +02:00
darkstack 937b8554dd Docker Compose bot + Google Api
Build App / Build (push) Failing after 3s
2026-08-06 16:23:50 +02:00
darkstack c9a3c252e1 Docker compose / Fix build ?
Build App / Build (push) Failing after 2s
2026-08-06 11:00:55 +02:00
darkstack a9860f4c94 Added this stupid vibecoded app as a test
Build App / Build (push) Canceled after 0s
2026-08-06 09:58:06 +02:00
darkstack d9e05fb487 Update to dotnet 9.0, add user roles, MatchStats and OpenApi/Scalar in dev 2026-08-06 09:56:07 +02:00
darkstack e10663c8c0 Update Readme
Build App / Build (push) Successful in 1m31s
2025-03-08 01:17:53 +01:00
darkstack cd03b39c20 Fix Discord Intents
Build App / Build (push) Successful in 2m14s
2025-03-07 15:14:38 +01:00
darkstack 9d8a7b3100 Fix DiscordBot
Build App / Build (push) Successful in 1m41s
2025-03-07 14:55:00 +01:00
darkstack e99479d8fb Fix Tags
Build App / Build (push) Successful in 2m2s
2025-03-07 13:15:11 +01:00
darkstack a9150ff58c URL Api Smash + Slug in HTML
Build App / Build (push) Successful in 1m36s
2025-03-07 10:56:24 +01:00
darkstack fba822a0af Build
Build App / Build (push) Successful in 1m40s
2025-02-09 22:39:17 +01:00
darkstack 73407e5867 Dot net 8
Build App / Build (push) Failing after 43s
2025-02-09 22:30:00 +01:00
133 changed files with 17650 additions and 286 deletions
+83
View File
@@ -0,0 +1,83 @@
# Copy to .env next to docker-compose.yml and edit. Compose reads it automatically;
# git ignores .env. Every value here is optional — the defaults in docker-compose.yml
# are what you get without it.
# --- Ports on your machine -----------------------------------------------------------
# Container-side ports are fixed (API 5000, nginx 80); only these move.
#LADOSE_WEB_PORT=8080
#LADOSE_API_PORT=5000
# --- Database ------------------------------------------------------------------------
# Overrides ConnectionStrings:DbContext from LaDOSE.Api/appsettings.json. The stack has
# no Postgres of its own, so this has to point at a reachable server.
#
# Postgres on the machine running the containers:
#LADOSE_DB_CONNECTION=Host=host.docker.internal;Username=tom;Password=tom;Database=ladoseapi
#
# Postgres elsewhere on the LAN — check the name resolves *inside* the container
# (`docker compose run --rm api getent hosts kafka.local`) and use the IP if it does not:
#LADOSE_DB_CONNECTION=Host=kafka.local;Username=tom;Password=tom;Database=ladoseapi
#
# Sql/dump_20240316.sql then Sql/2026-08-05_roles.sql populate an empty database.
# --- Secrets -------------------------------------------------------------------------
# appsettings.json carries placeholders for these. Any value works for JWT locally, but
# it must be at least 32 characters: Startup.cs feeds it to HMAC-SHA256 as raw ASCII.
#LADOSE_JWT_SECRET=dev-only-secret-not-for-any-deployed-environment
#LADOSE_SMASH_API_KEY=
#LADOSE_CHALLONGE_API_KEY=
# --- Google Sheets export ------------------------------------------------------------
# Powers "Push to Google Sheets" next to Export CSV on /tournaments: writes the generated
# ranking table into one tab, named after the latest event in the selection.
#
# Writer: Disabled | Logging | ServiceAccount.
# Disabled the button says "not configured" and refuses (the default)
# Logging writes the payload to the API log instead of Google — use this to check
# a selection without touching a spreadsheet
# ServiceAccount writes for real
#LADOSE_SHEETS_WRITER=Disabled
#
# The target spreadsheet. THIS is the value to change each year when you start a new
# sheet — then share the new sheet with the service-account address below as Editor.
# It is the id from the sheet URL, not the whole URL:
# https://docs.google.com/spreadsheets/d/<THIS PART>/edit
#LADOSE_SHEETS_SPREADSHEET_ID=
#
# Path *inside the container* to the service-account JSON key. docker-compose.yml mounts
# ./secrets (git-ignored) at /run/secrets, so drop the key there and leave this as is.
#
# One-time Google setup: create a project, enable the Google Sheets API, create a service
# account, create a JSON key, save it as secrets/ladose-sheets-sa.json, then share the
# spreadsheet with the account's ...iam.gserviceaccount.com address as Editor. Only that
# last sharing step repeats when you switch to a new sheet.
#LADOSE_SHEETS_SA_CREDENTIALS_PATH=/run/secrets/ladose-sheets-sa.json
# --- Discord bot -----------------------------------------------------------------------
# LaDOSE.DiscordBot only runs when you ask for it: `docker compose --profile bot up`.
# These values are rendered into the container's settings.json at start — the bot reads
# that file and never the environment.
#
# From https://discord.com/developers/applications -> your application -> Bot -> Reset
# Token. Setting this is what enables the rendering; leave it commented out and the bot
# falls back to the placeholder settings.json baked into the image (which cannot connect).
# The bot also needs the MESSAGE CONTENT intent enabled on that same page: Program.cs asks
# for DiscordIntents.MessageContents and Discord refuses the connection otherwise.
#LADOSE_DISCORD_TOKEN=
#
# Where the bot finds LaDOSE.Api. Resolved inside the container, so this is the compose
# service name, not localhost, and LADOSE_API_PORT does not apply.
#LADOSE_BOT_REST_URL=http://api:5000
#
# A LaDOSE.Api login for the bot — the commands that read events and rankings authenticate
# with it. Without it those commands log "Unable to contact services" and the rest of the
# bot still works.
#LADOSE_BOT_REST_USER=
#LADOSE_BOT_REST_PASSWORD=
#
# Challonge is shared with the api service: LADOSE_CHALLONGE_API_KEY above feeds both.
# --- Frontend ------------------------------------------------------------------------
# Only needed if the API is not on http://localhost:${LADOSE_API_PORT}. Resolved by the
# browser, so container names like http://api:5000 will not work.
#LADOSE_API_BASE_URL=http://localhost:5000
+9 -4
View File
@@ -4,7 +4,7 @@ on: [push]
jobs: jobs:
Build: Build:
runs-on: ubuntu-latest-real runs-on: ubuntu-latest
steps: steps:
- name: Update - name: Update
run: | run: |
@@ -19,7 +19,7 @@ jobs:
uses: actions/setup-dotnet@v3 uses: actions/setup-dotnet@v3
with: with:
# Semantic version range syntax or exact version of a dotnet version # Semantic version range syntax or exact version of a dotnet version
dotnet-version: '6.x' dotnet-version: '8.x'
- run: echo "Build." - run: echo "Build."
- name: Check out repository code - name: Check out repository code
@@ -36,8 +36,8 @@ jobs:
dotnet build --configuration Release --os win LaDOSE.DesktopApp.Avalonia dotnet build --configuration Release --os win LaDOSE.DesktopApp.Avalonia
- name: Zip file - name: Zip file
run: | run: |
zip -rj build-winx64.zip ./LaDOSE.Src/LaDOSE.DesktopApp.Avalonia/bin/Release/net6.0/win-x64/ zip -rj build-winx64.zip ./LaDOSE.Src/LaDOSE.DesktopApp.Avalonia/bin/Release/net8.0/win-x64/
zip -rj build-linux64.zip ./LaDOSE.Src/LaDOSE.DesktopApp.Avalonia/bin/Release/net6.0/linux-x64/ zip -rj build-linux64.zip ./LaDOSE.Src/LaDOSE.DesktopApp.Avalonia/bin/Release/net8.0/linux-x64/
- name: Upload Artifact Windows - name: Upload Artifact Windows
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
@@ -54,10 +54,15 @@ jobs:
retention-days: 30 retention-days: 30
overwrite: true overwrite: true
- name: Get current date
id: date
run: echo "date=$(echo $(date +'%Y-%m-%d'))" >> $GITHUB_OUTPUT
- name: Release - name: Release
if: github.ref_name == 'master'
uses: akkuman/gitea-release-action@v1 uses: akkuman/gitea-release-action@v1
env: env:
with: with:
tag_name: release-${{ steps.date.outputs.date }}
files: |- files: |-
build-winx64.zip build-winx64.zip
build-linux64.zip build-linux64.zip
+14
View File
@@ -328,3 +328,17 @@ ASALocalRun/
# MFractors (Xamarin productivity tool) working folder # MFractors (Xamarin productivity tool) working folder
.mfractor/ .mfractor/
# Local docker-compose overrides: connection string, API keys, ports.
# .env.example is documentation and stays tracked.
.env
.env.*
!.env.example
docker-compose.override.yml
# Google service-account key for the Sheets export, mounted at /run/secrets.
# Full write access to the ranking spreadsheet — never commit it.
# `secrets/*`, not `secrets/`: excluding the directory itself would stop git from
# looking inside it at all, and the un-ignore below would never apply.
secrets/*
!secrets/.gitkeep
+22 -2
View File
@@ -1,2 +1,22 @@
*/*/bin* # Context for LaDOSE.Src/Dockerfile.
*/*/obj* #
# The previous patterns here were */*/bin* and */*/obj*, which matched nothing: this
# context is rooted at LaDOSE.Src, so build output sits one level down (LaDOSE.Api/bin),
# not two.
**/bin/
**/obj/
# The frontend is a separate image with its own context (LaDOSE.WebApp/Dockerfile).
# Its node_modules alone was adding ~118 MB to every API build.
LaDOSE.WebApp/node_modules/
LaDOSE.WebApp/build/
LaDOSE.WebApp/.svelte-kit/
# Local state and editor noise. Note Libraries/ is NOT excluded: LaDOSE.Business
# references ChallongeCSharpDriver.dll from there by HintPath.
.git
.vs/
.vscode/
.idea/
*.user
*.suo
+40 -11
View File
@@ -1,15 +1,44 @@
FROM microsoft/dotnet:sdk AS build-env # Builds the LaDOSE.Api image. Context is LaDOSE.Src (see .dockerignore next to this file).
#
# Only LaDOSE.Api is published. LaDOSE.linux.sln also carries the Avalonia desktop app,
# the Discord bot and LinuxTest, and `dotnet publish <sln> -o out` flattens every project
# into that one directory — which is why the previous version of this file copied from
# /app/LaDOSE.Api/out/ and found nothing there.
ARG DOTNET_VERSION=9.0
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
WORKDIR /src
# Debug is deliberate for local work, and docker-compose.yml passes it: the OpenAPI
# document and the Scalar UI are gated behind `#if DEBUG` in Startup.cs *and* behind
# Condition="'$(Configuration)' == 'Debug'" on their PackageReferences in
# LaDOSE.Api.csproj. A Release image therefore serves no /openapi/v1.json, which is
# exactly what LaDOSE.WebApp's `npm run api:sync` reads. Default stays Release.
ARG BUILD_CONFIGURATION=Release
# Project files first so this layer survives every .cs edit. Restore has to run under
# the same Configuration as the publish below, or the conditional PackageReferences
# above make the two disagree about which packages the assets file should contain.
COPY global.json ./
COPY LaDOSE.Api/LaDOSE.Api.csproj LaDOSE.Api/
COPY LaDOSE.DTO/LaDOSE.DTO.csproj LaDOSE.DTO/
COPY LaDOSE.Entity/LaDOSE.Entity.csproj LaDOSE.Entity/
COPY LaDOSE.Service/LaDOSE.Business.csproj LaDOSE.Service/
RUN dotnet restore LaDOSE.Api/LaDOSE.Api.csproj -p:Configuration=${BUILD_CONFIGURATION}
# Libraries/ChallongeCSharpDriver.dll is a HintPath reference from LaDOSE.Business,
# so the build needs the whole tree, not just the projects listed above.
COPY . .
RUN dotnet publish LaDOSE.Api/LaDOSE.Api.csproj -c ${BUILD_CONFIGURATION} --no-restore -o /app/out
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}
WORKDIR /app WORKDIR /app
COPY --from=build /app/out/ ./
# Copy everything else and build # Fixed in the image on purpose. Program.cs binds Kestrel from appsettings.json's
COPY . ./ # AllowedHosts/Port through a ConfigurationBuilder that reads *only* that file, so a
# Port env var would not move the listener — remap on the host side instead.
RUN dotnet publish LaDOSE.linux.sln -c Release -o out # Everything Startup.cs reads does honour env vars (ConnectionStrings__DbContext,
# ApiKey__SmashApiKey, ApiKey__ChallongeApiKey, JWTTokenSecret).
# Build runtime image
FROM microsoft/dotnet:aspnetcore-runtime
WORKDIR /app
COPY --from=build-env /app/LaDOSE.Api/out/ .
EXPOSE 5000 EXPOSE 5000
ENTRYPOINT ["dotnet", "LaDOSE.Api.dll"] ENTRYPOINT ["dotnet", "LaDOSE.Api.dll"]
@@ -0,0 +1,101 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
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.Logging;
namespace LaDOSE.Api.Controllers
{
/// <summary>
/// Pushes ranking tables into the club's Google Spreadsheet.
///
/// The tables arrive already built: the browser reuses the same buildRanking() that produces
/// the CSV export, so the sheet and the CSV cannot drift. This controller resolves the target
/// from configuration, validates, and relays — see SheetsExportService.
///
/// Returns IActionResult with explicit ProducesResponseType, like UsersController: the API has
/// no exception middleware, so an escaping throw reaches the browser as an HTML developer page
/// that the client can only report as a bare status.
/// </summary>
[Authorize]
[Produces("application/json")]
[Route("api/[controller]")]
public class SheetsController : ControllerBase
{
private readonly ISheetsExportService _service;
private readonly IMapper _mapper;
private readonly ILogger<SheetsController> _logger;
public SheetsController(IMapper mapper, ISheetsExportService service, ILogger<SheetsController> logger)
{
_mapper = mapper;
_service = service;
_logger = logger;
}
/// <summary>
/// Whether the export is usable and where it points, so the panel can render itself and
/// disable the button when the server is not set up. Carries no secret and no key path.
/// </summary>
[HttpGet("Config")]
[ProducesResponseType(typeof(SheetsConfigDTO), StatusCodes.Status200OK)]
public IActionResult GetConfig()
{
return Ok(_mapper.Map<SheetsConfigDTO>(_service.GetConfig()));
}
/// <summary>
/// Writes one tab per table, in the order given — oldest event first, each tab holding the
/// cumulative result up to and including its own event.
///
/// Existing tabs of the same name are cleared and rewritten in place, keeping their
/// formatting; anything typed into them by hand is lost. Tabs not named in the request are
/// never touched or deleted.
/// </summary>
[HttpPost("Export")]
[ProducesResponseType(typeof(SheetExportResultDTO), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status502BadGateway)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IActionResult> Export([FromBody] SheetExportRequestDTO dto, CancellationToken ct)
{
if (dto?.Tabs == null || dto.Tabs.Count == 0)
return BadRequest(new { message = "No table to write." });
var request = _mapper.Map<SheetExportRequest>(dto);
try
{
var result = await _service.ExportAsync(request, ct);
_logger.LogInformation(
"Sheets export by user {User}: {TabCount} tabs to spreadsheet {SpreadsheetId} via {Writer}",
User?.Identity?.Name, result.Tabs?.Count ?? 0, result.SpreadsheetId, result.Writer);
return Ok(_mapper.Map<SheetExportResultDTO>(result));
}
catch (SheetsExportException ex)
{
_logger.LogWarning(ex, "Sheets export refused ({StatusCode})", ex.StatusCode);
return StatusCode(ex.StatusCode, new { message = ex.Message });
}
catch (OperationCanceledException)
{
// The caller navigated away or hit cancel; nothing to report to them.
throw;
}
catch (Exception ex)
{
// Anything unforeseen still has to arrive as JSON with a message.
_logger.LogError(ex, "Sheets export failed");
return StatusCode(StatusCodes.Status502BadGateway, new { message = ex.Message });
}
}
}
}
@@ -0,0 +1,62 @@
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);
}
/// <summary>
/// The players a versus lookup can report on: everyone with at least one set in a
/// bracket whose game is known, by display name. Not the application users —
/// these are tournament players, the entities set rows point at.
/// </summary>
[HttpGet("Players")]
public async Task<List<PlayerOptionDTO>> GetVersusPlayers()
{
var players = await _service.GetVersusPlayers();
return _mapper.Map<List<PlayerOptionDTO>>(players);
}
/// <summary>
/// Every recorded meeting between two players, across all events, split per game.
/// Sets played in a bracket with no game attached are excluded and only counted in
/// UnknownGameSets. Two identical or unknown ids return an empty breakdown, not an error.
/// </summary>
[HttpGet("Versus/{playerAId}/{playerBId}")]
public async Task<PlayerVersusDTO> GetVersus(int playerAId, int playerBId)
{
var versus = await _service.GetVersus(playerAId, playerBId);
return _mapper.Map<PlayerVersusDTO>(versus);
}
}
}
@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Linq; using System.Linq;
@@ -9,6 +9,7 @@ using LaDOSE.Business.Interface;
using LaDOSE.DTO; using LaDOSE.DTO;
using LaDOSE.Entity; using LaDOSE.Entity;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -33,12 +34,32 @@ namespace LaDOSE.Api.Controllers
_configuration = configuration; _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] [AllowAnonymous]
[HttpPost("auth")] [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) if (user == null)
return BadRequest(new { message = "Username or password is incorrect" }); 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 key = Encoding.ASCII.GetBytes(this._configuration["JWTTokenSecret"]);
var tokenDescriptor = new SecurityTokenDescriptor 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[] Subject = new ClaimsIdentity(new Claim[]
{ {
new Claim(ClaimTypes.Name, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Id.ToString()),
@@ -60,29 +84,62 @@ namespace LaDOSE.Api.Controllers
var tokenString = tokenHandler.WriteToken(token); var tokenString = tokenHandler.WriteToken(token);
// return basic user info (without password) and token to store client side // return basic user info (without password) and token to store client side
return Ok(new ApplicationUserDTO var dto = ToDto(user);
{ dto.Token = tokenString;
Id = user.Id, dto.Expire = token.ValidTo;
Username = user.Username, return Ok(dto);
FirstName = user.FirstName,
LastName = user.LastName,
Token = tokenString,
Expire = token.ValidTo
});
} }
[AllowAnonymous] /// <summary>Every account, for the admin user-management screen.</summary>
[HttpPost("register")] [Authorize(Roles = Roles.Admin)]
public IActionResult Register([FromBody]ApplicationUser userDto) [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 try
{ {
// save var created = _userService.Create(
_userService.Create(userDto, userDto.Password); new ApplicationUser
return Ok(); {
Username = userDto.Username?.Trim(),
FirstName = userDto.FirstName,
LastName = userDto.LastName
},
userDto.Password,
userDto.Roles);
return Ok(ToDto(created));
} }
catch (Exception ex) 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 });
}
}
} }
} }
@@ -77,7 +77,7 @@ namespace LaDOSE.Api.Controllers
[HttpGet("UpdateDb")] [HttpGet("UpdateDb")]
public bool UpdateDb() public bool UpdateDb()
{ {
return _service.UpdateBooking(); return false;
} }
[HttpGet("CreateChallonge/{gameId:int}/{wpEventId:int}")] [HttpGet("CreateChallonge/{gameId:int}/{wpEventId:int}")]
@@ -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
+12 -8
View File
@@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms> <Platforms>AnyCPU;x64</Platforms>
<LangVersion>12</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -10,13 +11,16 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AutoMapper" Version="10.0.0" /> <PackageReference Include="AutoMapper" Version="13.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.8" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="3.1.4" /> <PackageReference Include="Microsoft.OpenApi" Version="1.6.17" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.18" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.11" /> <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.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+104 -3
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.Reflection; using System.Reflection;
using System.Security.Claims;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using LaDOSE.Business.Interface; using LaDOSE.Business.Interface;
@@ -18,6 +19,7 @@ using AutoMapper;
using LaDOSE.Api.Helpers; using LaDOSE.Api.Helpers;
using LaDOSE.Business.Helper; using LaDOSE.Business.Helper;
using LaDOSE.Business.Provider.ChallongProvider; using LaDOSE.Business.Provider.ChallongProvider;
using LaDOSE.Business.Provider.SheetsProvider;
using LaDOSE.Business.Provider.SmashProvider; using LaDOSE.Business.Provider.SmashProvider;
using LaDOSE.Entity.Challonge; using LaDOSE.Entity.Challonge;
using LaDOSE.Entity.Wordpress; using LaDOSE.Entity.Wordpress;
@@ -25,6 +27,9 @@ using Result = LaDOSE.Entity.Challonge.Result;
using LaDOSE.Entity.BotEvent; using LaDOSE.Entity.BotEvent;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
#if DEBUG
using Scalar.AspNetCore;
#endif
namespace LaDOSE.Api namespace LaDOSE.Api
{ {
@@ -55,11 +60,25 @@ namespace LaDOSE.Api
} }
services.AddCors(); 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.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
x.SerializerSettings.MaxDepth= 4; // MaxDepth governs *reading*, so it caps how deep an inbound body may nest.
// ReferenceLoopHandling above is what tames the outbound Entity graph.
// The Sheets export body is 7 deep (root > tabs > tab > rows > row > points >
// number), so the previous value of 4 rejected it outright.
x.SerializerSettings.MaxDepth = 32;
}); });
#if DEBUG
services.AddOpenApi();
#endif
// services.AddDbContextPool<LaDOSEDbContext>( // replace "YourDbContext" with the class name of your DbContext // 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 // options => options.UseMySql($"Server={MySqlServer};Database={MySqlDatabase};User={MySqlUser};Password={MySqlPassword};", // replace with your Connection String
@@ -92,6 +111,18 @@ namespace LaDOSE.Api
{ {
// return unauthorized if user no longer exists // return unauthorized if user no longer exists
context.Fail("Unauthorized"); 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; return Task.CompletedTask;
@@ -130,6 +161,26 @@ namespace LaDOSE.Api
cfg.CreateMapTwoWay<Game, LaDOSE.DTO.GameDTO>(); cfg.CreateMapTwoWay<Game, LaDOSE.DTO.GameDTO>();
cfg.CreateMapTwoWay<Todo, LaDOSE.DTO.TodoDTO>(); 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>();
cfg.CreateMap<PlayerVersus, LaDOSE.DTO.PlayerVersusDTO>();
cfg.CreateMap<VersusGameStats, LaDOSE.DTO.VersusGameStatsDTO>();
cfg.CreateMap<PlayerOption, LaDOSE.DTO.PlayerOptionDTO>();
// Sheets export. Two-way: the request arrives as a DTO and has to become a POCO,
// which plain CreateMap does not give. SheetExportRequest.SpreadsheetId has no DTO
// counterpart on purpose — SheetsExportService fills it from configuration.
cfg.CreateMapTwoWay<SheetExportRequest, LaDOSE.DTO.SheetExportRequestDTO>();
cfg.CreateMapTwoWay<SheetTable, LaDOSE.DTO.SheetTableDTO>();
cfg.CreateMapTwoWay<SheetRow, LaDOSE.DTO.SheetRowDTO>();
cfg.CreateMap<SheetExportResult, LaDOSE.DTO.SheetExportResultDTO>();
cfg.CreateMap<SheetTabResult, LaDOSE.DTO.SheetTabResultDTO>();
cfg.CreateMap<SheetsConfig, LaDOSE.DTO.SheetsConfigDTO>();
}); });
IMapper mapper = mapperConfig.CreateMapper(); IMapper mapper = mapperConfig.CreateMapper();
services.AddSingleton(mapper); services.AddSingleton(mapper);
@@ -149,6 +200,7 @@ namespace LaDOSE.Api
services.AddScoped<IBotEventService, BotEventService>(); services.AddScoped<IBotEventService, BotEventService>();
services.AddScoped<IPlayerService, PlayerService>(); services.AddScoped<IPlayerService, PlayerService>();
services.AddScoped<IStatisticsService, StatisticsService>();
services.AddTransient<IChallongeProvider>(p => new ChallongeProvider( p.GetRequiredService<IGameService>(), services.AddTransient<IChallongeProvider>(p => new ChallongeProvider( p.GetRequiredService<IGameService>(),
p.GetRequiredService<IEventService>(), p.GetRequiredService<IEventService>(),
p.GetRequiredService<IPlayerService>(), p.GetRequiredService<IPlayerService>(),
@@ -160,6 +212,45 @@ namespace LaDOSE.Api
this.Configuration["ApiKey:SmashApiKey"])); this.Configuration["ApiKey:SmashApiKey"]));
services.AddScoped<IExternalProviderService, ExternalProviderService>(); services.AddScoped<IExternalProviderService, ExternalProviderService>();
#region Google Sheets export
// Limits and the target spreadsheet. The spreadsheet is reset every year, so it lives
// in configuration rather than in code — see .env.example.
services.AddSingleton(new SheetsSettings
{
SpreadsheetId = this.Configuration["GoogleSheets:SpreadsheetId"],
MaxTabs = ReadInt("GoogleSheets:MaxTabs", 60),
MaxRowsPerTab = ReadInt("GoogleSheets:MaxRowsPerTab", 5000),
MaxColumns = ReadInt("GoogleSheets:MaxColumns", 200)
});
// One writer, chosen by configuration. "Logging" exercises the whole feature without
// touching Google; anything unrecognised disables the export with a message rather
// than a null reference.
services.AddScoped<ISheetsWriter>(p =>
{
switch (this.Configuration["GoogleSheets:Writer"])
{
case "ServiceAccount":
return new GoogleApiSheetsWriter(
this.Configuration["GoogleSheets:ServiceAccount:CredentialsPath"]);
case "Logging":
return new LoggingSheetsWriter(
p.GetRequiredService<ILogger<LoggingSheetsWriter>>());
default:
return new DisabledSheetsWriter();
}
});
services.AddScoped<ISheetsExportService, SheetsExportService>();
#endregion
}
/// <summary>Configuration is all strings; a missing or unparsable value takes the default.</summary>
private int ReadInt(string key, int fallback)
{
return int.TryParse(this.Configuration[key], out var value) && value > 0 ? value : fallback;
} }
@@ -185,7 +276,17 @@ namespace LaDOSE.Api
app.UseRouting(); app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseEndpoints(x => x.MapControllers()); app.UseEndpoints(x =>
{
x.MapControllers();
#if DEBUG
if (env.IsDevelopment())
{
x.MapOpenApi();
x.MapScalarApiReference();
}
#endif
});
} }
} }
} }
+14 -9
View File
@@ -1,25 +1,30 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Warning" "Default": "Warning",
"LaDOSE": "Information"
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"DbContext":"Host=descartes.local;Username=tom;Password=tom;Database=ladoseapi" "DbContext":"Host=kafka.local;Username=tom;Password=tom;Database=ladoseapi"
}, },
"CertificateSettings": { "CertificateSettings": {
"fileName": "localhost.pfx", "fileName": "localhost.pfx",
"password": "YourSecurePassword" "password": "YourSecurePassword"
}, },
"MySql": {
"Server": "localhost",
"Database": "ladoseapi",
"User": "dev",
"Password": "dev"
},
"ApiKey": { "ApiKey": {
"ChallongeApiKey": "Challonge ApiKey", "ChallongeApiKey": "Challonge ApiKey",
"SmashApiKey": "Smash" "SmashApiKey": "SmashApiKey"
},
"GoogleSheets": {
"Writer": "ServiceAccount",
"SpreadsheetId": "1FMS3ZesZC7yBNsJG3CpB5c8qzF95JHUpnsj2q45BSs4",
"MaxTabs": 60,
"MaxRowsPerTab": 5000,
"MaxColumns": 200,
"ServiceAccount": {
"CredentialsPath": "/home/tom/test-agent/LaDOSE/secrets/test.json"
}
}, },
"AllowedHosts": "0.0.0.0", "AllowedHosts": "0.0.0.0",
"Port": 5000, "Port": 5000,
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic;
namespace LaDOSE.DTO namespace LaDOSE.DTO
{ {
@@ -9,8 +10,13 @@ namespace LaDOSE.DTO
public string FirstName { get; set; } public string FirstName { get; set; }
public string LastName { get; set; } public string LastName { get; set; }
public string Username { 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; } 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 string Token { get; set; }
public DateTime Expire { 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 class EventDTO
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name { 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; }
}; };
} }
+2 -2
View File
@@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms> <Platforms>AnyCPU;x64</Platforms>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+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; }
}
}
+76
View File
@@ -0,0 +1,76 @@
using System.Collections.Generic;
namespace LaDOSE.DTO
{
/// <summary>
/// Every recorded meeting between two players, broken down per game.
/// Sets belonging to a bracket with no game attached are not in <see cref="Games"/>
/// nor in the totals — they are only counted in <see cref="UnknownGameSets"/>.
/// </summary>
public class PlayerVersusDTO
{
public int PlayerAId { get; set; }
/// <summary>Gamertag, falling back to Name, else "#&lt;id&gt;".</summary>
public string PlayerA { get; set; }
public int PlayerBId { get; set; }
public string PlayerB { get; set; }
/// <summary>Meetings in a bracket whose game is known — the sum of the rows in <see cref="Games"/>.</summary>
public int Sets { get; set; }
/// <summary>Of those, the ones with a determinable winner.</summary>
public int DecidedSets { get; set; }
public int WinsA { get; set; }
public int WinsB { get; set; }
/// <summary>Meetings dropped because the bracket has no game attached.</summary>
public int UnknownGameSets { get; set; }
/// <summary>One row per game they met in, most-played first.</summary>
public List<VersusGameStatsDTO> Games { get; set; }
}
/// <summary>One game's slice of a <see cref="PlayerVersusDTO"/>.</summary>
public class VersusGameStatsDTO
{
public int GameId { get; set; }
/// <summary>Game name, or "#&lt;id&gt;" when the game row is gone.</summary>
public string Game { get; set; }
public string GameLongName { get; set; }
/// <summary>Meetings in this game, decided or not.</summary>
public int Sets { get; set; }
/// <summary>Meetings with a determinable winner. Always equals WinsA + WinsB.</summary>
public int DecidedSets { get; set; }
public int WinsA { get; set; }
public int WinsB { get; set; }
/// <summary>Individual games won inside the decided sets.</summary>
public int GamesWonA { get; set; }
public int GamesWonB { get; set; }
}
/// <summary>
/// A player who can be picked for a versus lookup: one with at least one set in a
/// bracket whose game is known.
/// </summary>
public class PlayerOptionDTO
{
public int Id { get; set; }
/// <summary>Gamertag, falling back to Name, else "#&lt;id&gt;".</summary>
public string Name { get; set; }
/// <summary>Sets in a bracket with a known game, whoever the opponent was.</summary>
public int Sets { get; set; }
}
}
+81
View File
@@ -0,0 +1,81 @@
using System.Collections.Generic;
namespace LaDOSE.DTO
{
/// <summary>
/// Body of POST /api/Sheets/Export. The target spreadsheet is deliberately absent: it comes
/// from server configuration only, so a caller cannot redirect the export.
/// </summary>
public class SheetExportRequestDTO
{
/// <summary>
/// One tab per selected event, oldest first. Tab N holds events 1..N merged, so the last
/// tab is the full cumulative ranking for the selection.
/// </summary>
public List<SheetTableDTO> Tabs { get; set; }
}
/// <summary>One tab: a players x games grid with a Total column, as built by buildRanking().</summary>
public class SheetTableDTO
{
/// <summary>Desired tab title, e.g. "Ranking #1301". Sanitised server-side.</summary>
public string Name { get; set; }
/// <summary>Provenance and logging only; tabs are addressed by title.</summary>
public int EventId { get; set; }
/// <summary>["Players", &lt;game names...&gt;, "Total"].</summary>
public List<string> Header { get; set; }
public List<SheetRowDTO> Rows { get; set; }
/// <summary>Provenance lines written below the grid.</summary>
public List<string> Footer { get; set; }
}
public class SheetRowDTO
{
public string Player { get; set; }
/// <summary>
/// Index-aligned with Header[1..^1]. Typed int, not string, so the values land in the
/// sheet as numbers and existing formulas and conditional formatting keep working.
/// </summary>
public List<int> Points { get; set; }
public int Total { get; set; }
}
public class SheetExportResultDTO
{
public string SpreadsheetId { get; set; }
public string SpreadsheetUrl { get; set; }
public string Writer { get; set; }
public List<SheetTabResultDTO> Tabs { get; set; }
public List<string> Warnings { get; set; }
}
public class SheetTabResultDTO
{
public string RequestedName { get; set; }
public string Name { get; set; }
public int Rows { get; set; }
public int Columns { get; set; }
public bool Created { get; set; }
}
/// <summary>
/// Response of GET /api/Sheets/Config. Never carries the credentials path or any secret —
/// only what the export panel needs to render itself.
/// </summary>
public class SheetsConfigDTO
{
/// <summary>"ServiceAccount" | "Logging" | "Disabled".</summary>
public string Writer { get; set; }
public bool Configured { get; set; }
public string SpreadsheetId { get; set; }
public int MaxTabs { get; set; }
public int MaxRowsPerTab { get; set; }
}
}
+2
View File
@@ -27,6 +27,8 @@ namespace LaDOSE.DTO
public List<GameDTO> Games { get; set; } public List<GameDTO> Games { get; set; }
public List<ResultDTO> Results { get; set; } public List<ResultDTO> Results { get; set; }
public string Slug { get; set; }
} }
public class ResultDTO public class ResultDTO
{ {
@@ -1,11 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport> <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<LangVersion>12</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -24,8 +25,8 @@
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.3" /> <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.3" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" /> <PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
<PackageReference Include="MessageBox.Avalonia" Version="3.2.0" /> <PackageReference Include="MessageBox.Avalonia" Version="3.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
</ItemGroup> </ItemGroup>
@@ -38,9 +38,9 @@ sealed class Program
var restUser = builder["REST:User"].ToString(); var restUser = builder["REST:User"].ToString();
var restPassword = builder["REST:Password"].ToString(); var restPassword = builder["REST:Password"].ToString();
currentMutable.RegisterLazySingleton<RestService>(() => currentMutable.Register<RestService>(() =>
{ {
var restService = new RestService(); var restService = new RestService(new Uri(restUrl), restUser, restPassword);
try try
{ {
restService.Connect(new Uri(restUrl), restUser, restPassword); restService.Connect(new Uri(restUrl), restUser, restPassword);
@@ -223,8 +223,8 @@ namespace LaDOSE.DesktopApp.Avalonia.ViewModels
this.To = new DateTimeOffset(DateTime.Now); this.To = new DateTimeOffset(DateTime.Now);
this.From = new DateTimeOffset(DateTime.Now.AddMonths(-1)); this.From = new DateTimeOffset(DateTime.Now.AddMonths(-1));
this.SelectRegex = "Ranking"; this.SelectRegex = "Ranking";
this.SelectEventRegex = @"Ranking #10\d{2}"; this.SelectEventRegex = @"Ranking #13\d{2}";
this.Slug = "ranking-1001"; this.Slug = "ranking-130";
LoadTournaments(); LoadTournaments();
LoadEvents(); LoadEvents();
@@ -472,7 +472,7 @@ namespace LaDOSE.DesktopApp.Avalonia.ViewModels
string url = enumerable.FirstOrDefault()?.TournamentUrl; string url = enumerable.FirstOrDefault()?.TournamentUrl;
url = url.Replace(" ", "-"); url = url.Replace(" ", "-");
url = url.Replace(".", "-"); url = url.Replace(".", "-");
sb.AppendLine($"<a href=\"https://smash.gg/tournament/ranking-1002/event/{url}\" target=\"_blank\">Voir le Bracket</p></td>"); sb.AppendLine($"<a href=\"https://start.gg/tournament/{Results.Slug}/event/{url}\" target=\"_blank\">Voir le Bracket</p></td>");
} }
@@ -1,58 +1,55 @@
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes;
using LaDOSE.DiscordBot.Service; using LaDOSE.DiscordBot.Service;
using LaDOSE.DTO; using LaDOSE.DTO;
namespace LaDOSE.DiscordBot.Command namespace LaDOSE.DiscordBot.Command
{ {
public class BotEvent : BaseCommandModule // public class BotEvent : BaseCommandModule
{ // {
private WebService dep; // private WebService dep;
public BotEvent(WebService d) // public BotEvent(WebService d)
{ // {
dep = d; // dep = d;
} // }
//
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")] // [Command("newevent")]
[Command("newevent")] // public async Task NewEventAsync(CommandContext ctx, string command)
public async Task NewEventAsync(CommandContext ctx, string command) // {
{ //
// await ctx.RespondAsync(dep.RestService.CreateBotEvent(command).ToString());
await ctx.RespondAsync(dep.RestService.CreateBotEvent(command).ToString()); // }
} // [RequireRolesAttribute(RoleCheckMode.Any,"Staff")]
[RequireRolesAttribute(RoleCheckMode.Any,"Staff")] // [Command("staffs")]
[Command("staffs")] // public async Task StaffAsync(CommandContext ctx)
public async Task StaffAsync(CommandContext ctx) // {
{ // BotEventDTO currentEvent = dep.RestService.GetLastBotEvent();
BotEventDTO currentEvent = dep.RestService.GetLastBotEvent(); // StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder = new StringBuilder(); //
// var present = currentEvent.Results.Where(x => x.Result).ToList();
var present = currentEvent.Results.Where(x => x.Result).ToList(); // var absent = currentEvent.Results.Where(x => !x.Result).ToList();
var absent = currentEvent.Results.Where(x => !x.Result).ToList(); //
// stringBuilder.AppendLine($"Pour {currentEvent.Name} : ");
stringBuilder.AppendLine($"Pour {currentEvent.Name} : "); // present.ForEach(x => stringBuilder.AppendLine($":white_check_mark: {x.Name}"));
present.ForEach(x => stringBuilder.AppendLine($":white_check_mark: {x.Name}")); // absent.ForEach(x => stringBuilder.AppendLine($":x: {x.Name}"));
absent.ForEach(x => stringBuilder.AppendLine($":x: {x.Name}")); //
// await ctx.RespondAsync(stringBuilder.ToString());
await ctx.RespondAsync(stringBuilder.ToString()); //
// }
} // [RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")] // [Command("present")]
[Command("present")] // public async Task PresentAsync(CommandContext ctx)
public async Task PresentAsync(CommandContext ctx) // {
{ // await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = true }).ToString());
await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = true }).ToString()); //
//
// }
} // [RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")] // [Command("absent")]
[Command("absent")] // public async Task AbsentAsync(CommandContext ctx)
public async Task AbsentAsync(CommandContext ctx) // {
{ // await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = false }).ToString());
await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = false }).ToString()); // }
} // }
}
} }
+14 -6
View File
@@ -2,14 +2,15 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DSharpPlus.CommandsNext; using DSharpPlus.Commands;
using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Commands.ArgumentModifiers;
using DSharpPlus.Commands.Processors.TextCommands;
using DSharpPlus.Entities; using DSharpPlus.Entities;
namespace LaDOSE.DiscordBot.Command namespace LaDOSE.DiscordBot.Command
{ {
public class Hokuto : BaseCommandModule public class Hokuto
{ {
private static List<string> Games = new List<string> { "2X", "3.3", "Karnov" }; private static List<string> Games = new List<string> { "2X", "3.3", "Karnov" };
@@ -21,14 +22,21 @@ namespace LaDOSE.DiscordBot.Command
[Command("hokuto")] [Command("hokuto")]
public async Task HokutoUserAsync(CommandContext ctx, params DiscordMember[] user) public async ValueTask HokutoUserAsync(TextCommandContext ctx)
{ {
var i = r.Next(0, 3); var i = r.Next(0, 3);
if (user!=null && user.Length>0) if (ctx.Message.MentionedUsers is { Count: 1 } )
{ {
await ctx.RespondAsync(ctx.User?.Mention + " vs " + user[0].Mention + " : " + Games[i].ToString()); foreach (var arg in ctx.Message.MentionedUsers)
{
if (arg is DiscordUser member)
{
await ctx.RespondAsync(ctx.User?.Mention + " vs " + member.Mention + " : " + Games[i].ToString());
return;
}
}
} }
else else
{ {
@@ -4,12 +4,11 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using DSharpPlus.CommandsNext; using DSharpPlus.Commands;
using DSharpPlus.CommandsNext.Attributes;
namespace LaDOSE.DiscordBot.Command namespace LaDOSE.DiscordBot.Command
{ {
public class Public : BaseCommandModule public class Public
{ {
private static List<string> Quotes { get; set; } private static List<string> Quotes { get; set; }
+52
View File
@@ -0,0 +1,52 @@
# Builds the LaDOSE.DiscordBot image. Context is LaDOSE.Src, same as LaDOSE.Src/Dockerfile,
# so the two share LaDOSE.Src/.dockerignore — hence the LaDOSE.DiscordBot/ prefix on every
# COPY below. docker-compose.yml passes `dockerfile: LaDOSE.DiscordBot/Dockerfile`.
#
# The context has to be the whole of LaDOSE.Src, not this directory: the bot pulls in
# LaDOSE.REST -> LaDOSE.DTO by ProjectReference, and Libraries/ChallongeCSharpDriver.dll
# by HintPath.
ARG DOTNET_VERSION=9.0
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
WORKDIR /src
# Release here, unlike the API image. Nothing in this project is gated on Debug — the
# #if DEBUG / Condition="'$(Configuration)' == 'Debug'" pairs that force the API to build
# Debug (OpenAPI, Scalar) have no equivalent in LaDOSE.DiscordBot.csproj.
ARG BUILD_CONFIGURATION=Release
# Project files first so this layer survives every .cs edit. Restore under the same
# Configuration as the publish below, matching LaDOSE.Src/Dockerfile.
COPY global.json ./
COPY LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj LaDOSE.DiscordBot/
COPY LaDOSE.REST/LaDOSE.REST.csproj LaDOSE.REST/
COPY LaDOSE.DTO/LaDOSE.DTO.csproj LaDOSE.DTO/
RUN dotnet restore LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj -p:Configuration=${BUILD_CONFIGURATION}
COPY . .
RUN dotnet publish LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj -c ${BUILD_CONFIGURATION} --no-restore -o /app/out
# The file on disk is Quotes.txt; Command/Public.cs:114 opens "quotes.txt". Windows does
# not care, Linux does — without this the !Quote command throws FileNotFoundException
# (LoadQuote, unlike LoadCards, does not catch it). A copy rather than a rename, so the
# name the .csproj lists keeps working too.
RUN if [ -f /app/out/Quotes.txt ] && [ ! -f /app/out/quotes.txt ]; then \
cp /app/out/Quotes.txt /app/out/quotes.txt; \
fi
# runtime, not aspnet: this is a console app with no Kestrel and no listening port.
FROM mcr.microsoft.com/dotnet/runtime:${DOTNET_VERSION}
WORKDIR /app
COPY --from=build /app/out/ ./
COPY LaDOSE.DiscordBot/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# WORKDIR is load-bearing, not cosmetic: Program.cs points its ConfigurationBuilder at
# Directory.GetCurrentDirectory() for settings.json, and Public.cs reads questions.txt /
# answers.txt / quotes.txt by bare relative path. All four sit in /app.
#
# The entrypoint renders settings.json from the environment before handing over; see the
# script for why that indirection exists.
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["dotnet", "LaDOSE.DiscordBot.dll"]
@@ -2,17 +2,18 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms> <Platforms>AnyCPU;x64</Platforms>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="DSharpPlus" Version="4.2.0" /> <PackageReference Include="DSharpPlus" Version="5.0.0-alpha.5" />
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.2.0" /> <PackageReference Include="DSharpPlus.Commands" Version="5.0.0-alpha.5" />
<PackageReference Include="DSharpPlus.Interactivity" Version="4.2.0" /> <PackageReference Include="DSharpPlus.Interactivity" Version="5.0.0-alpha.5" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.8" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+22 -89
View File
@@ -4,8 +4,12 @@ using System.IO;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DSharpPlus; using DSharpPlus;
using DSharpPlus.Commands;
using DSharpPlus.Commands.Processors.SlashCommands;
using DSharpPlus.Commands.Processors.TextCommands;
using DSharpPlus.Commands.Processors.TextCommands.Parsing;
using DSharpPlus.Entities;
using DSharpPlus.Interactivity; using DSharpPlus.Interactivity;
using DSharpPlus.CommandsNext;
using DSharpPlus.EventArgs; using DSharpPlus.EventArgs;
using DSharpPlus.Interactivity.Extensions; using DSharpPlus.Interactivity.Extensions;
//using DSharpPlus.SlashCommands; //using DSharpPlus.SlashCommands;
@@ -20,7 +24,6 @@ namespace LaDOSE.DiscordBot
{ {
class Program class Program
{ {
static DiscordClient discord;
//static InteractivityModule Interactivity { get; set; } //static InteractivityModule Interactivity { get; set; }
static void Main(string[] args) static void Main(string[] args)
@@ -43,106 +46,36 @@ namespace LaDOSE.DiscordBot
var restUser = builder["REST:User"].ToString(); var restUser = builder["REST:User"].ToString();
var restPassword = builder["REST:Password"].ToString(); var restPassword = builder["REST:Password"].ToString();
var service = new ServiceCollection()
.AddSingleton(typeof(WebService), new WebService(new Uri(restUrl), restUser, restPassword))
.BuildServiceProvider();
Console.WriteLine($"LaDOSE.Net Discord Bot"); Console.WriteLine($"LaDOSE.Net Discord Bot");
DiscordClientBuilder builder2 =
discord = new DiscordClient(new DiscordConfiguration DiscordClientBuilder.CreateDefault(discordToken, DiscordIntents.AllUnprivileged | DiscordIntents.MessageContents | DiscordIntents.GuildMessages| TextCommandProcessor.RequiredIntents | SlashCommandProcessor.RequiredIntents);
{
Token = discordToken,
TokenType = TokenType.Bot,
//AutoReconnect = true,
//MinimumLogLevel = LogLevel.Debug,
//MessageCacheSize = 0,
});
discord.UseInteractivity(new InteractivityConfiguration
{
// default pagination behaviour to just ignore the reactions
//PaginationBehaviour = TimeoutBehaviour.Ignore,
// default pagination timeout to 5 minutes
//PaginationTimeout = TimeSpan.FromMinutes(5),
// default timeout for other actions to 2 minutes
Timeout = TimeSpan.FromMinutes(2)
});
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
var _cnext = discord.UseCommandsNext(new CommandsNextConfiguration() // Setup the commands extension
builder2.UseCommands((IServiceProvider serviceProvider, CommandsExtension extension) =>
{ {
//CaseSensitive = false, extension.AddCommands([typeof(Hokuto), typeof(Public)]);
//EnableDefaultHelp = true, TextCommandProcessor textCommandProcessor = new();
//EnableDms = false, extension.AddProcessor(textCommandProcessor);
//EnableMentionPrefix = true, }, new CommandsConfiguration()
StringPrefixes = new List<string>() { "/", "!" }, {
//IgnoreExtraArguments = true, // The default value is true, however it's shown here for clarity
Services = service RegisterDefaultCommandProcessors = true,
UseDefaultCommandErrorHandler = false
// DebugGuildId = Environment.GetEnvironmentVariable("DEBUG_GUILD_ID") ?? 0,
}); });
DiscordClient client = builder2.Build();
//var slashCommands = discord.UseSlashCommands(new SlashCommandsConfiguration() {Services = service}); // We can specify a status for our bot. Let's set it to "playing" and set the activity to "with fire".
//slashCommands.RegisterCommands<SlashCommand>(guildId:null); DiscordActivity status = new("Street Fighter", DiscordActivityType.Playing);
await client.ConnectAsync(status,DiscordUserStatus.Online);
//_cnext.RegisterCommands<Result>();
_cnext.RegisterCommands<Public>();
//_cnext.RegisterCommands<Shutdown>();
//_cnext.RegisterCommands<Todo>();
_cnext.RegisterCommands<Hokuto>();
_cnext.RegisterCommands<BotEvent>();
foreach (var registeredCommandsKey in discord.GetCommandsNext().RegisteredCommands.Keys)
{
Console.WriteLine(registeredCommandsKey);
}
discord.Ready += (sender, eventArgs) =>
{
Console.WriteLine($"Bot READY.");
return Task.CompletedTask;
};
discord.GuildAvailable += (sender, eventArgs) =>
{
Console.WriteLine($"Joined Guild " + eventArgs.Guild.Name);
return Task.CompletedTask;
};
await discord.ConnectAsync();
await Task.Delay(Timeout.Infinite); await Task.Delay(Timeout.Infinite);
//while (!cts.IsCancellationRequested)
//{
// await Task.Delay(200);
// //if(discord.GetConnectionsAsync().Result.Count)
//}
} }
} }
//internal class SlashCommand : ApplicationCommandModule
//{
// [SlashCommand("test", "A slash command made to test the DSharpPlusSlashCommands library!")]
// public async Task TestCommand(InteractionContext ctx)
// {
// await ctx.CreateResponseAsync("Lol");
// }
//}
} }
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
# LaDOSE.DiscordBot reads its configuration from ./settings.json and from nothing else:
# Program.cs builds a ConfigurationBuilder with AddJsonFile("settings.json") and no
# AddEnvironmentVariables(), so a LADOSE_* variable would be invisible to it. This script
# renders that file at container start instead — the same indirection LaDOSE.WebApp's
# entrypoint uses to turn LADOSE_API_BASE_URL into /config.js. One image, any token.
#
# It writes only when LADOSE_DISCORD_TOKEN is set. Left unset, whatever settings.json is
# already at /app wins: the placeholder baked in by the build, or a file bind-mounted over
# it. Doing both — mounting settings.json read-only *and* setting the variable — fails
# here with EROFS rather than silently ignoring one of them.
set -eu
settings_file=/app/settings.json
# Drop control characters (newlines included) so a value cannot break out of its string
# literal, then escape backslashes before double quotes. A token containing " or \ stays
# inert data.
json_string() {
printf '%s' "${1:-}" | tr -d '\001-\037' | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}
if [ -n "${LADOSE_DISCORD_TOKEN:-}" ]; then
rest_url=${LADOSE_BOT_REST_URL:-http://api:5000}
# All five keys, always. Program.cs calls .ToString() on each of them at startup, so a
# missing one is a NullReferenceException before the bot ever reaches Discord.
cat >"$settings_file" <<EOF
{
"Discord": {
"Token": "$(json_string "${LADOSE_DISCORD_TOKEN}")"
},
"Challonge": {
"Token": "$(json_string "${LADOSE_CHALLONGE_API_KEY:-}")"
},
"REST": {
"Url": "$(json_string "${rest_url}")",
"User": "$(json_string "${LADOSE_BOT_REST_USER:-}")",
"Password": "$(json_string "${LADOSE_BOT_REST_PASSWORD:-}")"
}
}
EOF
# The token is deliberately not echoed.
echo "settings.json: rendered from the environment (REST:Url=$rest_url)" >&2
else
echo "settings.json: LADOSE_DISCORD_TOKEN unset, using the file already at $settings_file" >&2
fi
exec "$@"
@@ -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; using System.ComponentModel.DataAnnotations.Schema;
@@ -14,6 +15,14 @@ namespace LaDOSE.Entity
public string Password { get; set; } public string Password { get; set; }
public byte[] PasswordHash { get; set; } public byte[] PasswordHash { get; set; }
public byte[] PasswordSalt { 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.Challonge
public List<Game> Games{ get; set; } public List<Game> Games{ get; set; }
public List<Result> Results { get; set; } public List<Result> Results { get; set; }
public string Slug { get; set; }
} }
public class Result public class Result
@@ -9,6 +9,7 @@ namespace LaDOSE.Entity.Context
{ {
public DbSet<Game> Game { get; set; } public DbSet<Game> Game { get; set; }
public DbSet<ApplicationUser> ApplicationUser { get; set; } public DbSet<ApplicationUser> ApplicationUser { get; set; }
public DbSet<ApplicationRole> ApplicationRole { get; set; }
public DbSet<Todo> Todo { get; set; } public DbSet<Todo> Todo { get; set; }
@@ -49,6 +50,27 @@ namespace LaDOSE.Entity.Context
base.OnModelCreating(modelBuilder); 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>() modelBuilder.Entity<Event>()
.HasMany(s => s.Tournaments); .HasMany(s => s.Tournaments);
@@ -2,12 +2,18 @@
<PropertyGroup> <PropertyGroup>
<Platforms>AnyCPU;x64</Platforms> <Platforms>AnyCPU;x64</Platforms>
<TargetFrameworks>net6.0;netcoreapp3.1</TargetFrameworks> <TargetFramework>net9.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.1.2" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.EntityFrameworkCore">
<HintPath>..\..\..\..\.nuget\packages\microsoft.entityframeworkcore\8.0.11\lib\net8.0\Microsoft.EntityFrameworkCore.dll</HintPath>
</Reference>
</ItemGroup> </ItemGroup>
</Project> </Project>
+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,124 @@
using System.Collections.Generic;
namespace LaDOSE.Entity
{
/// <summary>
/// One spreadsheet write: the tables to put in it, and which spreadsheet.
/// Not an entity — never mapped by EF. It arrives as SheetExportRequestDTO and is
/// mapped by AutoMapper, same pattern as <see cref="MatchStats"/>, so property names
/// must stay identical to the DTO's.
///
/// The tables are built client-side by $lib/tournaments/cumulative.ts, which reuses the
/// very same buildRanking() that produces the CSV export. That is deliberate: the sheet
/// and the CSV cannot drift because one function produces both.
/// </summary>
public class SheetExportRequest
{
/// <summary>
/// Resolved by SheetsExportService from configuration — never supplied by the caller,
/// so a client cannot aim the export at someone else's spreadsheet.
/// </summary>
public string SpreadsheetId { get; set; }
/// <summary>
/// One tab per selected event, oldest first. Tab N holds events 1..N merged, so the
/// last tab is the full cumulative ranking for the scope.
/// </summary>
public List<SheetTable> Tabs { get; set; } = new List<SheetTable>();
}
/// <summary>One tab: a players x games grid with a Total column.</summary>
public class SheetTable
{
/// <summary>Desired tab title, e.g. "Ranking #1301". Sanitised before use.</summary>
public string Name { get; set; }
/// <summary>
/// Provenance and logging only. Tabs are addressed by title, never by this — see the
/// note on tab identity in SheetsExportService.
/// </summary>
public int EventId { get; set; }
/// <summary>["Players", &lt;game names...&gt;, "Total"].</summary>
public List<string> Header { get; set; } = new List<string>();
public List<SheetRow> Rows { get; set; } = new List<SheetRow>();
/// <summary>
/// Written below the grid, one line per entry, blank row in between. Carries which
/// events went into this tab so a stale or incomplete tab identifies itself.
/// </summary>
public List<string> Footer { get; set; } = new List<string>();
}
public class SheetRow
{
public string Player { get; set; }
/// <summary>Index-aligned with Header[1..^1] — one entry per game column.</summary>
public List<int> Points { get; set; } = new List<int>();
public int Total { get; set; }
}
public class SheetExportResult
{
public string SpreadsheetId { get; set; }
public string SpreadsheetUrl { get; set; }
/// <summary>Which ISheetsWriter did the work — "ServiceAccount", "Logging", ...</summary>
public string Writer { get; set; }
public List<SheetTabResult> Tabs { get; set; } = new List<SheetTabResult>();
/// <summary>Anything the user should know but that did not stop the write, e.g. renames.</summary>
public List<string> Warnings { get; set; } = new List<string>();
}
public class SheetTabResult
{
/// <summary>The title as asked for, before sanitisation.</summary>
public string RequestedName { get; set; }
/// <summary>The title actually written to.</summary>
public string Name { get; set; }
public int Rows { get; set; }
public int Columns { get; set; }
/// <summary>False when an existing tab of that name was rewritten in place.</summary>
public bool Created { get; set; }
}
/// <summary>What the UI needs to render the export panel. Never carries a secret.</summary>
public class SheetsConfig
{
public string Writer { get; set; }
/// <summary>The writer has its credentials and a target spreadsheet is configured.</summary>
public bool Configured { get; set; }
public string SpreadsheetId { get; set; }
public int MaxTabs { get; set; }
public int MaxRowsPerTab { get; set; }
}
/// <summary>
/// Server-side limits and the target spreadsheet, read from the GoogleSheets configuration
/// section. Constructor-injected rather than IOptions, matching how the Challonge and Smash
/// providers take their keys.
/// </summary>
public class SheetsSettings
{
/// <summary>
/// The spreadsheet everything is written to. Reset each year — see .env.example.
/// </summary>
public string SpreadsheetId { get; set; }
public int MaxTabs { get; set; } = 60;
public int MaxRowsPerTab { get; set; } = 5000;
/// <summary>Google caps a tab at 100 chars; this caps the grid width.</summary>
public int MaxColumns { get; set; } = 200;
}
}
@@ -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; }
}
}
@@ -0,0 +1,88 @@
using System.Collections.Generic;
namespace LaDOSE.Entity
{
/// <summary>
/// Every recorded meeting between two players, broken down per game.
/// Not an entity: computed in memory by StatisticsService and mapped to
/// PlayerVersusDTO by AutoMapper, same as <see cref="MatchStats"/>.
/// Property names must stay identical to the DTO's, the mapping is by convention.
///
/// A <see cref="Set"/> carries no game of its own — the game comes from the
/// <see cref="Tournament"/> the set belongs to, and that Tournament.GameId is
/// nullable. Sets behind a bracket with no game are counted in
/// <see cref="UnknownGameSets"/> and excluded from everything else, because a
/// per-game breakdown cannot say anything about them.
/// </summary>
public class PlayerVersus
{
public int PlayerAId { get; set; }
/// <summary>Gamertag, falling back to Name, else "#&lt;id&gt;".</summary>
public string PlayerA { get; set; }
public int PlayerBId { get; set; }
public string PlayerB { get; set; }
/// <summary>Meetings in a bracket whose game is known — the sum of the rows below.</summary>
public int Sets { get; set; }
/// <summary>Of those, the ones with a determinable winner (unequal scores).</summary>
public int DecidedSets { get; set; }
public int WinsA { get; set; }
public int WinsB { get; set; }
/// <summary>
/// Meetings dropped because the bracket has no game attached. Reported rather
/// than hidden: it is the difference between "they never met" and "we cannot
/// tell which game they met in".
/// </summary>
public int UnknownGameSets { get; set; }
/// <summary>One row per game they actually met in, most-played first.</summary>
public List<VersusGameStats> Games { get; set; } = new List<VersusGameStats>();
}
/// <summary>One game's slice of a <see cref="PlayerVersus"/>.</summary>
public class VersusGameStats
{
public int GameId { get; set; }
/// <summary>Game.Name, or "#&lt;id&gt;" when the game row is gone.</summary>
public string Game { get; set; }
public string GameLongName { get; set; }
/// <summary>Meetings in this game, decided or not.</summary>
public int Sets { get; set; }
/// <summary>Meetings with a determinable winner. Always equals WinsA + WinsB.</summary>
public int DecidedSets { get; set; }
public int WinsA { get; set; }
public int WinsB { get; set; }
/// <summary>Individual games won inside the decided sets (a DQ, stored as -1, clamps to 0).</summary>
public int GamesWonA { get; set; }
public int GamesWonB { get; set; }
}
/// <summary>
/// A player who can be picked for a versus lookup, i.e. one with at least one set
/// in a bracket whose game is known. Offering anyone else would mean offering
/// players the breakdown must then report as empty.
/// </summary>
public class PlayerOption
{
public int Id { get; set; }
/// <summary>Gamertag, falling back to Name, else "#&lt;id&gt;".</summary>
public string Name { get; set; }
/// <summary>Sets in a bracket with a known game, whoever the opponent was.</summary>
public int Sets { get; set; }
}
}
+4 -3
View File
@@ -1,13 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms> <Platforms>AnyCPU;x64</Platforms>
<LangVersion>12</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="RestSharp" Version="110.2.0" /> <PackageReference Include="RestSharp" Version="112.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+10 -4
View File
@@ -29,13 +29,19 @@ namespace LaDOSE.REST
{ {
} }
public RestService(Uri url, string user, string password)
public void Connect(Uri url, string user, string password)
{ {
Client = new RestClient(url); Client = new RestClient(url);
this.username = user; this.username = user;
this.password = password; this.password = password;
}
public void Connect(Uri url, string user, string password)
{
// Client = new RestClient(url);
// this.username = user;
// this.password = password;
string token; string token;
try try
{ {
@@ -79,7 +85,7 @@ namespace LaDOSE.REST
private void CheckToken() private void CheckToken()
{ {
if (this.Auth == null || this.Auth.Expire <= DateTime.Now) if (this.Auth == null || this.Auth.Expire.ToUniversalTime() <= DateTime.Now.ToUniversalTime())
{ {
GetToken(this.username,this.password); GetToken(this.username,this.password);
} }
@@ -0,0 +1,42 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using LaDOSE.Entity;
namespace LaDOSE.Business.Interface
{
public interface ISheetsExportService
{
/// <summary>What the export panel needs to render. Never includes a secret.</summary>
SheetsConfig GetConfig();
/// <summary>
/// Resolves the target spreadsheet from configuration, validates the tables, sanitises
/// and de-duplicates the tab titles, then hands off to the configured
/// <see cref="ISheetsWriter"/>. Throws <see cref="SheetsExportException"/> for anything
/// the caller can act on.
/// </summary>
Task<SheetExportResult> ExportAsync(SheetExportRequest request, CancellationToken ct = default);
}
/// <summary>
/// A failure with a message meant for the person who pressed the button, plus the status
/// the API should answer with. The API has no exception middleware, so anything that escapes
/// reaches the browser as an HTML developer page the client can only report as a bare status
/// — hence every foreseeable failure is raised as one of these instead.
/// </summary>
public class SheetsExportException : Exception
{
public SheetsExportException(int statusCode, string message) : base(message)
{
StatusCode = statusCode;
}
public SheetsExportException(int statusCode, string message, Exception inner) : base(message, inner)
{
StatusCode = statusCode;
}
public int StatusCode { get; }
}
}
@@ -0,0 +1,26 @@
using System.Threading;
using System.Threading.Tasks;
using LaDOSE.Entity;
namespace LaDOSE.Business.Interface
{
/// <summary>
/// Writes tabular data into a Google Spreadsheet. Startup picks one implementation from
/// GoogleSheets:Writer, so nothing above this interface knows how Google is reached.
///
/// Implementations must be idempotent: writing the same request twice leaves the same
/// spreadsheet. They must also leave tabs they were not asked about completely alone —
/// the spreadsheet holds hand-made summaries and charts, and deletion is irreversible
/// through the API.
/// </summary>
public interface ISheetsWriter
{
/// <summary>Reported to the UI so the panel can say what it is talking to.</summary>
string Name { get; }
/// <summary>False when credentials are missing, so the API can answer 503 with a message.</summary>
bool IsConfigured { get; }
Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default);
}
}
@@ -0,0 +1,30 @@
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);
/// <summary>
/// The players a versus lookup can say something about: those with at least one
/// set in a bracket whose game is known, by display name.
/// </summary>
Task<List<PlayerOption>> GetVersusPlayers();
/// <summary>
/// Every recorded meeting between two players, all events, broken down per game.
/// Sets whose bracket has no game are excluded and only counted in
/// <see cref="PlayerVersus.UnknownGameSets"/>.
/// Missing, equal or unknown ids yield a well-formed empty <see cref="PlayerVersus"/>.
/// </summary>
Task<PlayerVersus> GetVersus(int playerAId, int playerBId);
}
}
@@ -8,8 +8,11 @@ namespace LaDOSE.Business.Interface
ApplicationUser Authenticate(string username, string password); ApplicationUser Authenticate(string username, string password);
IEnumerable<ApplicationUser> GetAll(); IEnumerable<ApplicationUser> GetAll();
ApplicationUser GetById(int id); 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 Update(ApplicationUser user, string password = null);
void Delete(int id); void Delete(int id);
/// <summary>The roles that exist in the database — reference data, not created at runtime.</summary>
IEnumerable<ApplicationRole> GetAllRoles();
} }
} }
@@ -11,7 +11,6 @@ namespace LaDOSE.Business.Interface
List<WPEvent> GetWpEvent(); List<WPEvent> GetWpEvent();
List<WPUser> GetBooking(int wpEventId, Game game); List<WPUser> GetBooking(int wpEventId, Game game);
List<WPUser> GetBookingOptions(int wpEventId, Game game); List<WPUser> GetBookingOptions(int wpEventId, Game game);
bool UpdateBooking();
string CreateChallonge(int gameId, int wpEventId, IList<WPUser> additionPlayers); string CreateChallonge(int gameId, int wpEventId, IList<WPUser> additionPlayers);
Task<string> GetLastChallonge(); Task<string> GetLastChallonge();
@@ -1,16 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<AssemblyName>LaDOSE.Business</AssemblyName> <AssemblyName>LaDOSE.Business</AssemblyName>
<RootNamespace>LaDOSE.Business</RootNamespace> <RootNamespace>LaDOSE.Business</RootNamespace>
<Platforms>AnyCPU;x64</Platforms> <Platforms>AnyCPU;x64</Platforms>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="GraphQL.Client" Version="4.0.2" /> <PackageReference Include="Google.Apis.Sheets.v4" Version="1.75.0.4178" />
<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="4.0.2" /> <PackageReference Include="GraphQL.Client" Version="6.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="6.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
namespace LaDOSE.Business.Provider.SheetsProvider
{
/// <summary>
/// The default when GoogleSheets:Writer names nothing usable. Reports itself as
/// unconfigured so the API answers 503 with a message the user can act on, rather than
/// throwing a NullReferenceException that reaches the browser as an HTML developer page.
/// </summary>
public class DisabledSheetsWriter : ISheetsWriter
{
public string Name => "Disabled";
public bool IsConfigured => false;
public Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
{
throw new SheetsExportException(503,
"Google Sheets export is not configured on the server. Set GoogleSheets:Writer.");
}
}
}
@@ -0,0 +1,347 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Google;
using Google.Apis.Auth.OAuth2;
using Google.Apis.Services;
using Google.Apis.Sheets.v4;
using Google.Apis.Sheets.v4.Data;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
namespace LaDOSE.Business.Provider.SheetsProvider
{
/// <summary>
/// Writes to Google Sheets as a service account.
///
/// Two HTTP calls: read the existing tab list, then one batchUpdate. That second call is
/// atomic — either every tab lands or none does — so a failure can never leave the ranking
/// half updated.
///
/// Setup, once: create a service account, download its JSON key, point
/// GoogleSheets:ServiceAccount:CredentialsPath at it, and share the spreadsheet with the
/// account's ...iam.gserviceaccount.com address as Editor. That last step is the one people
/// forget, so a 403 says so explicitly and names the address.
/// </summary>
public class GoogleApiSheetsWriter : ISheetsWriter
{
private readonly string _credentialsPath;
public GoogleApiSheetsWriter(string credentialsPath)
{
_credentialsPath = credentialsPath;
}
public string Name => "ServiceAccount";
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(_credentialsPath) && File.Exists(_credentialsPath);
public async Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
{
if (!IsConfigured)
{
throw new SheetsExportException(503,
$"Service-account key not found at '{_credentialsPath}'. " +
"Check GoogleSheets:ServiceAccount:CredentialsPath and that the file is mounted.");
}
using var service = CreateService(out var accountEmail);
var tabs = request.Tabs ?? new List<SheetTable>();
try
{
// 1. What is already in the spreadsheet: titles, ids, and current grid sizes.
var get = service.Spreadsheets.Get(request.SpreadsheetId);
get.Fields = "spreadsheetId,spreadsheetUrl,sheets.properties(sheetId,title,index,gridProperties)";
var spreadsheet = await get.ExecuteAsync(ct);
var existing = (spreadsheet.Sheets ?? new List<Sheet>())
.Select(sheet => sheet.Properties)
.Where(properties => properties != null)
.ToList();
// 2. One atomic batch for the whole export.
var batch = BuildBatch(existing, tabs, out var results);
if (batch.Requests.Count > 0)
{
await service.Spreadsheets.BatchUpdate(batch, request.SpreadsheetId).ExecuteAsync(ct);
}
return new SheetExportResult
{
SpreadsheetId = spreadsheet.SpreadsheetId ?? request.SpreadsheetId,
SpreadsheetUrl = spreadsheet.SpreadsheetUrl ?? LoggingSheetsWriter.SheetUrl(request.SpreadsheetId),
Writer = Name,
Tabs = results,
Warnings = new List<string>()
};
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.Forbidden)
{
throw new SheetsExportException(502,
$"Google refused access to spreadsheet '{request.SpreadsheetId}'. " +
$"Share it as Editor with {accountEmail ?? "the service account address"}.", ex);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
{
throw new SheetsExportException(502,
$"No spreadsheet with id '{request.SpreadsheetId}'. " +
"Check GoogleSheets:SpreadsheetId — it is the id from the sheet URL, not the whole URL.", ex);
}
catch (GoogleApiException ex)
{
throw new SheetsExportException(502, $"Google rejected the write: {ex.Message}", ex);
}
}
/// <summary>
/// Builds the credential from the key file explicitly. GoogleCredential.FromFile and
/// .FromJson are both deprecated, and ServiceAccountCredential is already an HTTP client
/// initializer, so there is nothing for GoogleCredential to add here. Doing it this way
/// also hands us the account's own address for the "share the sheet with…" message.
/// </summary>
private SheetsService CreateService(out string accountEmail)
{
ServiceAccountCredential credential;
try
{
var json = File.ReadAllText(_credentialsPath);
var parameters = Google.Apis.Json.NewtonsoftJsonSerializer.Instance
.Deserialize<JsonCredentialParameters>(json);
if (parameters?.Type != JsonCredentialParameters.ServiceAccountCredentialType
|| string.IsNullOrEmpty(parameters.ClientEmail)
|| string.IsNullOrEmpty(parameters.PrivateKey))
{
throw new InvalidOperationException(
"not a service-account key (expected \"type\": \"service_account\" with client_email and private_key)");
}
accountEmail = parameters.ClientEmail;
credential = new ServiceAccountCredential(
new ServiceAccountCredential.Initializer(parameters.ClientEmail)
{
ProjectId = parameters.ProjectId,
KeyId = parameters.PrivateKeyId,
Scopes = new[] { SheetsService.Scope.Spreadsheets }
}.FromPrivateKey(parameters.PrivateKey));
}
catch (Exception ex)
{
throw new SheetsExportException(503,
$"Could not read the service-account key at '{_credentialsPath}': {ex.Message}", ex);
}
return new SheetsService(new BaseClientService.Initializer
{
HttpClientInitializer = credential,
ApplicationName = "LaDOSE"
});
}
#region Batch construction
/// <summary>
/// Turns "what the spreadsheet has" plus "what we want" into one ordered request list.
/// Deliberately static and free of I/O so the whole batch can be asserted in a test with
/// no network — the same split StatisticsService uses for its pure Aggregate.
///
/// Order matters: create missing tabs, resize, reindex, then per tab clear and write.
/// No tab is ever deleted — unlisted tabs hold hand-made summaries and charts.
/// </summary>
public static BatchUpdateSpreadsheetRequest BuildBatch(
List<SheetProperties> existing,
List<SheetTable> tabs,
out List<SheetTabResult> results)
{
var requests = new List<Request>();
results = new List<SheetTabResult>();
var byTitle = (existing ?? new List<SheetProperties>())
.Where(properties => properties.Title != null)
.GroupBy(properties => properties.Title, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
// Ids for tabs that do not exist yet are only known after the batch runs, so the
// clear and write for those address the sheet by title through a placeholder id.
// AddSheet with an explicit SheetId avoids that entirely: we pick the ids ourselves.
var nextId = NextFreeSheetId(byTitle.Values);
for (var index = 0; index < tabs.Count; index++)
{
var tab = tabs[index];
var values = ToRowData(tab);
var rowCount = values.Count;
var columnCount = tab.Header?.Count ?? 0;
byTitle.TryGetValue(tab.Name, out var properties);
var created = properties == null;
int sheetId;
if (created)
{
sheetId = nextId++;
requests.Add(new Request
{
AddSheet = new AddSheetRequest
{
Properties = new SheetProperties
{
SheetId = sheetId,
Title = tab.Name,
Index = index,
GridProperties = new GridProperties
{
RowCount = Math.Max(rowCount, 1),
ColumnCount = Math.Max(columnCount, 1),
FrozenRowCount = 1
}
}
}
});
}
else
{
sheetId = properties.SheetId ?? 0;
// Grow before writing: the API silently drops cells outside the grid, and a
// sheet created by hand defaults to 1000 x 26.
var haveRows = properties.GridProperties?.RowCount ?? 0;
var haveColumns = properties.GridProperties?.ColumnCount ?? 0;
if (haveRows < rowCount || haveColumns < columnCount)
{
requests.Add(new Request
{
UpdateSheetProperties = new UpdateSheetPropertiesRequest
{
Fields = "gridProperties.rowCount,gridProperties.columnCount",
Properties = new SheetProperties
{
SheetId = sheetId,
GridProperties = new GridProperties
{
RowCount = Math.Max(haveRows, rowCount),
ColumnCount = Math.Max(haveColumns, columnCount)
}
}
}
});
}
// Oldest event leftmost, so the tabs read in season order.
if (properties.Index != index)
{
requests.Add(new Request
{
UpdateSheetProperties = new UpdateSheetPropertiesRequest
{
Fields = "index",
Properties = new SheetProperties { SheetId = sheetId, Index = index }
}
});
}
}
// Clear the whole sheet first, then write. Fields = "userEnteredValue" is the
// equivalent of clearContents(): colours, notes and conditional formatting the
// user set up by hand all survive. Without the clear, cells beyond the new
// extent would linger from a previous, longer export.
requests.Add(new Request
{
UpdateCells = new UpdateCellsRequest
{
Range = new GridRange { SheetId = sheetId },
Fields = "userEnteredValue"
}
});
requests.Add(new Request
{
UpdateCells = new UpdateCellsRequest
{
Start = new GridCoordinate { SheetId = sheetId, RowIndex = 0, ColumnIndex = 0 },
Rows = values,
Fields = "userEnteredValue"
}
});
results.Add(new SheetTabResult
{
RequestedName = tab.Name,
Name = tab.Name,
Rows = rowCount,
Columns = columnCount,
Created = created
});
}
return new BatchUpdateSpreadsheetRequest { Requests = requests };
}
/// <summary>Sheet ids must be unique within the spreadsheet and are ours to choose.</summary>
private static int NextFreeSheetId(IEnumerable<SheetProperties> existing)
{
var used = existing.Select(properties => properties.SheetId ?? 0).DefaultIfEmpty(0).Max();
return Math.Max(used + 1, 1);
}
/// <summary>
/// Header, then one row per player, then the footer after a blank line. Points and totals
/// go in as numbers rather than text so the user's formulas keep working.
/// </summary>
private static List<RowData> ToRowData(SheetTable tab)
{
var header = tab.Header ?? new List<string>();
var width = header.Count;
var rows = new List<RowData>
{
new RowData { Values = header.Select(Text).ToList() }
};
foreach (var row in tab.Rows ?? new List<SheetRow>())
{
var cells = new List<CellData> { Text(row.Player) };
cells.AddRange((row.Points ?? new List<int>()).Select(Number));
cells.Add(Number(row.Total));
rows.Add(new RowData { Values = cells });
}
var footer = (tab.Footer ?? new List<string>()).Where(line => line != null).ToList();
if (footer.Count > 0)
{
rows.Add(new RowData { Values = Blank(width) });
foreach (var line in footer)
{
var cells = Blank(width);
if (cells.Count > 0) cells[0] = Text(line);
else cells.Add(Text(line));
rows.Add(new RowData { Values = cells });
}
}
return rows;
}
private static List<CellData> Blank(int width)
{
return Enumerable.Range(0, Math.Max(width, 0)).Select(_ => Text(string.Empty)).ToList();
}
private static CellData Text(string value)
{
return new CellData { UserEnteredValue = new ExtendedValue { StringValue = value ?? string.Empty } };
}
private static CellData Number(int value)
{
return new CellData { UserEnteredValue = new ExtendedValue { NumberValue = value } };
}
#endregion
}
}
@@ -0,0 +1,88 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
using Microsoft.Extensions.Logging;
namespace LaDOSE.Business.Provider.SheetsProvider
{
/// <summary>
/// Writes the export to the log instead of to Google, and reports success.
///
/// This exists so the whole feature — button, ordering, cumulative prefixes, payload shape —
/// can be exercised end to end before any Google account, key or sharing exists. Set
/// GoogleSheets:Writer to "Logging" and read the API log. It is also the safe way to try a
/// selection without touching the real spreadsheet.
/// </summary>
public class LoggingSheetsWriter : ISheetsWriter
{
private readonly ILogger<LoggingSheetsWriter> _logger;
public LoggingSheetsWriter(ILogger<LoggingSheetsWriter> logger)
{
_logger = logger;
}
public string Name => "Logging";
public bool IsConfigured => true;
public Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
{
var tabs = request.Tabs ?? new List<SheetTable>();
_logger.LogInformation(
"Sheets export (Logging writer): spreadsheet {SpreadsheetId}, {TabCount} tabs",
request.SpreadsheetId, tabs.Count);
foreach (var tab in tabs)
{
var header = tab.Header ?? new List<string>();
var rows = tab.Rows ?? new List<SheetRow>();
_logger.LogInformation(" [{Name}] event {EventId}: {RowCount} rows x {ColumnCount} columns | {Header}",
tab.Name, tab.EventId, rows.Count, header.Count, string.Join(" | ", header));
foreach (var row in rows)
{
_logger.LogInformation(" {Player} | {Points} | {Total}",
row.Player, string.Join(" | ", row.Points ?? new List<int>()), row.Total);
}
foreach (var line in tab.Footer ?? new List<string>())
{
_logger.LogInformation(" -- {Line}", line);
}
}
return Task.FromResult(new SheetExportResult
{
SpreadsheetId = request.SpreadsheetId,
SpreadsheetUrl = SheetUrl(request.SpreadsheetId),
Writer = Name,
Tabs = tabs.Select(tab => new SheetTabResult
{
RequestedName = tab.Name,
Name = tab.Name,
Rows = (tab.Rows?.Count ?? 0) + 1,
Columns = tab.Header?.Count ?? 0,
// Nothing was inspected, so this is a claim rather than an observation.
Created = true
}).ToList(),
Warnings = new List<string>
{
"Writer is 'Logging': nothing was written to Google. The payload is in the API log."
}
});
}
internal static string SheetUrl(string spreadsheetId)
{
return string.IsNullOrWhiteSpace(spreadsheetId)
? null
: $"https://docs.google.com/spreadsheets/d/{spreadsheetId}/edit";
}
}
}
@@ -15,6 +15,7 @@ namespace LaDOSE.Business.Provider.SmashProvider
{ {
public class SmashProvider : ISmashProvider public class SmashProvider : ISmashProvider
{ {
private static string API_FQDN = "api.start.gg";
public string ApiKey { get; set; } public string ApiKey { get; set; }
//public SmashProvider(string apiKey) //public SmashProvider(string apiKey)
//{ //{
@@ -34,7 +35,7 @@ namespace LaDOSE.Business.Provider.SmashProvider
private async Task<T> QuerySmash<T>(GraphQLRequest req) private async Task<T> QuerySmash<T>(GraphQLRequest req)
{ {
var graphQLClient = new GraphQLHttpClient("https://api.smash.gg/gql/alpha", new NewtonsoftJsonSerializer()); var graphQLClient = new GraphQLHttpClient($"https://{API_FQDN}/gql/alpha", new NewtonsoftJsonSerializer());
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}"); graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
var graphQLResponse = await graphQLClient.SendQueryAsync<T>(req); var graphQLResponse = await graphQLClient.SendQueryAsync<T>(req);
@@ -346,7 +347,7 @@ namespace LaDOSE.Business.Provider.SmashProvider
public async Task<TournamentResponse> GetNames(string slug) public async Task<TournamentResponse> GetNames(string slug)
{ {
var graphQLClient = new GraphQLHttpClient("https://api.smash.gg/gql/alpha", new NewtonsoftJsonSerializer()); var graphQLClient = new GraphQLHttpClient($"https://{API_FQDN}/gql/alpha", new NewtonsoftJsonSerializer());
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}"); graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
var Event = new GraphQLRequest var Event = new GraphQLRequest
{ {
@@ -412,7 +413,7 @@ namespace LaDOSE.Business.Provider.SmashProvider
public async Task<TournamentResponse> GetTournament(string slug) public async Task<TournamentResponse> GetTournament(string slug)
{ {
var graphQLClient = new GraphQLHttpClient("https://api.smash.gg/gql/alpha", new NewtonsoftJsonSerializer()); var graphQLClient = new GraphQLHttpClient($"https://{API_FQDN}/gql/alpha", new NewtonsoftJsonSerializer());
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}"); graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
var Event = new GraphQLRequest var Event = new GraphQLRequest
{ {
@@ -6,6 +6,7 @@ using LaDOSE.Entity.BotEvent;
using LaDOSE.Entity.Context; using LaDOSE.Entity.Context;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace LaDOSE.Business.Service namespace LaDOSE.Business.Service
{ {
public class BotEventService : BaseService<BotEvent>, IBotEventService public class BotEventService : BaseService<BotEvent>, IBotEventService
@@ -152,6 +152,10 @@ namespace LaDOSE.Business.Service
var games = _context.Game.ToList(); var games = _context.Game.ToList();
TournamentsResult result = new TournamentsResult(); TournamentsResult result = new TournamentsResult();
if (id.Count == 1)
{
result.Slug = _context.Event.Where(e=> e.Id == id.First()).First().SmashSlug;
}
result.Results = new List<Result>(); result.Results = new List<Result>();
result.Games = new List<Game>(); result.Games = new List<Game>();
result.Participents = new List<ChallongeParticipent>(); result.Participents = new List<ChallongeParticipent>();
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
namespace LaDOSE.Business.Service
{
/// <summary>
/// Everything about a spreadsheet export that is not Google-specific: resolving the target,
/// validating the tables, and making the tab titles legal and unique. Kept out of the writers
/// so the rules are stated once and can be tested without a network, the same way
/// StatisticsService keeps its aggregation in a pure static method.
/// </summary>
public class SheetsExportService : ISheetsExportService
{
private readonly SheetsSettings _settings;
private readonly ISheetsWriter _writer;
public SheetsExportService(SheetsSettings settings, ISheetsWriter writer)
{
_settings = settings ?? new SheetsSettings();
_writer = writer;
}
public SheetsConfig GetConfig()
{
return new SheetsConfig
{
Writer = _writer?.Name ?? "Disabled",
Configured = _writer != null
&& _writer.IsConfigured
&& !string.IsNullOrWhiteSpace(_settings.SpreadsheetId),
SpreadsheetId = _settings.SpreadsheetId ?? string.Empty,
MaxTabs = _settings.MaxTabs,
MaxRowsPerTab = _settings.MaxRowsPerTab
};
}
public async Task<SheetExportResult> ExportAsync(SheetExportRequest request, CancellationToken ct = default)
{
if (_writer == null || !_writer.IsConfigured)
{
throw new SheetsExportException(503,
"Google Sheets export is not configured on the server.");
}
if (string.IsNullOrWhiteSpace(_settings.SpreadsheetId))
{
throw new SheetsExportException(400,
"No spreadsheet configured on the server. Set GoogleSheets:SpreadsheetId.");
}
var tabs = request?.Tabs?.Where(tab => tab != null).ToList() ?? new List<SheetTable>();
if (tabs.Count == 0)
{
throw new SheetsExportException(400, "No table to write.");
}
Validate(tabs);
var warnings = new List<string>();
NameTabs(tabs, warnings);
// The caller never names the spreadsheet; it is resolved here, from configuration.
var resolved = new SheetExportRequest
{
SpreadsheetId = _settings.SpreadsheetId.Trim(),
Tabs = tabs
};
var result = await _writer.WriteTablesAsync(resolved, ct);
result.Writer = _writer.Name;
result.Warnings = (result.Warnings ?? new List<string>()).Concat(warnings).ToList();
return result;
}
#region Validation
private void Validate(List<SheetTable> tabs)
{
if (tabs.Count > _settings.MaxTabs)
{
throw new SheetsExportException(400,
$"{tabs.Count} tabs requested, the limit is {_settings.MaxTabs}. Narrow the selection.");
}
foreach (var tab in tabs)
{
var header = tab.Header ?? new List<string>();
// "Players", at least one game, "Total".
if (header.Count < 3)
{
throw new SheetsExportException(400,
$"Tab '{tab.Name}' has no game column — nothing was scored for it.");
}
if (header.Count > _settings.MaxColumns)
{
throw new SheetsExportException(400,
$"Tab '{tab.Name}' has {header.Count} columns, the limit is {_settings.MaxColumns}.");
}
var rows = tab.Rows ?? new List<SheetRow>();
if (rows.Count > _settings.MaxRowsPerTab)
{
throw new SheetsExportException(400,
$"Tab '{tab.Name}' has {rows.Count} rows, the limit is {_settings.MaxRowsPerTab}.");
}
var expected = header.Count - 2;
foreach (var row in rows)
{
var points = row?.Points?.Count ?? 0;
if (points != expected)
{
throw new SheetsExportException(400,
$"Tab '{tab.Name}' row '{row?.Player}' has {points} point columns, " +
$"header declares {expected}.");
}
}
}
}
#endregion
#region Tab naming
/// <summary>
/// Google rejects these in a tab title. Replaced rather than stripped so "Ranking 13/14"
/// stays readable as "Ranking 13-14".
/// </summary>
private static readonly Regex Forbidden = new Regex(@"[:\\/?*\[\]]", RegexOptions.Compiled);
private static readonly Regex Whitespace = new Regex(@"\s+", RegexOptions.Compiled);
private const int MaxTitleLength = 100;
/// <summary>
/// Makes every title legal and unique, in place, recording each change. Tab identity is
/// the title, so a rename means the next export writes somewhere else — which is exactly
/// why every rename is reported rather than applied quietly.
/// </summary>
private static void NameTabs(List<SheetTable> tabs, List<string> warnings)
{
var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var tab in tabs)
{
var requested = tab.Name ?? string.Empty;
var name = Sanitise(requested, tab.EventId);
if (!taken.Add(name))
{
var suffix = 2;
string candidate;
do
{
candidate = Truncate($"{name} ({suffix})");
suffix++;
} while (!taken.Add(candidate));
name = candidate;
}
if (!string.Equals(name, requested, StringComparison.Ordinal))
{
warnings.Add($"Tab renamed: '{requested}' -> '{name}'");
}
tab.Name = name;
}
}
/// <summary>
/// Trim, replace what Google forbids, collapse runs of whitespace, drop leading and
/// trailing apostrophes (Sheets uses them to quote a title), cap the length, and fall
/// back to the event id when nothing usable survives.
/// </summary>
public static string Sanitise(string requested, int eventId)
{
var name = (requested ?? string.Empty).Trim();
name = Forbidden.Replace(name, "-");
name = Whitespace.Replace(name, " ").Trim();
name = name.Trim('\'');
name = Truncate(name).Trim();
return string.IsNullOrWhiteSpace(name) ? $"Event {eventId}" : name;
}
private static string Truncate(string value)
{
return value.Length <= MaxTitleLength ? value : value.Substring(0, MaxTitleLength);
}
#endregion
}
}
@@ -0,0 +1,511 @@
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.
///
/// - A Set has no game either. The game belongs to the <see cref="Tournament"/> the set
/// was played in, and Tournament.GameId is nullable, so any per-game breakdown has
/// to decide what to do with brackets that have none. <see cref="GetVersus"/> drops
/// them and reports how many it dropped.
///
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
/// and <see cref="GetVersus"/> issue one query per table and hand the loaded lists to the pure
/// static <see cref="Aggregate"/> / <see cref="AggregateVersus"/>, which are 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));
}
public Task<List<PlayerOption>> GetVersusPlayers()
{
// Same filter GetVersus applies, so the picker cannot offer a player whose
// every meeting would then be dropped as "game unknown". Self-sets are
// excluded here too, otherwise they would be counted in both slots.
var usable = from s in _context.Set
join t in _context.Tournament on s.TournamentId equals t.Id
where t.GameId != null && s.Player1Id != 0 && s.Player2Id != 0
&& s.Player1Id != s.Player2Id
select s;
// Counted in the database, one group-by per slot: the set table is the
// largest one here and there is no reason to pull it into memory.
var asPlayer1 = usable
.GroupBy(s => s.Player1Id)
.Select(g => new { PlayerId = g.Key, Sets = g.Count() })
.ToList();
var asPlayer2 = usable
.GroupBy(s => s.Player2Id)
.Select(g => new { PlayerId = g.Key, Sets = g.Count() })
.ToList();
var setsByPlayer = new Dictionary<int, int>();
foreach (var row in asPlayer1.Concat(asPlayer2))
{
setsByPlayer.TryGetValue(row.PlayerId, out var running);
setsByPlayer[row.PlayerId] = running + row.Sets;
}
if (setsByPlayer.Count == 0)
{
return Task.FromResult(new List<PlayerOption>());
}
var playerIds = setsByPlayer.Keys.ToList();
var nameById = _context.Player
.Where(p => playerIds.Contains(p.Id))
.ToList()
.ToDictionary(p => p.Id, DisplayName);
// A set can reference a player row that no longer exists; keep it as "#id"
// rather than hiding a real opponent from the picker.
var options = setsByPlayer
.Select(pair => new PlayerOption
{
Id = pair.Key,
Name = ResolveName(pair.Key, nameById),
Sets = pair.Value
})
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Id)
.ToList();
return Task.FromResult(options);
}
public Task<PlayerVersus> GetVersus(int playerAId, int playerBId)
{
// Nothing to look up, and nothing exceptional either: a caller that has not
// picked two distinct players gets an empty breakdown, not a 500.
if (playerAId == 0 || playerBId == 0 || playerAId == playerBId)
{
return Task.FromResult(new PlayerVersus
{
PlayerAId = playerAId,
PlayerBId = playerBId
});
}
// Either seating: the set rows record whoever start.gg listed first.
var sets = _context.Set
.Where(s => (s.Player1Id == playerAId && s.Player2Id == playerBId)
|| (s.Player1Id == playerBId && s.Player2Id == playerAId))
.ToList();
var tournamentIds = sets.Select(s => s.TournamentId).Distinct().ToList();
var tournaments = tournamentIds.Count == 0
? new List<Tournament>()
: _context.Tournament
.Where(t => tournamentIds.Contains(t.Id))
.ToList();
var gameIds = tournaments
.Where(t => t.GameId.HasValue)
.Select(t => t.GameId.Value)
.Distinct()
.ToList();
var games = gameIds.Count == 0
? new List<Game>()
: _context.Game
.Where(g => gameIds.Contains(g.Id))
.ToList();
var players = _context.Player
.Where(p => p.Id == playerAId || p.Id == playerBId)
.ToList();
return Task.FromResult(AggregateVersus(playerAId, playerBId, sets, tournaments, games, 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;
}
/// <summary>
/// Pure aggregation of the meetings between two players, per game. No database,
/// no I/O, deterministic.
///
/// Winners are inferred from the scores exactly as in <see cref="Aggregate"/>: equal
/// scores mean undecided. Undecided meetings still count in Sets — they happened —
/// but contribute to no win and no game count.
/// </summary>
/// <param name="playerAId">Left-hand player; WinsA is always their side.</param>
/// <param name="playerBId">Right-hand player.</param>
/// <param name="sets">Candidate sets; the pairing is re-checked here.</param>
/// <param name="tournaments">Tournaments of those sets, for Tournament.GameId.</param>
/// <param name="games">Games used to resolve names; may be incomplete.</param>
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
public static PlayerVersus AggregateVersus(
int playerAId,
int playerBId,
IEnumerable<Set> sets,
IEnumerable<Tournament> tournaments,
IEnumerable<Game> games,
IEnumerable<Player> players)
{
var result = new PlayerVersus
{
PlayerAId = playerAId,
PlayerBId = playerBId
};
if (playerAId == 0 || playerBId == 0 || playerAId == playerBId)
{
return result;
}
var nameById = new Dictionary<int, string>();
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
{
nameById[player.Id] = DisplayName(player);
}
result.PlayerA = ResolveName(playerAId, nameById);
result.PlayerB = ResolveName(playerBId, nameById);
var gameIdByTournament = new Dictionary<int, int?>();
foreach (var tournament in (tournaments ?? Enumerable.Empty<Tournament>()).Where(t => t != null))
{
gameIdByTournament[tournament.Id] = tournament.GameId;
}
var gameById = new Dictionary<int, Game>();
foreach (var game in (games ?? Enumerable.Empty<Game>()).Where(g => g != null))
{
gameById[game.Id] = game;
}
var perGame = new Dictionary<int, VersusGameStats>();
foreach (var set in (sets ?? Enumerable.Empty<Set>()).Where(s => s != null))
{
// The query already restricts the pairing; checking again keeps this
// method correct on its own, which is the point of it being pure.
var aIsPlayer1 = set.Player1Id == playerAId && set.Player2Id == playerBId;
var bIsPlayer1 = set.Player1Id == playerBId && set.Player2Id == playerAId;
if (!aIsPlayer1 && !bIsPlayer1)
{
continue;
}
// No game on the bracket, nothing to file this meeting under. Counted so
// the caller can say "3 meetings we cannot attribute" instead of losing them.
if (!gameIdByTournament.TryGetValue(set.TournamentId, out var gameId) || !gameId.HasValue)
{
result.UnknownGameSets++;
continue;
}
var row = GetOrAddGame(perGame, gameId.Value, gameById);
row.Sets++;
var scoreA = aIsPlayer1 ? set.Player1Score : set.Player2Score;
var scoreB = aIsPlayer1 ? set.Player2Score : set.Player1Score;
// No winner column: equal scores (including 0-0 and -1 / -1) are undecided.
if (scoreA == scoreB)
{
continue;
}
row.DecidedSets++;
// A DQ is stored as -1. Clamp so it never produces negative games.
row.GamesWonA += Math.Max(0, scoreA);
row.GamesWonB += Math.Max(0, scoreB);
if (scoreA > scoreB)
{
row.WinsA++;
}
else
{
row.WinsB++;
}
}
result.Games = perGame.Values
.OrderByDescending(g => g.Sets)
.ThenByDescending(g => g.DecidedSets)
.ThenBy(g => g.Game, StringComparer.Ordinal)
.ThenBy(g => g.GameId)
.ToList();
// Totals are derived from the rows, so the header cannot disagree with the table.
result.Sets = result.Games.Sum(g => g.Sets);
result.DecidedSets = result.Games.Sum(g => g.DecidedSets);
result.WinsA = result.Games.Sum(g => g.WinsA);
result.WinsB = result.Games.Sum(g => g.WinsB);
return result;
}
private static VersusGameStats GetOrAddGame(Dictionary<int, VersusGameStats> perGame, int gameId,
Dictionary<int, Game> gameById)
{
if (!perGame.TryGetValue(gameId, out var row))
{
gameById.TryGetValue(gameId, out var game);
var name = string.IsNullOrWhiteSpace(game?.Name) ? $"#{gameId}" : game.Name;
row = new VersusGameStats
{
GameId = gameId,
Game = name,
GameLongName = string.IsNullOrWhiteSpace(game?.LongName) ? name : game.LongName
};
perGame[gameId] = row;
}
return row;
}
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.Business.Interface;
using LaDOSE.Entity; using LaDOSE.Entity;
using LaDOSE.Entity.Context; using LaDOSE.Entity.Context;
using Microsoft.EntityFrameworkCore;
namespace LaDOSE.Business.Service namespace LaDOSE.Business.Service
{ {
@@ -20,8 +21,9 @@ namespace LaDOSE.Business.Service
{ {
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
return null; return null;
var p = _context.ApplicationUser.ToList(); var user = _context.ApplicationUser
var user = _context.ApplicationUser.SingleOrDefault(x => x.Username == username); .Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
.SingleOrDefault(x => x.Username == username);
// check if username exists // check if username exists
if (user == null) if (user == null)
@@ -37,23 +39,44 @@ namespace LaDOSE.Business.Service
public IEnumerable<ApplicationUser> GetAll() 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) 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 // validation
if (string.IsNullOrWhiteSpace(user?.Username))
throw new Exception("Username is required");
if (string.IsNullOrWhiteSpace(password)) if (string.IsNullOrWhiteSpace(password))
throw new Exception("Password is required"); throw new Exception("Password is required");
if (_context.ApplicationUser.Any(x => x.Username == user.Username)) if (_context.ApplicationUser.Any(x => x.Username == user.Username))
throw new Exception("Username \"" + user.Username + "\" is already taken"); 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; byte[] passwordHash, passwordSalt;
CreatePasswordHash(password, out passwordHash, out passwordSalt); CreatePasswordHash(password, out passwordHash, out passwordSalt);
@@ -66,6 +89,35 @@ namespace LaDOSE.Business.Service
return user; 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) public void Update(ApplicationUser userParam, string password = null)
{ {
var user = _context.ApplicationUser.Find(userParam.Id); var user = _context.ApplicationUser.Find(userParam.Id);
@@ -101,13 +153,19 @@ namespace LaDOSE.Business.Service
public void Delete(int id) public void Delete(int id)
{ {
var user = _context.ApplicationUser.Find(id); // applicationuserrole's foreign keys are ON DELETE RESTRICT, so the join rows
if (user != null) // 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.ApplicationUser.Remove(user);
_context.SaveChanges(); _context.SaveChanges();
} }
}
// private helper methods // private helper methods
@@ -43,13 +43,6 @@ namespace LaDOSE.Business.Service
return wpEvents; return wpEvents;
} }
public bool UpdateBooking()
{
_context.Database.SetCommandTimeout(60);
_context.Database.ExecuteSqlRaw("call ladoseapi.ImportEvent();");
_context.Database.SetCommandTimeout(30);
return true;
}
public List<WPUser> GetBooking(int wpEventId, Game game) public List<WPUser> GetBooking(int wpEventId, Game game)
{ {
var selectedGameWpId = game.WordPressTag.Split(';'); var selectedGameWpId = game.WordPressTag.Split(';');
+21
View File
@@ -0,0 +1,21 @@
# Keep the build context small and free of local state.
node_modules
build
.svelte-kit
.git
.vscode
# Real URLs live here and must never reach the image. .env.example is documentation.
.env
.env.*
!.env.example
# Consumed by `podman build` itself, never copied in.
Dockerfile
.dockerignore
# Local noise
.DS_Store
Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+1
View File
@@ -0,0 +1 @@
lts/*
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+41
View File
@@ -0,0 +1,41 @@
# Build the SPA. adapter-static writes /app/build (pages == assets).
FROM node:24-alpine AS build
WORKDIR /app
# Lockfile first: this layer is reused until the dependencies actually change.
# Every build tool lives in devDependencies, so no --omit=dev here.
#
# --legacy-peer-deps works around a conflict that predates this Dockerfile:
# openapi-typescript@7.13.0 peer-requires typescript@^5.x while the project is on ^6.0.3,
# so plain `npm ci` fails ERESOLVE on every npm version tested (10.8, 10.9, 11.17).
# The flag only skips peer *validation* — the tree installed is still exactly
# package-lock.json (verified: typescript 6.0.3, lockfile unmodified).
# Drop the flag once package.json resolves that conflict.
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps
COPY . .
# Optional baked-in default. Precedence at runtime is
# /config.js > VITE_API_BASE_URL > the 'http://localhost:5000' in src/lib/api/client.ts.
# Declared after `npm ci` so passing it does not invalidate the dependency layer.
# client.ts uses `??`, so an empty string would beat its default: unset instead of exporting "".
ARG VITE_API_BASE_URL
# Same gate as the README's documented `npm run check`: a type regression fails the image.
RUN npm run check
RUN if [ -z "${VITE_API_BASE_URL:-}" ]; then unset VITE_API_BASE_URL; fi; npm run build
# Serve it. openapi.json / schema.d.ts are committed, so nothing here touches the live API.
FROM nginx:1.27-alpine
WORKDIR /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
COPY --from=build /app/build/ ./
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+277
View File
@@ -0,0 +1,277 @@
# LaDOSE.WebApp
Svelte 5 + SvelteKit front-end for `LaDOSE.Api`, styled with Tailwind CSS v4.
It ships as a static SPA (`@sveltejs/adapter-static`) and talks to the API over
JWT bearer auth, so it can be served from any static host.
## Requirements
Node ≥ 22.12 (the toolchain uses Vite 8). An `.nvmrc` is checked in:
```bash
nvm use # resolves lts/*
npm install
```
## Running
The API must be up — it serves on `http://localhost:5000` in development:
```bash
cd ../LaDOSE.Api && dotnet run # terminal 1
npm run dev # terminal 2 -> http://localhost:5173
```
Point the app at a different API with `VITE_API_BASE_URL` (see `.env.example`).
`LaDOSE.Api` already allows any origin with credentials, so no dev proxy is needed.
## Typed API access
`src/lib/api/schema.d.ts` is **generated** from the API's OpenAPI document — never
edit it by hand. Regenerate whenever a C# controller or DTO changes:
```bash
npm run api:sync # fetch openapi.json from the running API, then re-emit types
```
That is `api:fetch` (curl `/openapi/v1.json`, override the host with `LADOSE_API_URL`)
followed by `api:types` (`openapi-typescript`). `openapi.json` is committed so the
types can be rebuilt without a running API.
Because paths and DTOs come from the generated types, a renamed route or a changed
DTO field surfaces as a TypeScript error rather than a runtime 404.
| Module | Purpose |
| --- | --- |
| `src/lib/api/schema.d.ts` | Generated types — all 31 API paths and every DTO |
| `src/lib/api/schema-helpers.ts` | Friendly aliases (`ApplicationUserDTO`, `LoginRequest`, …) |
| `src/lib/api/client.ts` | `apiRequest` — bearer auth, JSON, `ApiError`; paths constrained to real routes |
| `src/lib/api/users.ts` | `login` against `/Users/auth` — the one unauthenticated call |
| `src/lib/api/errors.ts` | `toErrorMessage` — message to show, or redirect to `/login` on a 401 |
| `src/lib/api/tournaments.ts` | `listEvents` / `importSmashTournament` / `getResults`, authenticated from the session |
| `src/lib/api/games.ts` | `listGames` / `saveGame` / `deleteGame` / `searchSmashGames` |
| `src/lib/api/admin-users.ts` | `listUsers` / `listRoles` / `addUser` / `deleteUser` — all Admin-only |
| `src/lib/api/statistics.ts` | `getMatchStats` (set-level win/loss and head to head), `listVersusPlayers` / `getVersus` (one pairing, per game) |
| `src/lib/api/sheets.ts` | `getSheetsConfig` / `exportToSheets` — writes a ranking table into the club's Google Spreadsheet |
| `src/lib/tournaments/results.ts` | Pure reshaping of `TournamentsResultDTO`: ranking grid, per-game placements, WordPress HTML, CSV |
| `src/lib/tournaments/sheet.ts` | Pure: ranking grid → one spreadsheet tab, plus the tab title a selection suggests |
| `src/lib/statistics/aggregate.ts` | Pure aggregation for `/statistiques`: standings, per-game and per-event summaries, CSV |
| `src/lib/statistics/load.ts` | `loadEventResults` — per-event `GetResults` fan-out with progress, partial failure and abort |
| `src/lib/ui/PlayerPicker.svelte` | Filterable player list used twice on `/statistiques/players` |
| `src/lib/games/draft.ts` | `GameDTO` ⇄ editor form, including the blank-to-NULL rules |
| `src/lib/ui/classes.ts` | The Tailwind class strings shared by the pages |
| `src/lib/stores/session.svelte.ts` | Signed-in user, persisted to `localStorage`, drops expired JWTs |
Calling another endpoint takes one line, and the path is checked at compile time:
```ts
import { apiRequest, buildPath, session } from '$lib';
import type { GameDTO } from '$lib';
const games = await apiRequest<GameDTO[]>('/api/Game', { token: session.token });
const one = await apiRequest<GameDTO>(buildPath('/api/Game/{id}', { id: 3 }));
```
## Routes
- `/login` — username + password, posts to `POST /Users/auth`, stores the returned JWT
- `/` — guarded; greets the signed-in user with **Hello, \<name\>.** and offers sign-out
- `/tournaments` — guarded; the start.gg half of the old Avalonia `TournamentResultView`,
and the one-click push of the generated ranking table into the club's Google Spreadsheet
- `/statistiques` — redirects to `/statistiques/rankings` (the path the navbar used before
the section became two pages)
- `/statistiques/rankings` — guarded; standings, attendance and match statistics over a
chosen scope
- `/statistiques/players` — guarded; two players, every game they met in, all events
- `/games` — guarded; the game catalogue editor, from the Avalonia `GamesView`
- `/users`**Admin only**; add and remove accounts
`session.displayName` prefers `firstName lastName` and falls back to `username`.
### `/tournaments`
Ports the Smash.gg (start.gg) column of `LaDOSE.DesktopApp.Avalonia`:
1. **Import** — a slug (`start.gg/tournament/<slug>`) is sent to
`GET /api/Tournament/ParseSmash/{slug}`, which pulls the brackets, placements and
sets into the database. Every bracket must be `COMPLETED` or the API throws.
2. **Events**`GET /api/Event`, newest first. Tick one event for a single
tournament, or several to aggregate a ranking season. The regex box replaces the
selection with every matching event name (e.g. `Ranking #13\d{2}`).
3. **Generate results**`POST /api/Tournament/GetResults` with the selected ids;
the API applies the point rules in `ExternalProviderService`. Three views:
- *Ranking* — players × games with totals, highest first, plus CSV export and the
Google Sheets push below
- *By game* — placements and points for one game
- *HTML* — the podium table for the WordPress recap, with copy-to-clipboard
The Challonge half of the Avalonia view (date range, Challonge tournament list,
`ParseChallonge`) is deliberately not ported.
#### Google Sheets export
Next to **Export CSV** on the *Ranking* view: **Push to Google Sheets** writes that same
table straight into the club's spreadsheet, as one tab. It replaces the
download-then-import-by-hand step — the tab is the CSV, because `rankingToSheetTable`
reads the very same `RankingTable` that `buildCsv` does, so the two cannot drift.
Because `GetResults` merges everything it is given, selecting `Ranking #1301`, `#1302` and
`#1303` produces the cumulative table for that ranking day — which is why the tab title
defaults to the **latest** event in the selection (`Ranking #1303`). Override it in the
**Tab** box; leaving it blank uses the suggestion shown as the placeholder.
- The tab is **cleared and rewritten in place**. Formatting, notes and conditional
formatting survive (the write only sets `userEnteredValue`), but anything typed into it
by hand is lost — keep hand analysis in its own tab.
- Tabs the export does not name are **never touched or deleted**.
- Re-running changes nothing but the provenance footer's timestamp.
- Points and totals are written as **numbers**, so formulas over them keep working.
- Titles are sanitised server-side (Google forbids `: \ / ? * [ ]`, caps at 100 chars);
any rename is reported back in the success banner.
- Tab identity is the **title**, so renaming an event makes the next push write a new tab
beside the old one.
- The provenance footer, below the grid, names every event scored into the tab — so a
stale tab says so itself.
Server configuration lives in the `GoogleSheets` section — writer, target spreadsheet and
limits. The target is config-only because the sheet is replaced each year; see
`.env.example` for the one-time Google setup and the yearly swap. Set
`GoogleSheets:Writer` to `Logging` to see the exact payload in the API log without
touching a spreadsheet.
### `/statistiques/rankings`
**Rankings Statistiques.** Pick a scope — everything, the last 6/12 events, or a regex over event names — then
**Compute statistics**. Two independent sources feed the page, and they are kept apart
on purpose:
- **Points, placements, attendance** come from `POST /api/Tournament/GetResults`, called
**one event at a time**. The endpoint merges everything it is given and never says
which event a row came from, so per-event calls are the only way to get a time series
— and they contain the damage, because it throws a 500 on any bracket missing a rank-1
or rank-2 row. One broken import is reported as a skipped event instead of taking the
whole scope down with it.
- **Set-level win rates and head to head** come from `POST /api/Statistics/Matches`, in
a single call for the whole scope.
The **Matches** tab leads with its coverage line, and it matters: brackets imported
before set rows were persisted contribute placements but no matches, so those figures
can describe a fraction of the scope while the standings above cover all of it. Win
rates count **decided sets only**, so a player with no resolvable set is left out rather
than shown at 0%.
Other things worth knowing:
- Rank `999` is the service's "unplaced" sentinel (the participation bucket in
`ExternalProviderService`), so it never counts as a placement or a podium, and
**Best** shows `—`.
- Player names are merged case-insensitively, as everywhere else in the app.
- Brackets are counted per event, so a bracket name reused every month counts once
per event rather than once overall.
- Undated events sort last in the chart and the Events tab — `GET /api/Event` returns
newest first, which is the least misleading place to put them.
- `aggregate.ts` is pure, so it can be exercised under plain Node with fixtures, the
same way `src/lib/tournaments/results.ts` is.
### `/statistiques/players`
**Players Statistiques.** Pick two players and the page answers one question: how often
did they meet, and in which games. There is no event scope — the point of a pairing is
its whole history, and slicing it by season would only be the Rankings page again.
- The pickers come from `GET /api/Statistics/Players`, which lists **tournament
players** (the rows `set` points at), not the application accounts of `/users`. Only
players with at least one set in a bracket whose game is known are offered, so the
list can never suggest a player the breakdown must then report as empty.
- The breakdown comes from `GET /api/Statistics/Versus/{a}/{b}`, one request per
complete pairing, aborted and re-issued when either side changes. `winsA` is always
the first id's side.
- **A set carries no game.** The game belongs to the `Tournament` the set was played
in, and `Tournament.GameId` is nullable, so meetings in a bracket with no game cannot
be filed under one. The API excludes them from `games` and from the totals and counts
them in `unknownGameSets`; the page reports that number instead of hiding it — it is
the difference between "they never met" and "we cannot tell what they played".
- **Meetings** counts every recorded set, **Decided** only those with unequal scores.
A set with equal scores (including 0-0, and the `-1 / -1` a double DQ leaves behind)
happened, but nobody won it, so it is in neither record nor game counts. A single DQ
is stored as `-1` and clamps to zero games.
- `StatisticsService.AggregateVersus` is a pure static method over already-loaded rows,
like `Aggregate` beside it, so the win inference is testable without a database.
### `/games`
Ports `GamesView`: the list on the left (ordered by `Order`), an editor on the right.
- **Save** — `POST /api/Game`. `AddOrUpdate` inserts when `id` is 0 and otherwise
**replaces every column**, so the form always sends a complete `GameDTO`; blank
text fields are sent as `null`.
- **New game** — starts an empty draft with `id` 0 and the next free `Order`. The
desktop app instead posted a blank row immediately and let you fill it in after.
- **Delete** — `DELETE /api/Game/{id}`, behind a confirm.
- **Find on start.gg** — `GET /api/Game/smash/{name}` searches start.gg's videogame
catalogue using the long name (falling back to the name); picking a match fills
`smashId`. The ids listed are **start.gg** videogame ids, not LaDOSE game ids.
`smashId` is what bracket imports match on: a game without one collects its results
under a synthetic "GAME NOT FOUND" entry.
Unlike the desktop form, `imgUrl` is editable here — it is part of `GameDTO` and was
otherwise only reachable through the database.
### `/users` (Admin only)
Lists every account with its roles, creates accounts, and deletes them.
`POST /Users/register` used to be `[AllowAnonymous]` so that the first account could be
created. That route is gone; account creation is now `POST /Users/AddUser` and requires
the **Admin** role, so before this
page is reachable at all, one account has to be promoted directly in the database:
```bash
# edit the username in the file first, then:
psql "$LADOSE_DB" -v ON_ERROR_STOP=1 -f ../../Sql/2026-08-05_roles.sql
```
How roles work:
- They live in the pre-existing `applicationrole` / `applicationuserrole` tables. The
script seeds `Admin` and `User`; no schema change was needed.
- Only user management checks a role. **Every other endpoint is unchanged** — a plain
or role-less account can still use tournaments, games and the rest.
- The JWT carries only the user id. Roles are read from the database on every request
(`OnTokenValidated` in `Startup.cs`), so granting or revoking Admin applies to the
caller's next request instead of whenever their 16-minute token expires.
- `session.isAdmin` hides the link and the page, but that is cosmetic — the API is
what enforces access, and a non-admin calling these endpoints gets a 403.
- The API refuses to delete the caller's own account. Since only an admin can reach the
endpoint, that is what guarantees at least one admin always remains.
## Notes
- The API issues 16-minute tokens. A lapsed token is treated as signed out on load;
there is no refresh flow yet, so long sessions will need a re-login. The guarded
pages turn a 401 into a redirect back to `/login` (see `toErrorMessage`).
- The API has no exception middleware, so an unhandled server error arrives as an
HTML developer page. `ApiError` then carries only the status, which is why each
call site supplies its own fallback message.
- `GetResults` only fills `slug` when **one** event id is requested, so the
"Voir le Bracket" links appear only for a single-event export.
- The Sheets export posts a 7-deep body, so `Startup.cs` sets Newtonsoft's `MaxDepth` to
32. `MaxDepth` governs *reading*; at the previous value of 4 the request was rejected
before it reached the controller.
- Player names are merged case-insensitively across brackets, matching the desktop app.
- `src/routes/+layout.ts` sets `ssr = false`: the JWT lives in the browser, so there
is nothing meaningful to render on the server.
## Checks
```bash
npm run check # svelte-check (types + template diagnostics)
npm run lint # eslint, including type-aware rules
npm run test # vitest over the pure modules in $lib
npm run build # static build into ./build
```
`npm run test` covers `$lib/statistics`, `$lib/tournaments`, `$lib/games/draft` and the
small shared helpers beside them (`csv`, `events`, `format`). Those modules are pure by
design, so they run under plain Node with no API and no database.
@@ -0,0 +1,24 @@
#!/bin/sh
# Vite inlines import.meta.env.VITE_* at build time, so runtime configuration cannot
# come from an env var the app reads directly. Instead we write /config.js here, which
# src/app.html loads synchronously ahead of the bundle. One image, any environment.
set -eu
config_file=/usr/share/nginx/html/config.js
# Drop control characters (newlines included) so the value cannot break out of the
# string literal, then escape backslashes before double quotes. A URL containing
# & ? " or a trailing slash stays inert data.
value=$(printf '%s' "${LADOSE_API_BASE_URL:-}" | tr -d '\001-\037')
if [ -z "$value" ]; then
# Empty object, not an empty string: lets the app keep its baked-in default.
printf 'window.__LADOSE_CONFIG__ = {};\n' >"$config_file"
echo "config.js: LADOSE_API_BASE_URL unset, deferring to the built-in default" >&2
else
escaped=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
printf 'window.__LADOSE_CONFIG__ = { apiBaseUrl: "%s" };\n' "$escaped" >"$config_file"
echo "config.js: apiBaseUrl=$value" >&2
fi
exec "$@"
+81
View File
@@ -0,0 +1,81 @@
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import ts from 'typescript-eslint';
/**
* Lint only. Formatting is deliberately not enforced here there is no Prettier in
* this project, so any stylistic rule would fight the existing hand-kept style.
*
* Type-aware linting is on (`projectService`), which is what makes
* `no-floating-promises` able to see that an `async` handler's promise is dropped.
*/
export default ts.config(
js.configs.recommended,
...ts.configs.recommendedTypeChecked,
...svelte.configs.recommended,
{
languageOptions: {
globals: { ...globals.browser },
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
tsconfigRootDir: import.meta.dirname
}
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
rules: {
// The app ships no logging of its own; `hooks.client.ts` is the one exception
// and opts in explicitly below.
'no-console': 'error',
/*
* Off: this rule wants every href and goto() wrapped in `resolve()`, which
* matters only for an app served under a base path. This one is served from
* the root (see nginx.conf) and sets no `base`, so it would be 14 wrappers
* buying nothing. Revisit if the app ever moves under a sub-path.
*/
'svelte/no-navigation-without-resolve': 'off',
/*
* The regex placeholders on the event pickers need a literal `{` in an
* attribute, which in Svelte can only be written as a mustache.
*/
'svelte/no-useless-mustaches': ['error', { ignoreStringEscape: true }],
// This is the rule that catches an `async` function used directly as an
// event handler, where a rejection becomes an unhandled rejection.
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
]
}
},
{
files: ['src/hooks.client.ts'],
rules: { 'no-console': 'off' }
},
{
// This file and the Vite config are build tooling, outside the app's tsconfig
// project, so type-aware rules cannot resolve them.
files: ['eslint.config.js', 'vite.config.ts'],
...ts.configs.disableTypeChecked
},
{
// Generated from openapi.json by `npm run api:types`; not ours to lint.
ignores: [
'src/lib/api/schema.d.ts',
'build/',
'.svelte-kit/',
'node_modules/',
'static/config.js'
]
}
);
+58
View File
@@ -0,0 +1,58 @@
# Copied to /etc/nginx/conf.d/default.conf, replacing the image's default server.
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
# The build emits no .gz/.br, so compress on the fly.
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_types
application/javascript
application/json
application/manifest+json
application/wasm
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
# Rewritten by docker-entrypoint.sh on every container start. Exact match, so the
# _app/immutable rule below can never apply to it.
location = /config.js {
add_header Cache-Control "no-store" always;
try_files $uri =404;
}
# Content-hashed filenames: safe forever.
location /_app/immutable/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
# Deploy marker the client polls. Stale copies mean updates are never noticed.
location = /_app/version.json {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
# The shell. Same reason as version.json: it names the current immutable bundle.
location = /index.html {
add_header Cache-Control "no-cache" always;
}
# Nothing is prerendered (+layout.ts sets ssr = false, prerender = false) and the
# adapter's fallback is index.html, so every unknown path belongs to the client
# router. Without this, /login, /games, /tournaments, /users and /statistiques 404
# on deep-link or hard refresh. The internal redirect re-enters `location =
# /index.html`, so fallback responses pick up no-cache too.
location / {
try_files $uri $uri/ /index.html;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "ladose.webapp",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"api:fetch": "curl -fsS ${LADOSE_API_URL:-http://localhost:5000}/openapi/v1.json -o openapi.json",
"api:types": "openapi-typescript openapi.json -o src/lib/api/schema.d.ts",
"api:sync": "npm run api:fetch && npm run api:types",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.3",
"eslint": "^10.8.0",
"eslint-plugin-svelte": "^3.22.0",
"globals": "^17.9.0",
"openapi-typescript": "^7.13.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.66.0",
"vite": "^8.0.16",
"vitest": "^4.1.10"
},
"engines": {
"node": "^20.19 || ^22.12 || >=24"
}
}
+218
View File
@@ -0,0 +1,218 @@
@import 'tailwindcss';
/*
* Colour is expressed twice over:
*
* 1. the LaDOSE brand ramp below fixed values, theme-independent;
* 2. semantic tokens (canvas, surface, ink, accent, ) that every component
* actually uses, and that swap wholesale between light and dark.
*
* Components must only ever reach for the semantic layer. Adding `dark:` next to
* a hundred hard-coded shades would double every future edit; one set of tokens
* that changes underneath them does not.
*/
@theme {
--color-ladose-50: #f2f6ff;
--color-ladose-100: #e4ebff;
--color-ladose-200: #c3d4ff;
--color-ladose-300: #9db4ff;
--color-ladose-400: #6f8fff;
--color-ladose-500: #4b6cff;
--color-ladose-600: #3450e6;
--color-ladose-700: #2a3fb8;
--color-ladose-800: #1e2d80;
--color-ladose-900: #131a3d;
--color-ladose-950: #0b1027;
}
/*
* `inline` is load-bearing. Without it Tailwind emits `--color-surface:
* var(--app-surface)` into :root and the utility resolves through that copy, so
* redefining --app-surface in a more specific selector does nothing. `inline`
* substitutes the reference straight into the utility, which is what makes
* runtime theme switching work at all in Tailwind v4.
*/
@theme inline {
--color-canvas: var(--app-canvas);
--color-surface: var(--app-surface);
--color-overlay: var(--app-overlay);
--color-inset: var(--app-inset);
--color-line: var(--app-line);
--color-line-strong: var(--app-line-strong);
--color-ink: var(--app-ink);
--color-muted: var(--app-muted);
--color-subtle: var(--app-subtle);
--color-accent: var(--app-accent);
--color-accent-hover: var(--app-accent-hover);
--color-on-accent: var(--app-on-accent);
--color-danger: var(--app-danger);
--color-danger-soft: var(--app-danger-soft);
--color-success: var(--app-success);
--color-success-soft: var(--app-success-soft);
--color-warning: var(--app-warning);
--color-warning-soft: var(--app-warning-soft);
--shadow-card: var(--app-shadow);
}
/*
* Palette values live here once each. The three blocks that follow only
* re-point the --app-* aliases, so a colour is never written twice.
*/
:root {
--light-canvas: #f6f7fb;
--light-surface: #ffffff;
--light-overlay: #ffffff;
--light-inset: #f1f3f9;
--light-line: #dfe3ee;
--light-line-strong: #c6cddf;
--light-ink: #101430;
--light-muted: #4c5470;
--light-subtle: #767e99;
--light-accent: var(--color-ladose-600);
--light-accent-hover: var(--color-ladose-700);
--light-on-accent: #ffffff;
--light-danger: #b42318;
--light-danger-soft: rgb(180 35 24 / 0.09);
--light-success: #067647;
--light-success-soft: rgb(6 118 71 / 0.09);
--light-warning: #b54708;
--light-warning-soft: rgb(181 71 8 / 0.09);
--light-shadow: 0 1px 2px rgb(16 24 40 / 0.06), 0 10px 28px rgb(16 24 40 / 0.08);
--light-grad-from: #ffffff;
--light-grad-to: #eaeef8;
/* Translucent surfaces on purpose: they sit over the canvas gradient and
keep the depth the app had before tokens existed. */
--dark-canvas: var(--color-ladose-950);
--dark-surface: rgb(255 255 255 / 0.05);
/* Menus and popovers sit over content, so they need to be opaque. */
--dark-overlay: #171e3d;
--dark-inset: rgb(11 16 39 / 0.6);
--dark-line: rgb(255 255 255 / 0.1);
--dark-line-strong: rgb(255 255 255 / 0.16);
--dark-ink: var(--color-ladose-50);
--dark-muted: rgb(195 212 255 / 0.7);
--dark-subtle: rgb(195 212 255 / 0.45);
--dark-accent: var(--color-ladose-500);
--dark-accent-hover: var(--color-ladose-600);
--dark-on-accent: #ffffff;
--dark-danger: #fca5a5;
--dark-danger-soft: rgb(239 68 68 / 0.12);
--dark-success: #6ee7b7;
--dark-success-soft: rgb(16 185 129 / 0.12);
--dark-warning: #fcd34d;
--dark-warning-soft: rgb(245 158 11 / 0.12);
--dark-shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 16px 40px rgb(0 0 0 / 0.35);
--dark-grad-from: var(--color-ladose-900);
--dark-grad-to: var(--color-ladose-950);
}
/* Layer 1 — light is the unconditional default. */
:root {
--app-color-scheme: light;
--app-canvas: var(--light-canvas);
--app-surface: var(--light-surface);
--app-overlay: var(--light-overlay);
--app-inset: var(--light-inset);
--app-line: var(--light-line);
--app-line-strong: var(--light-line-strong);
--app-ink: var(--light-ink);
--app-muted: var(--light-muted);
--app-subtle: var(--light-subtle);
--app-accent: var(--light-accent);
--app-accent-hover: var(--light-accent-hover);
--app-on-accent: var(--light-on-accent);
--app-danger: var(--light-danger);
--app-danger-soft: var(--light-danger-soft);
--app-success: var(--light-success);
--app-success-soft: var(--light-success-soft);
--app-warning: var(--light-warning);
--app-warning-soft: var(--light-warning-soft);
--app-shadow: var(--light-shadow);
--app-grad-from: var(--light-grad-from);
--app-grad-to: var(--light-grad-to);
}
/*
* Layer 2 follow the OS to dark, unless the user explicitly chose light.
* The :not() is what lets an explicit light choice win on a dark machine: a bare
* :root in layer 1 could never outrank a later media block on its own.
*/
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--app-color-scheme: dark;
--app-canvas: var(--dark-canvas);
--app-surface: var(--dark-surface);
--app-overlay: var(--dark-overlay);
--app-inset: var(--dark-inset);
--app-line: var(--dark-line);
--app-line-strong: var(--dark-line-strong);
--app-ink: var(--dark-ink);
--app-muted: var(--dark-muted);
--app-subtle: var(--dark-subtle);
--app-accent: var(--dark-accent);
--app-accent-hover: var(--dark-accent-hover);
--app-on-accent: var(--dark-on-accent);
--app-danger: var(--dark-danger);
--app-danger-soft: var(--dark-danger-soft);
--app-success: var(--dark-success);
--app-success-soft: var(--dark-success-soft);
--app-warning: var(--dark-warning);
--app-warning-soft: var(--dark-warning-soft);
--app-shadow: var(--dark-shadow);
--app-grad-from: var(--dark-grad-from);
--app-grad-to: var(--dark-grad-to);
}
}
/* Layer 3 — an explicit dark choice beats an OS light preference. */
:root[data-theme='dark'] {
--app-color-scheme: dark;
--app-canvas: var(--dark-canvas);
--app-surface: var(--dark-surface);
--app-overlay: var(--dark-overlay);
--app-inset: var(--dark-inset);
--app-line: var(--dark-line);
--app-line-strong: var(--dark-line-strong);
--app-ink: var(--dark-ink);
--app-muted: var(--dark-muted);
--app-subtle: var(--dark-subtle);
--app-accent: var(--dark-accent);
--app-accent-hover: var(--dark-accent-hover);
--app-on-accent: var(--dark-on-accent);
--app-danger: var(--dark-danger);
--app-danger-soft: var(--dark-danger-soft);
--app-success: var(--dark-success);
--app-success-soft: var(--dark-success-soft);
--app-warning: var(--dark-warning);
--app-warning-soft: var(--dark-warning-soft);
--app-shadow: var(--dark-shadow);
--app-grad-from: var(--dark-grad-from);
--app-grad-to: var(--dark-grad-to);
}
/*
* The canvas has to be painted by CSS on html/body, not by a class on a Svelte
* element: ssr is off, so that element does not exist until the bundle hydrates.
* Anything that relies on hydration to paint the background flashes white on
* every cold load of every route.
*/
html {
color-scheme: var(--app-color-scheme);
background-color: var(--app-canvas);
}
body {
min-height: 100svh;
background-image: radial-gradient(ellipse at top, var(--app-grad-from), var(--app-grad-to));
background-repeat: no-repeat;
background-attachment: fixed;
color: var(--app-ink);
}
/* Height of the fixed navbar, so pages that centre themselves can subtract it. */
:root {
--nav-h: 3.5rem;
}
+31
View File
@@ -0,0 +1,31 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
/**
* Build-time configuration Vite inlines into the bundle. Declared so the reads in
* `$lib/api/client` are typed rather than `any` see `resolveBaseUrl` for how
* this relates to the runtime `window.__LADOSE_CONFIG__` tier.
*/
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
/** Shape of the object `static/config.js` defines, rewritten at container start. */
interface Window {
__LADOSE_CONFIG__?: { apiBaseUrl?: string };
}
}
export {};
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<!--
Runtime configuration, written by the container entrypoint from
LADOSE_API_BASE_URL. Synchronous and ahead of the app bundle so
src/lib/api/client.ts can read it at module scope. Absent in `vite dev`,
which is why the client falls back to VITE_API_BASE_URL.
-->
<script src="%sveltekit.assets%/config.js"></script>
<!--
Theme, ahead of the head placeholder below that emits the render-blocking
stylesheet, so data-theme is already on <html> when its selectors are first
matched. Do not name that placeholder literally anywhere above it: SvelteKit
substitutes the first textual occurrence, so a mention in prose swallows the
real one and the built page ships with no stylesheet.
Must stay a plain synchronous inline script: type=module, defer, async or
an external file all run after first paint, which is the flash we are
avoiding. Only ever writes 'light' or 'dark' — the CSS keys off those two
exact values, so a literal data-theme="system" would match neither.
-->
<script>
try {
var t = localStorage.getItem('ladose.theme');
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
} catch (e) {
/* localStorage throws in Safari private mode and with cookies blocked */
}
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,19 @@
import type { HandleClientError } from '@sveltejs/kit';
/**
* Last resort for errors no page caught an uncaught error in an `$effect`, or a
* failed `load`. Without this they surface nowhere at all: the app logs nothing
* anywhere else, so a blank panel is the only symptom a user could report.
*
* There is no telemetry backend to ship these to, so the console is the whole
* story. It is the one place in the app where `console.error` is intentional.
*/
export const handleError: HandleClientError = ({ error, status, message }) => {
if (status !== 404) console.error('[LaDOSE]', error);
// What `+error.svelte` renders. Deliberately generic: `error` can carry API
// internals, and the pages already surface anything the user can act on.
return {
message: status === 404 ? message : 'An unexpected error occurred.'
};
};
@@ -0,0 +1,48 @@
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { ApplicationUserDTO, NewUserRequest } from './schema-helpers';
/**
* User administration. Every endpoint here is `[Authorize(Roles = "Admin")]`, so a
* non-admin gets a 403 the UI hides the page, but the API is what enforces it.
*
* `login` and the session live in `users.ts`; this module is only the admin screen.
*/
/** GET /Users — every account, ordered by username. Never includes password material. */
export function listUsers(options: RequestOptions = {}): Promise<ApplicationUserDTO[]> {
return apiRequest<ApplicationUserDTO[]>('/Users', options);
}
/**
* GET /Users/Roles the role names that exist in `applicationrole`. Assigning a name
* that is not in this list is rejected by the API rather than creating a new role.
*/
export function listRoles(options: RequestOptions = {}): Promise<string[]> {
return apiRequest<string[]>('/Users/Roles', options);
}
/**
* POST /Users/AddUser replaces the old anonymous `register`. Returns the created
* user. A duplicate username or an unknown role name comes back as a 400 carrying
* the reason in `message`.
*/
export function addUser(
user: NewUserRequest,
options: RequestOptions = {}
): Promise<ApplicationUserDTO> {
return apiRequest<ApplicationUserDTO>('/Users/AddUser', {
...options,
method: 'POST',
body: user
});
}
/**
* DELETE /Users/{id} 204 on success. The API refuses to delete the caller's own
* account (400), which is what guarantees an admin always remains.
*/
export async function deleteUser(id: number, options: RequestOptions = {}): Promise<void> {
await apiRequest<void>(buildPath('/Users/{id}', { id }), {
...options,
method: 'DELETE'
});
}
@@ -0,0 +1,127 @@
import { session } from '$lib/stores/session.svelte';
import type { ApiPath } from './schema-helpers';
/**
* Base URL of LaDOSE.Api, resolved in this order:
*
* 1. `window.__LADOSE_CONFIG__.apiBaseUrl` written by the container entrypoint
* from `LADOSE_API_BASE_URL` (see Dockerfile). This is what lets one image
* serve several environments: Vite inlines `import.meta.env` at *build* time,
* so without it the URL would be frozen into the bundle.
* 2. `VITE_API_BASE_URL` baked at build time, for `vite dev` and for anyone
* who prefers one image per environment.
* 3. the Kestrel binding the API uses in development (LaDOSE.Api/appsettings.json).
*/
function resolveBaseUrl(): string {
// `typeof window` rather than a browser guard: this runs at module scope, and
// the static build still evaluates this module while generating the shell.
const runtime =
typeof window !== 'undefined' ? window.__LADOSE_CONFIG__?.apiBaseUrl : undefined;
// Vite replaces this with a string literal at build time, or leaves it undefined
// when the variable was not set (see ImportMetaEnv in app.d.ts).
const baked = import.meta.env.VITE_API_BASE_URL;
const configured = runtime?.trim() || baked || 'http://localhost:5000';
return configured.replace(/\/$/, '');
}
export const API_BASE_URL = resolveBaseUrl();
/** Thrown for any non-2xx response, carrying the API's message when it sent one. */
export class ApiError extends Error {
constructor(
readonly status: number,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
/** The shape LaDOSE.Api uses for its error payloads: `new { message = "..." }`. */
function extractMessage(body: unknown, status: number): string {
if (body && typeof body === 'object' && 'message' in body) {
const { message } = body as { message?: unknown };
if (typeof message === 'string' && message.length > 0) return message;
}
return `Request failed with status ${status}`;
}
export interface RequestOptions {
method?: 'GET' | 'POST' | 'DELETE';
body?: unknown;
/**
* JWT sent as `Authorization: Bearer ...`.
*
* Left out, the session store's token is used almost every endpoint on
* LaDOSE.Api is `[Authorize]`, so authenticated is the useful default. Pass
* `null` to send the request unauthenticated (`POST /Users/auth` is the only
* caller that needs to), or a string to override the stored token.
*/
token?: string | null;
fetch?: typeof globalThis.fetch;
signal?: AbortSignal;
}
/**
* Calls LaDOSE.Api. `path` is constrained to the paths in the generated OpenAPI
* types, so a typo or a route removed on the server is a compile error.
* Templated paths (e.g. `/api/Game/{id}`) are built with `buildPath`, whose
* branded return type is the only other thing accepted here widening this to
* `string` would silently re-admit routes the server no longer serves.
*/
export async function apiRequest<TResponse>(
path: ApiPath | BuiltPath,
options: RequestOptions = {}
): Promise<TResponse> {
const { method = 'GET', body, fetch: fetchImpl = globalThis.fetch, signal } = options;
const token = options.token === undefined ? session.token : options.token;
const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (token) headers['Authorization'] = `Bearer ${token}`;
let response: Response;
try {
response = await fetchImpl(`${API_BASE_URL}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal
});
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
// fetch only rejects on transport failures — the API being down, DNS, CORS.
const error = new ApiError(0, `Could not reach LaDOSE.Api at ${API_BASE_URL}`);
error.cause = cause;
throw error;
}
const isJson = response.headers.get('content-type')?.includes('json') ?? false;
const payload: unknown = isJson ? await response.json().catch(() => null) : null;
if (!response.ok) {
throw new ApiError(response.status, extractMessage(payload, response.status));
}
return payload as TResponse;
}
/**
* A path already filled in by `buildPath`. Branded so `apiRequest` can accept it
* without accepting `string`, which would defeat the `ApiPath` constraint.
*/
export type BuiltPath = string & { readonly __apiPath: unique symbol };
/** Fills a templated OpenAPI path, e.g. buildPath('/api/Game/{id}', { id: 3 }). */
export function buildPath(
template: ApiPath,
params: Record<string, string | number>
): BuiltPath {
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = params[key];
if (value === undefined) throw new Error(`Missing route parameter "${key}" for ${template}`);
return encodeURIComponent(String(value));
}) as BuiltPath;
}
@@ -0,0 +1,38 @@
import { goto } from '$app/navigation';
import { session } from '$lib/stores/session.svelte';
import { ApiError } from './client';
/**
* Turns a failed call into something to show the user.
*
* A 401 means the 16-minute JWT lapsed mid-session: there is nothing useful to
* display, so the session is dropped and the user is sent back to `/login`, and
* this returns null.
*/
export function toErrorMessage(cause: unknown, fallback: string): string | null {
if (cause instanceof ApiError && cause.status === 401) {
session.clear();
void goto('/login', { replaceState: true });
return null;
}
// ApiError already carries the API's own `message`, or "unreachable" for status 0.
return cause instanceof ApiError ? cause.message : fallback;
}
/**
* Binds `toErrorMessage` to a page's error state, so every page reports failures
* the same way:
*
* const report = errorReporter((message) => (error = message));
* ...
* catch (cause) { report(cause, 'Could not load the accounts.'); }
*
* Pages used to split between a hand-copied `report` wrapper and calling
* `toErrorMessage` inline two conventions for one behaviour.
*/
export function errorReporter(
set: (message: string | null) => void
): (cause: unknown, fallback: string) => void {
return (cause, fallback) => set(toErrorMessage(cause, fallback));
}
@@ -0,0 +1,40 @@
/** GameController is `[Authorize]`; `apiRequest` supplies the session JWT. */
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { GameDTO } from './schema-helpers';
/** GET /api/Game — every game, in database order (sort by `order` for display). */
export function listGames(options: RequestOptions = {}): Promise<GameDTO[]> {
return apiRequest<GameDTO[]>('/api/Game', options);
}
/**
* POST /api/Game `AddOrUpdate`: an `id` of 0 inserts, anything else updates.
* The update replaces every column, so send a full DTO rather than a patch.
* Returns the saved game with its assigned id.
*/
export function saveGame(game: GameDTO, options: RequestOptions = {}): Promise<GameDTO> {
return apiRequest<GameDTO>('/api/Game', { ...options, method: 'POST', body: game });
}
/**
* DELETE /api/Game/{id} 204 on success. The service swallows `DbUpdateException`,
* so a game still referenced by tournaments comes back as a 404 rather than a 409.
*/
export async function deleteGame(id: number, options: RequestOptions = {}): Promise<void> {
await apiRequest<void>(buildPath('/api/Game/{id}', { id }), {
...options,
method: 'DELETE'
});
}
/**
* GET /api/Game/smash/{name} searches start.gg's videogame catalogue by name.
* The `id` of each match is a **start.gg** videogame id, i.e. a candidate value for
* `GameDTO.smashId`, not a LaDOSE game id.
*/
export function searchSmashGames(
name: string,
options: RequestOptions = {}
): Promise<GameDTO[]> {
return apiRequest<GameDTO[]>(buildPath('/api/Game/smash/{name}', { name }), options);
}
@@ -0,0 +1,53 @@
import type { components, paths } from './schema';
/**
* Re-exports of the DTOs generated from LaDOSE.Api's OpenAPI document
* (see `npm run api:types`). Import these instead of hand-writing shapes so the
* client breaks at compile time when the C# DTOs change.
*/
export type Schemas = components['schemas'];
export type ApplicationUserDTO = Schemas['ApplicationUserDTO'];
export type GameDTO = Schemas['GameDTO'];
export type EventDTO = Schemas['EventDTO'];
export type TodoDTO = Schemas['TodoDTO'];
export type WPEventDTO = Schemas['WPEventDTO'];
export type TournamentDTO = Schemas['TournamentDTO'];
export type TournamentsResultDTO = Schemas['TournamentsResultDTO'];
export type ResultDTO = Schemas['ResultDTO'];
export type ParticipentDTO = Schemas['ParticipentDTO'];
export type MatchStatsDTO = Schemas['MatchStatsDTO'];
export type MatchCoverageDTO = Schemas['MatchCoverageDTO'];
export type PlayerMatchStatsDTO = Schemas['PlayerMatchStatsDTO'];
export type HeadToHeadDTO = Schemas['HeadToHeadDTO'];
export type PlayerVersusDTO = Schemas['PlayerVersusDTO'];
export type VersusGameStatsDTO = Schemas['VersusGameStatsDTO'];
export type PlayerOptionDTO = Schemas['PlayerOptionDTO'];
export type SheetsConfigDTO = Schemas['SheetsConfigDTO'];
export type SheetExportRequestDTO = Schemas['SheetExportRequestDTO'];
export type SheetTableDTO = Schemas['SheetTableDTO'];
export type SheetRowDTO = Schemas['SheetRowDTO'];
export type SheetExportResultDTO = Schemas['SheetExportResultDTO'];
export type SheetTabResultDTO = Schemas['SheetTabResultDTO'];
/** The body `UsersController.Authenticate` binds — only these two fields are read. */
export type LoginRequest = Pick<ApplicationUserDTO, 'username' | 'password'>;
/** The body `UsersController.AddUser` binds. `roles` must name rows of `applicationrole`. */
export type NewUserRequest = Pick<
ApplicationUserDTO,
'username' | 'password' | 'firstName' | 'lastName' | 'roles'
>;
/**
* A logged-in user is the auth response with the fields the API always fills in
* narrowed to non-optional, so pages don't have to null-check `token`/`username`.
*/
export type AuthenticatedUser = ApplicationUserDTO & {
username: string;
token: string;
};
/** Every path exposed by LaDOSE.Api, e.g. `'/Users/auth'` or `'/api/Game'`. */
export type ApiPath = keyof paths;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
/** SheetsController is `[Authorize]`, like the rest of the API. */
import { apiRequest, type RequestOptions } from './client';
import type {
SheetExportRequestDTO,
SheetExportResultDTO,
SheetsConfigDTO
} from './schema-helpers';
/**
* GET /api/Sheets/Config whether the export is usable and which spreadsheet it points
* at. The target is server configuration (it is reset each year), so the UI reads it
* rather than offering it as an input. Carries no credentials.
*/
export function getSheetsConfig(options: RequestOptions = {}): Promise<SheetsConfigDTO> {
return apiRequest<SheetsConfigDTO>('/api/Sheets/Config', options);
}
/**
* POST /api/Sheets/Export writes one tab per table, in the order given.
*
* Existing tabs of the same name are cleared and rewritten in place, keeping their
* formatting; tabs not named in the request are left untouched. Re-running the same export
* is a no-op beyond the provenance timestamp.
*/
export function exportToSheets(
request: SheetExportRequestDTO,
options: RequestOptions = {}
): Promise<SheetExportResultDTO> {
return apiRequest<SheetExportResultDTO>('/api/Sheets/Export', {
...options,
method: 'POST',
body: request
});
}
@@ -0,0 +1,55 @@
/** StatisticsController is `[Authorize]`, like the tournament endpoints. */
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { MatchStatsDTO, PlayerOptionDTO, PlayerVersusDTO } from './schema-helpers';
/**
* POST /api/Statistics/Matches set-level statistics (win/loss, games, head to
* head) for the given events, read from the `set` rows an import persisted.
*
* Unlike `GetResults` this takes every event in one call: the service tolerates
* brackets with no set rows instead of throwing, and reports what it could see in
* `coverage`. Always show that coverage many older brackets were imported
* without sets, so a low `bracketsWithSets` means these numbers describe only a
* slice of the scope.
*/
export function getMatchStats(
eventIds: number[],
options: RequestOptions = {}
): Promise<MatchStatsDTO> {
return apiRequest<MatchStatsDTO>('/api/Statistics/Matches', {
...options,
method: 'POST',
body: eventIds
});
}
/**
* GET /api/Statistics/Players the tournament players a versus lookup can report
* on, alphabetically. **Not** the application accounts of `/api/Users`: these are
* the players `set` rows point at, and `sets` counts their meetings in brackets
* whose game is known the same filter `getVersus` applies.
*/
export function listVersusPlayers(options: RequestOptions = {}): Promise<PlayerOptionDTO[]> {
return apiRequest<PlayerOptionDTO[]>('/api/Statistics/Players', options);
}
/**
* GET /api/Statistics/Versus/{a}/{b} every recorded meeting between two players,
* over every event, split per game. `winsA` is always the first id's side.
*
* A set only knows its bracket, and a bracket's game is nullable, so meetings we
* cannot attribute to a game are excluded from `games` and the totals and counted
* in `unknownGameSets` instead. Show that number: it is the difference between
* "they never met" and "we cannot tell what they played".
*/
export function getVersus(
playerAId: number,
playerBId: number,
options: RequestOptions = {}
): Promise<PlayerVersusDTO> {
const path = buildPath('/api/Statistics/Versus/{playerAId}/{playerBId}', {
playerAId,
playerBId
});
return apiRequest<PlayerVersusDTO>(path, options);
}
@@ -0,0 +1,48 @@
/**
* TournamentController and EventController are both `[Authorize]`. `apiRequest`
* defaults the JWT to the session store's, so nothing here passes one explicitly.
*/
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { EventDTO, TournamentsResultDTO } from './schema-helpers';
/**
* GET /api/Event every imported event, newest first (the controller orders by
* `Date` descending). A start.gg tournament becomes one Event holding one
* Tournament per bracket.
*/
export function listEvents(options: RequestOptions = {}): Promise<EventDTO[]> {
return apiRequest<EventDTO[]>('/api/Event', options);
}
/**
* GET /api/Tournament/ParseSmash/{slug} pulls a start.gg tournament (brackets,
* placements, sets) into the database. Returns false only for an empty slug;
* re-importing an already-known slug is a no-op that still returns true.
* The API throws (500) when the tournament has an unfinished bracket.
*/
export function importSmashTournament(
slug: string,
options: RequestOptions = {}
): Promise<boolean> {
const path = buildPath('/api/Tournament/ParseSmash/{tournamentSlug}', {
tournamentSlug: slug
});
return apiRequest<boolean>(path, options);
}
/**
* POST /api/Tournament/GetResults scores the given events with the point rules
* in ExternalProviderService and returns the merged participants/games/results.
* Pass one id for a single tournament or several to aggregate a ranking season.
* Note: the API only fills `slug` when exactly one id is requested.
*/
export function getResults(
eventIds: number[],
options: RequestOptions = {}
): Promise<TournamentsResultDTO> {
return apiRequest<TournamentsResultDTO>('/api/Tournament/GetResults', {
...options,
method: 'POST',
body: eventIds
});
}
@@ -0,0 +1,25 @@
import { apiRequest } from './client';
import type { ApplicationUserDTO, AuthenticatedUser, LoginRequest } from './schema-helpers';
/**
* POST /Users/auth returns the user plus a JWT valid for 16 minutes
* (see UsersController.Authenticate). Throws ApiError(400) on bad credentials.
*/
export async function login(credentials: LoginRequest): Promise<AuthenticatedUser> {
const user = await apiRequest<ApplicationUserDTO>('/Users/auth', {
method: 'POST',
body: credentials,
// The only unauthenticated endpoint: opt out of the session token that
// `apiRequest` would otherwise attach, so signing in as a second user while
// a stale session is still in memory sends only the credentials.
token: null
});
// The generated DTO marks every field optional because the C# properties are
// nullable reference types; the auth path always populates these two.
if (!user?.token || !user.username) {
throw new Error('LaDOSE.Api returned an authentication response without a token.');
}
return user as AuthenticatedUser;
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,74 @@
import { goto } from '$app/navigation';
import { session } from '$lib/stores/session.svelte';
/**
* The client-side route guard, in one place.
*
* Every protected page used to carry its own copy of this effect plus a `started`
* latch five verbatim copies, which meant a new protected route shipped unguarded
* whenever someone forgot to paste it.
*
* This is presentation only. The API authorises every request independently and
* re-reads the caller's roles from the database, so a guard here never decides
* access; it only avoids rendering a shell that can produce nothing but 401s
* and 403s.
*/
export interface Guard {
/**
* Whether the page may render. Gate the page's `<main>` on this: `goto` is
* asynchronous, so without it an unauthenticated deep link paints the whole
* page for a frame before the redirect lands.
*/
readonly ready: boolean;
}
interface GuardOptions {
/** Also require the Admin role, sending non-admins to the dashboard. */
admin?: boolean;
/** Run once, the first time the guard admits the user. For the initial fetch. */
onReady?: () => void;
}
function guard(options: GuardOptions = {}): Guard {
const { admin = false, onReady } = options;
let ready = $state(false);
let started = false;
$effect(() => {
if (!session.isLoggedIn) {
ready = false;
void goto('/login', { replaceState: true });
return;
}
if (admin && !session.isAdmin) {
ready = false;
void goto('/', { replaceState: true });
return;
}
ready = true;
if (!started) {
started = true;
onReady?.();
}
});
return {
get ready() {
return ready;
}
};
}
/** Requires a signed-in user. Call once, at the top level of a page component. */
export function requireSession(onReady?: () => void): Guard {
return guard({ onReady });
}
/** Requires a signed-in user holding the Admin role. */
export function requireAdmin(onReady?: () => void): Guard {
return guard({ admin: true, onReady });
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { quote, toCsv } from './csv';
describe('quote', () => {
it('wraps every field, including numbers', () => {
expect(quote('Bob')).toBe('"Bob"');
expect(quote(12)).toBe('"12"');
expect(quote('')).toBe('""');
});
it('doubles inner quotes rather than escaping them', () => {
expect(quote('He said "hi"')).toBe('"He said ""hi"""');
});
it('leaves separators alone — quoting is what makes them safe', () => {
expect(quote('Smash; Melee')).toBe('"Smash; Melee"');
expect(quote('line\nbreak')).toBe('"line\nbreak"');
});
});
describe('toCsv', () => {
it('emits CRLF between rows and a trailing CRLF', () => {
expect(toCsv(['A', 'B'], [['1', '2']])).toBe('"A";"B"\r\n"1";"2"\r\n');
});
it('writes a header-only document when there are no rows', () => {
expect(toCsv(['A'], [])).toBe('"A"\r\n');
});
it('is semicolon separated, which is what Excel expects here', () => {
expect(toCsv(['A', 'B'], []).includes(';')).toBe(true);
expect(toCsv(['A', 'B'], []).includes(',')).toBe(false);
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* The one CSV dialect this app writes.
*
* Excel is picky: semicolon separated, every field quoted, inner quotes doubled,
* CRLF line endings and a trailing newline. `buildCsv` (tournaments) and
* `standingsCsv` (statistics) both used to carry their own copy of this they
* were character-identical, and a comment in one pointed at the other rather than
* sharing it.
*
* Pure: exercisable under plain Node.
*/
const SEPARATOR = ';';
const NEWLINE = '\r\n';
/** One field, quoted, with inner quotes doubled. */
export function quote(value: string | number): string {
return `"${String(value).replaceAll('"', '""')}"`;
}
/** A full document: header row, then one row per record, CRLF-terminated throughout. */
export function toCsv(header: readonly (string | number)[], rows: readonly (string | number)[][]): string {
const lines = [header.map(quote).join(SEPARATOR)];
for (const row of rows) lines.push(row.map(quote).join(SEPARATOR));
return lines.join(NEWLINE) + NEWLINE;
}
@@ -0,0 +1,33 @@
/**
* Client-side file download.
*
* The rankings and tournaments pages each carried their own copy of this. Both
* clicked an anchor that was never in the document and revoked the object URL on
* the very next line browsers that hand the blob to a download manager
* asynchronously can produce a silently empty file. This appends the anchor,
* clicks it, removes it, and defers the revoke to the next task.
*/
/** Triggers a download of `content` as `filename`. Browser only. */
export function downloadText(filename: string, content: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.append(link);
link.click();
link.remove();
// Revoking synchronously can cut the download off before the browser has read
// the blob; one task later is enough and still frees the memory.
setTimeout(() => URL.revokeObjectURL(url), 0);
}
/** `downloadText` with the CSV content type the exports use. */
export function downloadCsv(filename: string, content: string): void {
downloadText(filename, content, 'text/csv;charset=utf-8');
}
@@ -0,0 +1,94 @@
import type { EventDTO } from '$lib/api/schema-helpers';
import { describe, expect, it } from 'vitest';
import { compareByDate, eventIds, identified, matchingEventIds, toggleId } from './events';
function event(partial: Partial<EventDTO>): EventDTO {
return { id: 1, name: 'Ranking #1301', date: '2026-01-10T00:00:00', ...partial };
}
describe('identified / eventIds', () => {
it('drops events the API gave no id', () => {
const events = [event({ id: 1 }), event({ id: undefined }), event({ id: 3 })];
expect(eventIds(events)).toEqual([1, 3]);
expect(identified(events)).toHaveLength(2);
});
it('keeps id 0, which is a real id and not "missing"', () => {
expect(eventIds([event({ id: 0 })])).toEqual([0]);
});
});
describe('toggleId', () => {
it('adds then removes', () => {
expect(toggleId([], 3)).toEqual([3]);
expect(toggleId([1, 3], 3)).toEqual([1]);
});
it('ignores undefined instead of pushing a hole into the selection', () => {
expect(toggleId([1], undefined)).toEqual([1]);
});
it('does not mutate the input', () => {
const selected = [1];
toggleId(selected, 2);
expect(selected).toEqual([1]);
});
});
describe('matchingEventIds', () => {
const events = [
event({ id: 1, name: 'Ranking #1301' }),
event({ id: 2, name: 'Ranking #1302' }),
event({ id: 3, name: 'Tournoi de Noël' }),
event({ id: 4, name: undefined })
];
it('selects by regular expression', () => {
expect(matchingEventIds(events, String.raw`Ranking #13\d{2}`).ids).toEqual([1, 2]);
});
it('is case sensitive', () => {
expect(matchingEventIds(events, 'ranking').ids).toEqual([]);
});
it('never matches an event with no name', () => {
expect(matchingEventIds(events, '').ids).toEqual([]);
expect(matchingEventIds(events, '.*').ids).toEqual([1, 2, 3]);
});
it('reports an invalid pattern instead of throwing', () => {
const match = matchingEventIds(events, 'Ranking #13(');
expect(match.ids).toEqual([]);
expect(match.error).toContain('not a valid regular expression');
});
it('treats a blank pattern as no request at all', () => {
expect(matchingEventIds(events, ' ')).toEqual({ ids: [], error: null });
});
});
describe('compareByDate', () => {
const older = '2026-01-01T00:00:00';
const newer = '2026-06-01T00:00:00';
it('orders ascending by default and descending on request', () => {
expect(compareByDate(older, newer, 'asc')).toBeLessThan(0);
expect(compareByDate(older, newer, 'desc')).toBeGreaterThan(0);
});
it('ties on equal dates, so callers can add their own tiebreak', () => {
expect(compareByDate(older, older, 'asc')).toBe(0);
});
it('sorts undated last in both directions — they cannot claim to be latest', () => {
for (const direction of ['asc', 'desc'] as const) {
expect(compareByDate(older, null, direction)).toBeLessThan(0);
expect(compareByDate(null, older, direction)).toBeGreaterThan(0);
}
});
it('treats an unparseable date as undated', () => {
expect(compareByDate(older, 'not a date', 'asc')).toBeLessThan(0);
expect(compareByDate(null, undefined, 'asc')).toBe(0);
});
});
@@ -0,0 +1,92 @@
/**
* Event-selection helpers shared by the tournaments and rankings pages, which both
* present the same "tick events, or select all / recent / by pattern" scope picker.
*
* `EventDTO.id` is optional because the generated DTO mirrors nullable C# reference
* types, so every consumer used to repeat `.filter(e => e.id !== undefined)` followed
* by an `as number` cast. `eventIds` narrows properly and removes the cast.
*
* Pure: exercisable under plain Node.
*/
import type { EventDTO } from '$lib/api/schema-helpers';
/** An event the API gave an id, so it can be requested. */
export type IdentifiedEvent = EventDTO & { id: number };
export function hasId(event: EventDTO): event is IdentifiedEvent {
return event.id !== undefined;
}
/** The requestable events of a list, in order. */
export function identified(events: EventDTO[]): IdentifiedEvent[] {
return events.filter(hasId);
}
/** Just the ids — what the scoring endpoints take. */
export function eventIds(events: EventDTO[]): number[] {
return identified(events).map((event) => event.id);
}
/** Adds or removes an id, ignoring the undefined case. Returns a new array. */
export function toggleId(selected: number[], id: number | undefined): number[] {
if (id === undefined) return selected;
return selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id];
}
export interface PatternMatch {
/** Ids of the events whose name matched. Empty when the pattern was invalid. */
ids: number[];
/** Set when the pattern would not compile — show it instead of the match count. */
error: string | null;
}
/**
* Selects events by regular expression on their name, used for things like
* `Ranking #13\d{2}`. Case-sensitive, and events with no name never match.
*
* An invalid pattern is a user typo, not a bug, so it comes back as a message
* rather than throwing. A blank pattern selects nothing and reports nothing
* callers treat it as "no request made" and leave the selection alone.
*/
export function matchingEventIds(events: EventDTO[], pattern: string): PatternMatch {
const value = pattern.trim();
if (!value) return { ids: [], error: null };
let regex: RegExp;
try {
regex = new RegExp(value);
} catch {
return { ids: [], error: `"${value}" is not a valid regular expression.` };
}
return {
ids: identified(events)
.filter((event) => event.name && regex.test(event.name))
.map((event) => event.id),
error: null
};
}
/**
* Compares events by date. Events the API gave no date for cannot claim to be the
* latest, so they always sort last regardless of direction.
*
* `'desc'` is newest first (which event names a spreadsheet tab); `'asc'` is oldest
* first (which way a chart reads).
*/
export function compareByDate(
a: string | null | undefined,
b: string | null | undefined,
direction: 'asc' | 'desc' = 'asc'
): number {
const aTime = a ? Date.parse(a) : NaN;
const bTime = b ? Date.parse(b) : NaN;
const aOk = Number.isFinite(aTime);
const bOk = Number.isFinite(bTime);
if (aOk && bOk) return direction === 'asc' ? aTime - bTime : bTime - aTime;
if (aOk) return -1;
if (bOk) return 1;
return 0;
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { fullName, percent } from './format';
describe('percent', () => {
it('keeps one decimal below 100', () => {
expect(percent(0)).toBe('0.0%');
expect(percent(66.666)).toBe('66.7%');
expect(percent(99.9)).toBe('99.9%');
});
it('collapses to a bare 100% from 99.95 up, so the column never widens', () => {
expect(percent(99.95)).toBe('100%');
expect(percent(100)).toBe('100%');
});
});
describe('fullName', () => {
it('joins both names', () => {
expect(fullName('Ada', 'Lovelace')).toBe('Ada Lovelace');
});
it('uses whichever half is present', () => {
expect(fullName('Ada', null)).toBe('Ada');
expect(fullName(undefined, 'Lovelace')).toBe('Lovelace');
});
it('falls back when there is no name at all', () => {
expect(fullName(null, null)).toBe('');
expect(fullName(null, undefined, 'ada')).toBe('ada');
expect(fullName('', '', 'ada')).toBe('ada');
});
});
@@ -0,0 +1,24 @@
/**
* Small display helpers that were duplicated across routes.
*
* Pure: exercisable under plain Node.
*/
/**
* A percentage already on a 0..100 scale, rendered for a narrow table column:
* one decimal below 100, and a bare "100%" from 99.95 up so the column never
* widens to "100.0%".
*/
export function percent(value: number): string {
return value >= 99.95 ? '100%' : `${value.toFixed(1)}%`;
}
/** First and last name if either is set, otherwise the fallback (usually the username). */
export function fullName(
firstName: string | null | undefined,
lastName: string | null | undefined,
fallback = ''
): string {
const full = [firstName, lastName].filter(Boolean).join(' ').trim();
return full.length > 0 ? full : fallback;
}
@@ -0,0 +1,93 @@
import type { GameDTO } from '$lib/api/schema-helpers';
import { describe, expect, it } from 'vitest';
import { blankDraft, isDirty, nextOrder, toDraft, toDto } from './draft';
describe('toDraft / toDto', () => {
it('replaces every null with a value the inputs can bind to', () => {
const draft = toDraft({ id: 3, name: null, longName: null, order: undefined, smashId: undefined });
expect(draft).toEqual({
id: 3,
name: '',
longName: '',
order: 0,
imgUrl: '',
wordPressTag: '',
wordPressTagOs: '',
smashId: null
});
});
it('stores blank and whitespace-only text as NULL, not as an empty string', () => {
const dto = toDto({ ...blankDraft, name: 'SF6', longName: ' ', imgUrl: '' });
expect(dto.longName).toBeNull();
expect(dto.imgUrl).toBeNull();
expect(dto.name).toBe('SF6');
});
it('trims text on the way out', () => {
expect(toDto({ ...blankDraft, name: ' SF6 ' }).name).toBe('SF6');
});
it('round-trips a full game', () => {
const game: GameDTO = {
id: 4,
name: 'SF6',
longName: 'Street Fighter 6',
order: 2,
imgUrl: 'https://example.test/sf6.png',
wordPressTag: 'sf6',
wordPressTagOs: 'sf6-os',
smashId: 43868
};
expect(toDto(toDraft(game))).toEqual(game);
});
});
describe('isDirty', () => {
/*
* The editor used to compare `JSON.stringify` output, which is key-order
* dependent: an identical draft built with its keys in another order read as
* unsaved changes.
*/
it('is false for equal drafts regardless of key order', () => {
const a = { ...blankDraft, name: 'SF6', order: 2 };
const reordered = {
order: 2,
smashId: null,
wordPressTagOs: '',
wordPressTag: '',
imgUrl: '',
longName: '',
name: 'SF6',
id: 0
};
expect(JSON.stringify(a)).not.toBe(JSON.stringify(reordered));
expect(isDirty(a, reordered)).toBe(false);
});
it('detects a change in any field', () => {
const base = { ...blankDraft, name: 'SF6' };
expect(isDirty(base, { ...base, name: 'SFV' })).toBe(true);
expect(isDirty(base, { ...base, order: 9 })).toBe(true);
expect(isDirty(base, { ...base, smashId: 1 })).toBe(true);
expect(isDirty(base, { ...base })).toBe(false);
});
it('distinguishes null from 0 for the nullable numbers', () => {
expect(isDirty({ ...blankDraft, smashId: null }, { ...blankDraft, smashId: 0 })).toBe(true);
});
});
describe('nextOrder', () => {
it('sorts a new game after the current last one', () => {
expect(nextOrder([{ order: 1 }, { order: 5 }, { order: 3 }])).toBe(6);
});
it('starts at 1 for an empty catalogue', () => {
expect(nextOrder([])).toBe(1);
});
it('treats a missing order as 0', () => {
expect(nextOrder([{ order: undefined }])).toBe(1);
});
});
@@ -0,0 +1,85 @@
import type { GameDTO } from '$lib/api/schema-helpers';
/**
* The shape the game editor binds to. Every field is present and non-null so the
* inputs never see `undefined`, and `POST /api/Game` always receives a whole DTO
* `AddOrUpdate` replaces every column, so a partial body would blank the rest.
*
* `id` 0 marks an unsaved game: the API inserts on 0 and updates otherwise.
*/
export interface Draft {
id: number;
name: string;
longName: string;
/** Null while the number input sits empty; `Game.Order` is a non-nullable int. */
order: number | null;
imgUrl: string;
wordPressTag: string;
wordPressTagOs: string;
smashId: number | null;
}
export const blankDraft: Draft = {
id: 0,
name: '',
longName: '',
order: 0,
imgUrl: '',
wordPressTag: '',
wordPressTagOs: '',
smashId: null
};
export function toDraft(game: GameDTO): Draft {
return {
id: game.id ?? 0,
name: game.name ?? '',
longName: game.longName ?? '',
order: game.order ?? 0,
imgUrl: game.imgUrl ?? '',
wordPressTag: game.wordPressTag ?? '',
wordPressTagOs: game.wordPressTagOs ?? '',
smashId: game.smashId ?? null
};
}
/** Blank text is stored as NULL rather than as an empty string. */
export function toDto(draft: Draft): GameDTO {
const text = (value: string) => (value.trim() === '' ? null : value.trim());
return {
id: draft.id,
name: text(draft.name),
longName: text(draft.longName),
order: draft.order ?? 0,
imgUrl: text(draft.imgUrl),
wordPressTag: text(draft.wordPressTag),
wordPressTagOs: text(draft.wordPressTagOs),
smashId: draft.smashId
};
}
/** A new game sorts after the current last one. */
export function nextOrder(games: GameDTO[]): number {
return games.reduce((max, game) => Math.max(max, game.order ?? 0), 0) + 1;
}
/**
* Whether the editor holds unsaved changes.
*
* Field by field rather than by comparing `JSON.stringify` output, which is
* key-order dependent: it would report a clean draft as dirty the moment
* `blankDraft` and `toDraft` listed their keys in a different order.
*/
export function isDirty(a: Draft, b: Draft): boolean {
return (
a.id !== b.id ||
a.name !== b.name ||
a.longName !== b.longName ||
a.order !== b.order ||
a.imgUrl !== b.imgUrl ||
a.wordPressTag !== b.wordPressTag ||
a.wordPressTagOs !== b.wordPressTagOs ||
a.smashId !== b.smashId
);
}
@@ -0,0 +1,206 @@
import type { EventDTO, ResultDTO } from '$lib/api/schema-helpers';
import { describe, expect, it } from 'vitest';
import { aggregate, formatMonth, standingsCsv, type EventResult } from './aggregate';
/** The sentinel ExternalProviderService emits for a player it could not place. */
const UNRANKED = 999;
const sf6 = { id: 1, name: 'SF6', longName: 'Street Fighter 6' };
const ssbu = { id: 2, name: 'SSBU', longName: 'Smash Ultimate' };
function row(partial: Partial<ResultDTO>): ResultDTO {
return { gameId: 1, player: 'Bob', rank: 1, point: 10, tournamentUrl: 'SF6', ...partial };
}
function scored(event: Partial<EventDTO>, results: ResultDTO[]): EventResult {
return {
event: { id: 1, name: 'Ranking #1301', date: '2026-01-10T00:00:00', ...event },
result: { games: [sf6, ssbu], results }
};
}
describe('aggregate — standings', () => {
it('sums points across events and counts distinct events and games', () => {
const stats = aggregate([
scored({ id: 1 }, [row({ player: 'Bob', point: 10 })]),
scored({ id: 2 }, [row({ player: 'Bob', point: 7, gameId: 2, tournamentUrl: 'SSBU' })])
]);
expect(stats.standings).toHaveLength(1);
expect(stats.standings[0]).toMatchObject({
player: 'Bob',
points: 17,
entries: 2,
events: 2,
games: 2
});
});
it('merges names that differ only in case or padding, keeping the first spelling', () => {
const stats = aggregate([
scored({}, [row({ player: 'Bob' }), row({ player: ' BOB ', rank: 2, point: 7 })])
]);
expect(stats.standings).toHaveLength(1);
expect(stats.standings[0]).toMatchObject({ player: 'Bob', points: 17, entries: 2 });
});
it('counts podium places and their total', () => {
const stats = aggregate([
scored({}, [
row({ player: 'Bob', rank: 1 }),
row({ player: 'Bob', rank: 2 }),
row({ player: 'Bob', rank: 3 }),
row({ player: 'Bob', rank: 4 })
])
]);
expect(stats.standings[0]).toMatchObject({
firsts: 1,
seconds: 1,
thirds: 1,
podiums: 3,
entries: 4
});
});
/*
* 999 is a "we could not place this player" bucket, not a placement. Reading it
* as one would report a best rank of 999 for anyone who only ever participated.
*/
it('never reads the 999 sentinel as a placement', () => {
const stats = aggregate([scored({}, [row({ player: 'Bob', rank: UNRANKED })])]);
expect(stats.standings[0].bestRank).toBeNull();
expect(stats.standings[0].podiums).toBe(0);
});
it('keeps the best real rank when a player also has unranked entries', () => {
const stats = aggregate([
scored({}, [row({ player: 'Bob', rank: UNRANKED }), row({ player: 'Bob', rank: 4 })])
]);
expect(stats.standings[0].bestRank).toBe(4);
});
it('sorts by points, then firsts, then podiums, then name', () => {
const stats = aggregate([
scored({}, [
row({ player: 'Amy', point: 10, rank: 4 }),
row({ player: 'Zoe', point: 10, rank: 1 }),
row({ player: 'Cid', point: 20, rank: 4 })
])
]);
// Cid leads on points; Zoe beats Amy on firsts at equal points.
expect(stats.standings.map((s) => s.player)).toEqual(['Cid', 'Zoe', 'Amy']);
});
it('ignores rows with no player name', () => {
const stats = aggregate([scored({}, [row({ player: ' ' }), row({ player: 'Bob' })])]);
expect(stats.standings.map((s) => s.player)).toEqual(['Bob']);
});
});
describe('aggregate — games', () => {
it('counts entries, distinct players and brackets per game', () => {
const stats = aggregate([
scored({}, [
row({ gameId: 1, player: 'Bob', tournamentUrl: 'SF6 A' }),
row({ gameId: 1, player: 'Amy', tournamentUrl: 'SF6 A' }),
row({ gameId: 1, player: 'Amy', tournamentUrl: 'SF6 B' })
])
]);
const game = stats.games.find((g) => g.gameId === 1);
expect(game).toMatchObject({ name: 'SF6', entries: 3, players: 2, brackets: 2 });
expect(game?.averageField).toBeCloseTo(1.5);
});
it('scopes brackets to their event, since bracket names repeat monthly', () => {
const stats = aggregate([
scored({ id: 1 }, [row({ tournamentUrl: 'SF6' })]),
scored({ id: 2 }, [row({ tournamentUrl: 'SF6' })])
]);
expect(stats.games[0].brackets).toBe(2);
});
it('reports the highest scoring player of each game', () => {
const stats = aggregate([
scored({}, [
row({ gameId: 1, player: 'Bob', point: 5 }),
row({ gameId: 1, player: 'Amy', point: 12 })
])
]);
expect(stats.games[0]).toMatchObject({ topPlayer: 'Amy', topPoints: 12 });
});
it('falls back to a placeholder name for a game the payload never described', () => {
const stats = aggregate([
{
event: { id: 1, name: 'Ranking' },
result: { games: [], results: [row({ gameId: 42 })] }
}
]);
expect(stats.games[0]).toMatchObject({ gameId: 42, name: '#42' });
});
});
describe('aggregate — attendance and totals', () => {
it('orders attendance oldest first, so a chart reads left to right', () => {
const stats = aggregate([
scored({ id: 2, name: 'June', date: '2026-06-01T00:00:00' }, [row({})]),
scored({ id: 1, name: 'January', date: '2026-01-01T00:00:00' }, [row({})])
]);
expect(stats.attendance.map((a) => a.name)).toEqual(['January', 'June']);
});
it('places undated events last, keeping their incoming order', () => {
const stats = aggregate([
scored({ id: 3, name: 'Undated A', date: undefined }, [row({})]),
scored({ id: 1, name: 'January', date: '2026-01-01T00:00:00' }, [row({})]),
scored({ id: 4, name: 'Undated B', date: undefined }, [row({})])
]);
expect(stats.attendance.map((a) => a.name)).toEqual(['January', 'Undated A', 'Undated B']);
});
it('totals events, entries, distinct players and points across the scope', () => {
const stats = aggregate([
scored({ id: 1 }, [row({ player: 'Bob', point: 10 }), row({ player: 'Amy', point: 7 })]),
scored({ id: 2 }, [row({ player: 'Bob', point: 3 })])
]);
expect(stats.totals).toMatchObject({ events: 2, entries: 3, players: 2, points: 20 });
});
it('returns an empty aggregate for an empty scope', () => {
const stats = aggregate([]);
expect(stats.standings).toEqual([]);
expect(stats.games).toEqual([]);
expect(stats.attendance).toEqual([]);
expect(stats.totals).toMatchObject({ events: 0, entries: 0, players: 0, points: 0 });
});
});
describe('standingsCsv', () => {
it('writes the header and one quoted row per player', () => {
const stats = aggregate([scored({}, [row({ player: 'Bob', point: 10, rank: 1 })])]);
const lines = standingsCsv(stats.standings).split('\r\n');
expect(lines[0]).toBe(
'"Player";"Points";"Entries";"Events";"Games";"1st";"2nd";"3rd";"Podiums";"Best rank"'
);
expect(lines[1]).toBe('"Bob";"10";"1";"1";"1";"1";"0";"0";"1";"1"');
expect(lines[2]).toBe('');
});
it('renders an absent best rank as an empty field rather than "null"', () => {
const stats = aggregate([scored({}, [row({ player: 'Bob', rank: UNRANKED })])]);
expect(standingsCsv(stats.standings)).toContain(';""\r\n');
});
});
describe('formatMonth', () => {
it('is empty for a missing or unparseable date', () => {
expect(formatMonth(null)).toBe('');
expect(formatMonth('not a date')).toBe('');
});
it('renders a month and year', () => {
expect(formatMonth('2026-08-05T00:00:00')).toMatch(/2026/);
});
});
@@ -0,0 +1,317 @@
import type { EventDTO, GameDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
import { toCsv } from '$lib/csv';
import { compareByDate } from '$lib/events';
/**
* Aggregation for the Statistiques page. Pure: it takes results already fetched
* per event and reshapes them, so it can be exercised under plain Node the same
* way `$lib/tournaments/results.ts` is.
*
* Why per event rather than one batched call: `POST /api/Tournament/GetResults`
* merges everything it is given and never says which event a row came from, so
* per-event scoping is the only way to get a time series. It also contains the
* blast radius the endpoint throws on a bracket with no rank-1 row, and one bad
* event would otherwise take the whole batch with it.
*/
/** One event's scored results, as returned for that event alone. */
export interface EventResult {
event: EventDTO;
result: TournamentsResultDTO;
}
export interface PlayerStanding {
player: string;
points: number;
/** Bracket entries, i.e. result rows — a player counts once per bracket entered. */
entries: number;
events: number;
games: number;
firsts: number;
seconds: number;
thirds: number;
podiums: number;
/** Best placement seen, ignoring the 999 "rank unknown" sentinel. */
bestRank: number | null;
}
export interface GameSummary {
gameId: number;
name: string;
longName: string;
entries: number;
players: number;
brackets: number;
/** Highest scoring player in this game over the scope. */
topPlayer: string | null;
topPoints: number;
/** Mean entries per bracket — 0 when the game has no bracket in scope. */
averageField: number;
}
export interface EventAttendance {
eventId: number;
name: string;
/** ISO date from EventDTO.date, or null when the API did not supply one. */
date: string | null;
players: number;
entries: number;
games: number;
brackets: number;
}
export interface Totals {
events: number;
brackets: number;
entries: number;
players: number;
points: number;
}
export interface Aggregate {
standings: PlayerStanding[];
games: GameSummary[];
attendance: EventAttendance[];
totals: Totals;
}
/**
* The service emits 999 for players it could not place (the participation bucket
* in ExternalProviderService), so it must never be read as a placement.
*/
const UNRANKED = 999;
/** Names come from different brackets and only ever match case-insensitively. */
function key(name: string): string {
return name.trim().toUpperCase();
}
function displayName(name: string): string {
return name.trim();
}
interface PlayerAccumulator {
player: string;
points: number;
entries: number;
events: Set<number>;
games: Set<number>;
firsts: number;
seconds: number;
thirds: number;
bestRank: number | null;
}
interface GameAccumulator {
game: GameDTO;
entries: number;
players: Set<string>;
brackets: Set<string>;
pointsByPlayer: Map<string, { player: string; points: number }>;
}
export function aggregate(loaded: EventResult[]): Aggregate {
const players = new Map<string, PlayerAccumulator>();
const games = new Map<number, GameAccumulator>();
const attendance: EventAttendance[] = [];
let totalBrackets = 0;
let totalEntries = 0;
let totalPoints = 0;
for (const { event, result } of loaded) {
const eventId = event.id ?? 0;
const rows = result.results ?? [];
const eventPlayers = new Set<string>();
const eventGames = new Set<number>();
const eventBrackets = new Set<string>();
for (const row of rows) {
const name = row.player?.trim();
if (!name) continue;
const gameId = row.gameId ?? 0;
const points = row.point ?? 0;
const rank = row.rank ?? UNRANKED;
const bracket = `${gameId}::${row.tournamentUrl ?? ''}`;
eventPlayers.add(key(name));
eventGames.add(gameId);
eventBrackets.add(bracket);
totalEntries++;
totalPoints += points;
let player = players.get(key(name));
if (!player) {
player = {
player: displayName(name),
points: 0,
entries: 0,
events: new Set(),
games: new Set(),
firsts: 0,
seconds: 0,
thirds: 0,
bestRank: null
};
players.set(key(name), player);
}
player.points += points;
player.entries++;
player.events.add(eventId);
player.games.add(gameId);
if (rank === 1) player.firsts++;
else if (rank === 2) player.seconds++;
else if (rank === 3) player.thirds++;
if (rank !== UNRANKED && (player.bestRank === null || rank < player.bestRank)) {
player.bestRank = rank;
}
let game = games.get(gameId);
if (!game) {
const meta = (result.games ?? []).find((candidate) => candidate.id === gameId);
game = {
game: meta ?? { id: gameId, name: `#${gameId}`, longName: `#${gameId}` },
entries: 0,
players: new Set(),
brackets: new Set(),
pointsByPlayer: new Map()
};
games.set(gameId, game);
}
game.entries++;
game.players.add(key(name));
// Bracket names repeat every month, so scope them to the event.
game.brackets.add(`${eventId}::${bracket}`);
const forPlayer = game.pointsByPlayer.get(key(name)) ?? {
player: displayName(name),
points: 0
};
forPlayer.points += points;
game.pointsByPlayer.set(key(name), forPlayer);
}
totalBrackets += eventBrackets.size;
attendance.push({
eventId,
name: event.name ?? `#${eventId}`,
date: event.date ?? null,
players: eventPlayers.size,
entries: rows.length,
games: eventGames.size,
brackets: eventBrackets.size
});
}
return {
standings: buildStandings(players),
games: buildGameSummaries(games),
attendance: sortByDate(attendance),
totals: {
events: loaded.length,
brackets: totalBrackets,
entries: totalEntries,
players: players.size,
points: totalPoints
}
};
}
function buildStandings(players: Map<string, PlayerAccumulator>): PlayerStanding[] {
return [...players.values()]
.map((p) => ({
player: p.player,
points: p.points,
entries: p.entries,
events: p.events.size,
games: p.games.size,
firsts: p.firsts,
seconds: p.seconds,
thirds: p.thirds,
podiums: p.firsts + p.seconds + p.thirds,
bestRank: p.bestRank
}))
.sort(
(a, b) =>
b.points - a.points ||
b.firsts - a.firsts ||
b.podiums - a.podiums ||
a.player.localeCompare(b.player)
);
}
function buildGameSummaries(games: Map<number, GameAccumulator>): GameSummary[] {
return [...games.values()]
.map((g) => {
const top = [...g.pointsByPlayer.values()].sort(
(a, b) => b.points - a.points || a.player.localeCompare(b.player)
)[0];
return {
gameId: g.game.id ?? 0,
name: g.game.name ?? `#${g.game.id ?? 0}`,
longName: g.game.longName ?? g.game.name ?? `#${g.game.id ?? 0}`,
entries: g.entries,
players: g.players.size,
brackets: g.brackets.size,
topPlayer: top?.player ?? null,
topPoints: top?.points ?? 0,
averageField: g.brackets.size === 0 ? 0 : g.entries / g.brackets.size
};
})
.sort((a, b) => b.entries - a.entries || a.name.localeCompare(b.name));
}
/**
* Oldest first, so a chart reads left to right. Events the API gave no date for
* keep their incoming order and sort last `GET /api/Event` returns newest
* first, so that is still the least misleading placement for them.
*/
function sortByDate(attendance: EventAttendance[]): EventAttendance[] {
return attendance
.map((entry, index) => ({ entry, index }))
.sort(
(a, b) =>
compareByDate(a.entry.date, b.entry.date, 'asc') || a.index - b.index
)
.map(({ entry }) => entry);
}
/** `2026-08-05T00:00:00` → `Aug 2026`. Falls back to the event name's own text. */
export function formatMonth(date: string | null): string {
if (!date) return '';
const parsed = Date.parse(date);
if (!Number.isFinite(parsed)) return '';
return new Date(parsed).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
}
/** Semicolon-separated, quoted — the dialect `$lib/csv` defines. */
export function standingsCsv(standings: PlayerStanding[]): string {
const header = [
'Player',
'Points',
'Entries',
'Events',
'Games',
'1st',
'2nd',
'3rd',
'Podiums',
'Best rank'
];
return toCsv(
header,
standings.map((row) => [
row.player,
row.points,
row.entries,
row.events,
row.games,
row.firsts,
row.seconds,
row.thirds,
row.podiums,
row.bestRank ?? ''
])
);
}
@@ -0,0 +1,122 @@
import { ApiError } from '$lib/api/client';
import type { EventDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
import { beforeEach, describe, expect, it, vi } from 'vitest';
/*
* `loadEventResults` exists to contain the blast radius of one bad event, so what
* matters here is the failure behaviour: a broken event must not take the others
* with it, while an abort or a 401 must stop everything.
*/
// Typed so the assertions below read the call arguments without falling back to `any`.
const getResults = vi.hoisted(() =>
vi.fn<(ids: number[], options?: { signal?: AbortSignal }) => Promise<TournamentsResultDTO>>()
);
vi.mock('$lib/api/tournaments', () => ({ getResults }));
const { loadEventResults } = await import('./load');
function events(count: number): EventDTO[] {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Event ${i + 1}`,
date: undefined
}));
}
beforeEach(() => {
getResults.mockReset();
});
describe('loadEventResults', () => {
it('requests one event at a time and returns them all', async () => {
getResults.mockImplementation((ids: number[]) =>
Promise.resolve({ results: [{ player: `P${ids[0]}` }] })
);
const outcome = await loadEventResults(events(3));
expect(outcome.loaded).toHaveLength(3);
expect(outcome.failed).toEqual([]);
// One id per call — batching is what this module exists to avoid.
expect(getResults.mock.calls.map((c) => c[0])).toEqual([[1], [2], [3]]);
});
it('skips events with no id rather than requesting undefined', async () => {
getResults.mockResolvedValue({ results: [] });
await loadEventResults([{ id: 1, name: 'A' }, { name: 'B' }]);
expect(getResults).toHaveBeenCalledTimes(1);
});
it('isolates a failing event and reports it, keeping the rest', async () => {
getResults.mockImplementation((ids: number[]) =>
ids[0] === 2
? Promise.reject(new ApiError(500, 'Bracket has no rank-1 row'))
: Promise.resolve({ results: [] })
);
const outcome = await loadEventResults(events(3));
expect(outcome.loaded).toHaveLength(2);
expect(outcome.failed).toHaveLength(1);
expect(outcome.failed[0].event.id).toBe(2);
expect(outcome.failed[0].message).toBe('Bracket has no rank-1 row');
});
it('stops everything on a 401 — retrying N times would just burn requests', async () => {
getResults.mockRejectedValue(new ApiError(401, 'Unauthorized'));
await expect(loadEventResults(events(3))).rejects.toThrow(ApiError);
});
it('propagates an abort rather than recording it as a failed event', async () => {
getResults.mockRejectedValue(new DOMException('Aborted', 'AbortError'));
await expect(loadEventResults(events(2))).rejects.toThrow(DOMException);
});
it('makes no request at all when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
const outcome = await loadEventResults(events(3), { signal: controller.signal });
expect(getResults).not.toHaveBeenCalled();
expect(outcome.loaded).toEqual([]);
});
it('reports progress once per settled event, failures included', async () => {
getResults.mockImplementation((ids: number[]) =>
ids[0] === 2 ? Promise.reject(new ApiError(500, 'boom')) : Promise.resolve({ results: [] })
);
const seen: number[] = [];
await loadEventResults(events(3), { onProgress: (done, total) => {
expect(total).toBe(3);
seen.push(done);
} });
expect(seen).toEqual([1, 2, 3]);
});
it('never runs more requests at once than the concurrency allows', async () => {
let inFlight = 0;
let peak = 0;
getResults.mockImplementation(async () => {
inFlight++;
peak = Math.max(peak, inFlight);
await Promise.resolve();
inFlight--;
return { results: [] };
});
await loadEventResults(events(10), { concurrency: 3 });
expect(peak).toBeLessThanOrEqual(3);
expect(getResults).toHaveBeenCalledTimes(10);
});
it('handles an empty scope without spawning workers', async () => {
const outcome = await loadEventResults([]);
expect(outcome).toEqual({ loaded: [], failed: [] });
expect(getResults).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,72 @@
import { ApiError } from '$lib/api/client';
import type { EventDTO } from '$lib/api/schema-helpers';
import { getResults } from '$lib/api/tournaments';
import type { EventResult } from './aggregate';
/**
* Fetches scored results one event at a time.
*
* `POST /api/Tournament/GetResults` throws a 500 when any bracket in the request
* is missing a rank-1 or rank-2 row, so asking for thirty events in one call means
* one broken import hides all thirty. Per-event requests cost more round trips but
* degrade to "we skipped event #97" instead of "statistics are unavailable".
*/
export interface FailedEvent {
event: EventDTO;
message: string;
}
export interface LoadOutcome {
loaded: EventResult[];
failed: FailedEvent[];
}
export interface LoadOptions {
/** Called after each event settles, for a progress indicator. */
onProgress?: (done: number, total: number) => void;
/** Abort further requests, e.g. when the user changes scope mid-load. */
signal?: AbortSignal;
/** Parallel requests. Enough to be quick, few enough to be polite to the API. */
concurrency?: number;
}
export async function loadEventResults(
events: EventDTO[],
options: LoadOptions = {}
): Promise<LoadOutcome> {
const { onProgress, signal, concurrency = 6 } = options;
const targets = events.filter((event) => event.id !== undefined);
const loaded: EventResult[] = [];
const failed: FailedEvent[] = [];
let done = 0;
let next = 0;
async function worker() {
while (next < targets.length) {
if (signal?.aborted) return;
const event = targets[next++];
try {
const result = await getResults([event.id as number], { signal });
loaded.push({ event, result });
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
// A 401 has to stop everything: retrying N times just burns requests.
if (cause instanceof ApiError && cause.status === 401) throw cause;
failed.push({
event,
message: cause instanceof ApiError ? cause.message : 'Request failed'
});
}
onProgress?.(++done, targets.length);
}
}
const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
await Promise.all(workers);
return { loaded, failed };
}

Some files were not shown because too many files have changed in this diff Show More