Initial commit

This commit is contained in:
2026-09-17 21:45:57 +02:00
commit 9160e861e0
302 changed files with 31275 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
using Bookie.Application.DTOs.Auth;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly IAuthService _auth;
public AuthController(IAuthService auth) => _auth = auth;
/// <summary>Exchange username/password for a JWT.</summary>
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public ActionResult<AuthResponseDto> Login([FromBody] LoginRequestDto request)
{
var result = _auth.Login(request);
return result is null ? Unauthorized(new { message = "Invalid credentials." }) : Ok(result);
}
/// <summary>Returns the current user's identity from the token.</summary>
[HttpGet("me")]
[Authorize]
public ActionResult<object> Me()
=> Ok(new
{
username = User.Identity?.Name,
role = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value
});
}

View File

@@ -0,0 +1,54 @@
using Bookie.Application.DTOs.Common;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
/// <summary>
/// Shared CRUD endpoints. Reads require any authenticated user; writes require the admin role.
/// </summary>
[ApiController]
[Authorize]
public abstract class CrudControllerBase<TRead, TCreate, TUpdate> : ControllerBase
{
protected readonly ICrudService<TRead, TCreate, TUpdate, int> Service;
protected CrudControllerBase(ICrudService<TRead, TCreate, TUpdate, int> service) => Service = service;
/// <summary>Paged list with optional search + sorting.</summary>
[HttpGet]
public async Task<ActionResult<PagedResult<TRead>>> GetPaged([FromQuery] PagedQuery query, CancellationToken ct)
=> Ok(await Service.GetPagedAsync(query, ct));
[HttpGet("{id:int}")]
public async Task<ActionResult<TRead>> GetById(int id, CancellationToken ct)
{
var item = await Service.GetByIdAsync(id, ct);
return item is null ? NotFound() : Ok(item);
}
[HttpPost]
[Authorize(Roles = "admin")]
public async Task<ActionResult<TRead>> Create([FromBody] TCreate dto, CancellationToken ct)
{
var created = await Service.CreateAsync(dto, ct);
return StatusCode(StatusCodes.Status201Created, created);
}
[HttpPut("{id:int}")]
[Authorize(Roles = "admin")]
public async Task<ActionResult<TRead>> Update(int id, [FromBody] TUpdate dto, CancellationToken ct)
{
var updated = await Service.UpdateAsync(id, dto, ct);
return updated is null ? NotFound() : Ok(updated);
}
[HttpDelete("{id:int}")]
[Authorize(Roles = "admin")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var ok = await Service.DeleteAsync(id, ct);
return ok ? NoContent() : NotFound();
}
}

View File

@@ -0,0 +1,21 @@
using Bookie.Application.DTOs.Dashboard;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
[ApiController]
[Route("api/dashboard")]
[Authorize]
public class DashboardController : ControllerBase
{
private readonly IDashboardService _dashboard;
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
[HttpGet("summary")]
[ProducesResponseType(typeof(DashboardSummaryDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardSummaryDto>> GetSummary(CancellationToken ct)
=> Ok(await _dashboard.GetSummaryAsync(ct));
}

View File

@@ -0,0 +1,11 @@
using Bookie.Application.DTOs.Leagues;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
[Route("api/leagues")]
public class LeaguesController : CrudControllerBase<LeagueDto, LeagueCreateDto, LeagueUpdateDto>
{
public LeaguesController(ILeagueService service) : base(service) { }
}

View File

@@ -0,0 +1,36 @@
using Bookie.Application.DTOs.Common;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
[ApiController]
[Route("api/lookups")]
[Authorize]
public class LookupsController : ControllerBase
{
private readonly ILookupService _lookups;
public LookupsController(ILookupService lookups) => _lookups = lookups;
[HttpGet("leagues")]
public async Task<ActionResult<IReadOnlyList<LookupDto>>> Leagues(CancellationToken ct)
=> Ok(await _lookups.LeaguesAsync(ct));
[HttpGet("seasons")]
public async Task<ActionResult<IReadOnlyList<LookupDto>>> Seasons([FromQuery] int? leagueId, CancellationToken ct)
=> Ok(await _lookups.SeasonsAsync(leagueId, ct));
[HttpGet("teams")]
public async Task<ActionResult<IReadOnlyList<LookupDto>>> Teams([FromQuery] int? leagueId, [FromQuery] int? seasonId, CancellationToken ct)
=> Ok(await _lookups.TeamsAsync(leagueId, seasonId, ct));
[HttpGet("players")]
public async Task<ActionResult<IReadOnlyList<LookupDto>>> Players(CancellationToken ct)
=> Ok(await _lookups.PlayersAsync(ct));
[HttpGet("matchdays")]
public async Task<ActionResult<IReadOnlyList<LookupDto>>> Matchdays(CancellationToken ct)
=> Ok(await _lookups.MatchdaysAsync(ct));
}

View File

@@ -0,0 +1,11 @@
using Bookie.Application.DTOs.Matches;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
[Route("api/matches")]
public class MatchesController : CrudControllerBase<MatchDto, MatchCreateDto, MatchUpdateDto>
{
public MatchesController(IMatchService service) : base(service) { }
}

View File

@@ -0,0 +1,18 @@
using Bookie.Application.DTOs.Contracts;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
[Route("api/player-contracts")]
public class PlayerContractsController : CrudControllerBase<PlayerContractDto, PlayerContractCreateDto, PlayerContractUpdateDto>
{
private readonly IPlayerContractService _contracts;
public PlayerContractsController(IPlayerContractService service) : base(service) => _contracts = service;
/// <summary>All contracts for a given player.</summary>
[HttpGet("by-player/{playerId:int}")]
public async Task<ActionResult<IReadOnlyList<PlayerContractDto>>> GetByPlayer(int playerId, CancellationToken ct)
=> Ok(await _contracts.GetByPlayerAsync(playerId, ct));
}

View File

@@ -0,0 +1,11 @@
using Bookie.Application.DTOs.Players;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
[Route("api/players")]
public class PlayersController : CrudControllerBase<PlayerDto, PlayerCreateDto, PlayerUpdateDto>
{
public PlayersController(IPlayerService service) : base(service) { }
}

View File

@@ -0,0 +1,77 @@
using Bookie.Application.DTOs.Predictions;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
[ApiController]
[Route("api/predictions")]
[Authorize]
public class PredictionsController : ControllerBase
{
private readonly IPredictionService _service;
private readonly ILogger<PredictionsController> _logger;
public PredictionsController(IPredictionService service, ILogger<PredictionsController> logger)
{
_service = service;
_logger = logger;
}
/// <summary>
/// Statistical projection (goals, cards, fouls, corners, shots on target) for a single
/// fixture, produced by the OpenAI-backed Football Match Statistics Predictor.
/// </summary>
[HttpPost("match/{matchId:int}")]
[ProducesResponseType(typeof(MatchPredictionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status502BadGateway)]
public async Task<ActionResult<MatchPredictionDto>> PredictMatch(int matchId, CancellationToken ct)
{
try
{
var prediction = await _service.PredictMatchAsync(matchId, ct);
return prediction is null ? NotFound() : Ok(prediction);
}
catch (InvalidOperationException ex)
{
// Configuration or upstream OpenAI failures — surface a clean message to the client.
_logger.LogError(ex, "Prediction failed for match {MatchId}", matchId);
return Problem(title: "Prediction unavailable", detail: ex.Message, statusCode: StatusCodes.Status502BadGateway);
}
}
/// <summary>
/// Batch projection for several fixtures in a single model call. The predictor prompt is
/// batch-oriented, so this produces consistent, cross-checked estimates across the selection.
/// </summary>
[HttpPost("matches")]
[ProducesResponseType(typeof(IEnumerable<MatchPredictionDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status502BadGateway)]
public async Task<ActionResult<IEnumerable<MatchPredictionDto>>> PredictMatches(
[FromBody] PredictMatchesRequest request, CancellationToken ct)
{
if (request?.MatchIds is null || request.MatchIds.Count == 0)
return BadRequest("Provide at least one match id.");
if (request.MatchIds.Count > 20)
return BadRequest("A maximum of 20 matches can be predicted per request.");
try
{
var predictions = await _service.PredictMatchesAsync(request.MatchIds, ct);
return Ok(predictions);
}
catch (InvalidOperationException ex)
{
_logger.LogError(ex, "Batch prediction failed for {Count} matches", request.MatchIds.Count);
return Problem(title: "Prediction unavailable", detail: ex.Message, statusCode: StatusCodes.Status502BadGateway);
}
}
public class PredictMatchesRequest
{
public List<int> MatchIds { get; set; } = new();
}
}

View File

@@ -0,0 +1,57 @@
using Bookie.Application.DTOs.Reports;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
[ApiController]
[Route("api/reports")]
[Authorize]
public class ReportsController : ControllerBase
{
private readonly IPreMatchReportService _service;
public ReportsController(IPreMatchReportService service) => _service = service;
/// <summary>
/// Pre-Match Report: all matches on the given date grouped by league/country/season,
/// with per-team historical stats computed only from earlier matches in the same season.
/// </summary>
/// <param name="date">Target day in YYYY-MM-DD. Defaults to today.</param>
/// <param name="seasonsBack">
/// How many previous seasons of the same league to fold into the stats.
/// 0 (default) = current season only; 1 = current + previous, etc.
/// </param>
[HttpGet("pre-match")]
[ProducesResponseType(typeof(PreMatchReportDto), StatusCodes.Status200OK)]
public async Task<ActionResult<PreMatchReportDto>> GetPreMatch(
[FromQuery] DateOnly? date, [FromQuery] int seasonsBack = 0, CancellationToken ct = default)
{
var target = date ?? DateOnly.FromDateTime(DateTime.Today);
var report = await _service.GetPreMatchReportAsync(target, seasonsBack, ct);
return Ok(report);
}
/// <summary>Full statistics for a single match (team stats, goals, cards, penalties).</summary>
[HttpGet("match/{matchId:int}")]
[ProducesResponseType(typeof(MatchDetailsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MatchDetailsDto>> GetMatchDetails(int matchId, CancellationToken ct)
{
var details = await _service.GetMatchDetailsAsync(matchId, ct);
return details is null ? NotFound() : Ok(details);
}
/// <summary>Historical head-to-head record between two teams (previous meetings before a given match).</summary>
[HttpGet("head-to-head")]
[ProducesResponseType(typeof(HeadToHeadDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<HeadToHeadDto>> GetHeadToHead(
[FromQuery] int teamAId, [FromQuery] int teamBId,
[FromQuery] int? beforeMatchId, CancellationToken ct)
{
var h2h = await _service.GetHeadToHeadAsync(teamAId, teamBId, beforeMatchId, ct);
return h2h is null ? NotFound() : Ok(h2h);
}
}

View File

@@ -0,0 +1,18 @@
using Bookie.Application.DTOs.Seasons;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
[Route("api/seasons")]
public class SeasonsController : CrudControllerBase<SeasonDto, SeasonCreateDto, SeasonUpdateDto>
{
private readonly ISeasonService _seasons;
public SeasonsController(ISeasonService service) : base(service) => _seasons = service;
/// <summary>All seasons for a given league (for dropdowns).</summary>
[HttpGet("by-league/{leagueId:int}")]
public async Task<ActionResult<IReadOnlyList<SeasonDto>>> GetByLeague(int leagueId, CancellationToken ct)
=> Ok(await _seasons.GetByLeagueAsync(leagueId, ct));
}

View File

@@ -0,0 +1,35 @@
using Bookie.Application.DTOs.Stats;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
[ApiController]
[Authorize]
public class StatsController : ControllerBase
{
private readonly IStatsService _service;
public StatsController(IStatsService service) => _service = service;
/// <summary>Aggregated career statistics for a single player (goals, assists, cards, per-season, recent goals).</summary>
[HttpGet("api/players/{playerId:int}/stats")]
[ProducesResponseType(typeof(PlayerStatsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlayerStatsDto>> GetPlayerStats(int playerId, CancellationToken ct)
{
var stats = await _service.GetPlayerStatsAsync(playerId, ct);
return stats is null ? NotFound() : Ok(stats);
}
/// <summary>Aggregated statistics for a single team (record, averages, form, top scorers, per-season, recent matches).</summary>
[HttpGet("api/teams/{teamId:int}/stats")]
[ProducesResponseType(typeof(TeamStatsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TeamStatsDto>> GetTeamStats(int teamId, CancellationToken ct)
{
var stats = await _service.GetTeamStatsAsync(teamId, ct);
return stats is null ? NotFound() : Ok(stats);
}
}

View File

@@ -0,0 +1,18 @@
using Bookie.Application.DTOs.Teams;
using Bookie.Application.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace Bookie.Api.Controllers;
[Route("api/teams")]
public class TeamsController : CrudControllerBase<TeamDto, TeamCreateDto, TeamUpdateDto>
{
private readonly ITeamService _teams;
public TeamsController(ITeamService service) : base(service) => _teams = service;
/// <summary>Unpaged list of all teams (for dropdowns).</summary>
[HttpGet("all")]
public async Task<ActionResult<IReadOnlyList<TeamDto>>> GetAll(CancellationToken ct)
=> Ok(await _teams.GetAllAsync(ct));
}