Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f934e69c90 | ||
|
|
937b8554dd | ||
|
|
c9a3c252e1 | ||
|
|
a9860f4c94 | ||
|
|
d9e05fb487 | ||
|
|
e10663c8c0 | ||
|
|
cd03b39c20 | ||
|
|
9d8a7b3100 | ||
|
|
e99479d8fb | ||
|
|
a9150ff58c | ||
|
|
fba822a0af | ||
|
|
73407e5867 |
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LaDOSE.DTO
|
||||||
|
{
|
||||||
|
public class MatchStatsDTO
|
||||||
|
{
|
||||||
|
public MatchCoverageDTO Coverage { get; set; }
|
||||||
|
public List<PlayerMatchStatsDTO> Players { get; set; }
|
||||||
|
public List<HeadToHeadDTO> HeadToHead { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MatchCoverageDTO
|
||||||
|
{
|
||||||
|
/// <summary>Requested event ids that actually exist.</summary>
|
||||||
|
public int Events { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Tournaments (brackets) belonging to those events.</summary>
|
||||||
|
public int Brackets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Of those brackets, how many have at least one set row.</summary>
|
||||||
|
public int BracketsWithSets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total set rows in scope, including unusable ones.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Sets with a determinable winner.</summary>
|
||||||
|
public int DecidedSets { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlayerMatchStatsDTO
|
||||||
|
{
|
||||||
|
public int PlayerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||||
|
public string Player { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
public int Wins { get; set; }
|
||||||
|
public int Losses { get; set; }
|
||||||
|
public int GamesWon { get; set; }
|
||||||
|
public int GamesLost { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HeadToHeadDTO
|
||||||
|
{
|
||||||
|
public int PlayerAId { get; set; }
|
||||||
|
public string PlayerA { get; set; }
|
||||||
|
public int PlayerBId { get; set; }
|
||||||
|
public string PlayerB { get; set; }
|
||||||
|
public int WinsA { get; set; }
|
||||||
|
public int WinsB { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "#<id>".</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 "#<id>" 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 "#<id>".</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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", <game names...>, "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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
// }
|
||||||
}
|
// }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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", <game names...>, "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 "#<id>".</summary>
|
||||||
|
public string Player { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
public int Wins { get; set; }
|
||||||
|
public int Losses { get; set; }
|
||||||
|
public int GamesWon { get; set; }
|
||||||
|
public int GamesLost { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per unordered pair of players with at least one decided set.
|
||||||
|
/// A is always the lower PlayerId so WinsA / WinsB are unambiguous.
|
||||||
|
/// </summary>
|
||||||
|
public class HeadToHead
|
||||||
|
{
|
||||||
|
public int PlayerAId { get; set; }
|
||||||
|
public string PlayerA { get; set; }
|
||||||
|
public int PlayerBId { get; set; }
|
||||||
|
public string PlayerB { get; set; }
|
||||||
|
public int WinsA { get; set; }
|
||||||
|
public int WinsB { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "#<id>".</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 "#<id>" 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 "#<id>".</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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 "#<id>" (a set can reference a player row
|
||||||
|
/// that no longer exists, and Gamertag is nullable in the database).
|
||||||
|
/// </summary>
|
||||||
|
private static string ResolveName(int playerId, Dictionary<int, string> nameById)
|
||||||
|
{
|
||||||
|
return nameById.TryGetValue(playerId, out var name) ? name : $"#{playerId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DisplayName(Player player)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.Gamertag))
|
||||||
|
{
|
||||||
|
return player.Gamertag;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.Name))
|
||||||
|
{
|
||||||
|
return player.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"#{player.Id}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using System.Linq;
|
|||||||
using LaDOSE.Business.Interface;
|
using LaDOSE.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,12 +153,18 @@ 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
|
||||||
_context.ApplicationUser.Remove(user);
|
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||||
_context.SaveChanges();
|
.SingleOrDefault(x => x.Id == id);
|
||||||
}
|
|
||||||
|
if (user == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
user.UserRoles?.Clear();
|
||||||
|
_context.ApplicationUser.Remove(user);
|
||||||
|
_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(';');
|
||||||
|
|||||||
@@ -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-*
|
||||||
@@ -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-*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
lts/*
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
@@ -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;"]
|
||||||
@@ -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 "$@"
|
||||||
@@ -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'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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
+4138
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {};
|
||||||
@@ -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;
|
||||||
+1761
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
Reference in New Issue
Block a user