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

After

Width:  |  Height:  |  Size: 1.5 KiB

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