Sube carpetas API, API.Tests, Application y Domain del Backend

This commit is contained in:
FrancoRu
2026-09-18 13:35:59 -03:00
parent bbb09554bb
commit 2eb4eeef03
572 changed files with 56405 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="AutoMapper" Version="16.2.0">
<NoWarn>NU1903</NoWarn>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.30" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.30" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.30">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.30" />
<PackageReference Include="Serilog" Version="4.4.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Application\Application.csproj" />
<ProjectReference Include="..\Domain\Domain.csproj" />
<ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
</Project>
@@ -0,0 +1,17 @@
using Application.DTOs.AuditLogs.Response;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class AuditLogProfile : Profile
{
public AuditLogProfile()
{
_ = CreateMap<AuditLog, AuditLogResponse>()
.ForMember(dest => dest.Action, opt => opt.MapFrom(src => src.Action.ToString()))
.ForMember(dest => dest.Timestamp, opt => opt.MapFrom(src => src.DateCreated));
}
}
@@ -0,0 +1,28 @@
using Application.DTOs.BlogPosts.Request;
using Application.DTOs.BlogPosts.Response;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class BlogPostProfile : Profile
{
public BlogPostProfile()
{
_ = CreateMap<CreateBlogPostRequest, BlogPost>();
_ = CreateMap<BlogPost, BlogPostResponse>()
.ForMember(dest => dest.CreatedAt, opt => opt.MapFrom(src => src.DateCreated))
.ReverseMap();
_ = CreateMap<UpdateBlogPostRequest, BlogPost>()
// Null IsPublished on the request means leave unchanged, so only overwrite the entity's flag when the caller sent a value.
.ForMember(dest => dest.IsPublished, opt =>
{
opt.PreCondition(src => src.IsPublished.HasValue);
opt.MapFrom(src => src.IsPublished!.Value);
});
}
}
@@ -0,0 +1,43 @@
using Application.DTOs.Divisions.Request;
using Application.DTOs.Divisions.Response;
using Application.DTOs.Tournament.Response;
using Application.Utils.Helper.Playoff;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class DivisionProfile : Profile
{
public DivisionProfile()
{
_ = CreateMap<Division, DivisionResponse>()
// QualificationRanges has no Division counterpart, so it is computed here from PlayoffMappings and ignored on the reverse map.
.ForMember(
dest => dest.QualificationRanges,
opt => opt.MapFrom(src => QualificationRangeBuilder.Build(src.PlayoffMappings)))
// TournamentSlug is resolved from the Tournament navigation, degrading to null when it was not included.
.ForMember(
dest => dest.TournamentSlug,
opt => opt.MapFrom(src => src.Tournament != null ? src.Tournament.Slug : null))
.ReverseMap();
_ = CreateMap<Division, MinimalDivisionResponse>()
.ReverseMap();
_ = CreateMap<PlayoffMappingRequest, DivisionPlayoffMapping>();
_ = CreateMap<DivisionPlayoffMapping, PlayoffMappingResponse>();
// Tournament cloning (HU-cloning): Stages resolves to StageStructureResponse
// once StageProfile registers that map.
_ = CreateMap<Division, DivisionStructureResponse>();
_ = CreateMap<CreateDivisionRequest, Division>();
// TournamentId is deliberately excluded from the blind mapping, since an omitted value would map to Guid via GetValueOrDefault and silently zero out the division's tournament, so reassignment must go through DivisionService.TryAssignTournamentAsync instead.
_ = CreateMap<UpdateDivisionRequest, Division>()
.ForMember(dest => dest.TournamentId, opt => opt.Ignore());
}
}
@@ -0,0 +1,138 @@
using Application.DTOs.Match.Request;
using Application.DTOs.Match.Response;
using Application.DTOs.Player.Response;
using Application.DTOs.Scorer.Response;
using AutoMapper;
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq;
namespace API.AutoMapperProfiles;
public class MatchProfile : Profile
{
public MatchProfile()
{
_ = CreateMap<CreateMatchRequest, Match>();
_ = CreateMap<Match, DetailedMatchResponse>()
.ForMember(dest => dest.MatchType, opt => opt.MapFrom(src => src.Type.ToString()))
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.Status.ToString()))
.ForMember(dest => dest.HomeTeam, opt => opt.MapFrom(src => src.HomeTeam))
.ForMember(dest => dest.VisitorTeam, opt => opt.MapFrom(src => src.VisitorTeam))
.ForMember(dest => dest.WinningTeamName, opt => opt.MapFrom(src => src.WinningTeam != null ? src.WinningTeam.Name : null))
.ForMember(dest => dest.WinningTeamId, opt => opt.MapFrom(src => src.WinningTeam != null ? src.WinningTeam.Id : (Guid?) null))
// Score and Scorers are set here instead of via ForPath, since ForPath would force AutoMapper to instantiate HomeTeam even when src.HomeTeam is null, producing a fake team instead of an empty bracket slot.
.AfterMap((src, dest) =>
{
// Each match's scorers are attributed to a team via the player's TeamId since Scorer itself has no TeamId, which requires the match to be loaded with Scorers.Player.
Guid? tournamentId = src.Stage?.Division?.TournamentId;
if (dest.HomeTeam is not null)
{
dest.HomeTeam.Score = src.HomeScore ?? 0;
dest.HomeTeam.Scorers = ScorersForTeam(src, src.HomeTeamId);
PopulateRosterEligibility(dest.HomeTeam.Players, src.HomeTeam?.Players, tournamentId);
}
if (dest.VisitorTeam is not null)
{
dest.VisitorTeam.Score = src.VisitorScore ?? 0;
dest.VisitorTeam.Scorers = ScorersForTeam(src, src.VisitorTeamId);
PopulateRosterEligibility(dest.VisitorTeam.Players, src.VisitorTeam?.Players, tournamentId);
}
dest.TournamentId = tournamentId;
});
_ = CreateMap<Match, MinimalMatchResponse>()
.ForMember(dest => dest.MatchType, opt => opt.MapFrom(src => src.Type.ToString()))
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.Status.ToString()))
.ForMember(dest => dest.HomeTeamName, opt => opt.MapFrom(src => src.HomeTeam != null ? src.HomeTeam.Name : null))
.ForMember(dest => dest.VisitorTeamName, opt => opt.MapFrom(src => src.VisitorTeam != null ? src.VisitorTeam.Name : null))
.ForMember(dest => dest.WinningTeamName, opt => opt.MapFrom(src => src.WinningTeam != null ? src.WinningTeam.Name : null));
_ = CreateMap<UpdateMatchRequest, Match>();
}
/// <summary>
/// Builds the per-player scorer ranking for one team, attributing each scorer via Player.TeamId and summing points per player, highest first.
/// </summary>
private static List<ScorerByPlayerResponse> ScorersForTeam(Match match, Guid? teamId)
{
if (teamId is null)
{
return [];
}
Guid? tournamentId = match.Stage?.Division?.TournamentId;
return [.. match.Scorers
.Where(scorer => scorer.Player is not null && scorer.Player.TeamId == teamId.Value)
.GroupBy(scorer => scorer.PlayerId)
.Select(group => new ScorerByPlayerResponse
{
PlayerId = group.Key,
FullName = group.First().Player!.FullName,
JerseyNumber = JerseyNumberFor(group.First().Player!, tournamentId),
Points = group.Sum(scorer => scorer.Points),
})
.OrderByDescending(scorer => scorer.Points)];
}
/// <summary>
/// Resolves the player's jersey number from the matching roster registration for the tournament, since Player.JerseyNumber itself is transient.
/// </summary>
private static int? JerseyNumberFor(Player player, Guid? tournamentId)
{
if (player.PlayerTeamRegistrations is null || player.PlayerTeamRegistrations.Count == 0)
{
return null;
}
PlayerTeamRegistration? registration = tournamentId is null
? null
: player.PlayerTeamRegistrations.FirstOrDefault(reg => reg.TournamentId == tournamentId.Value);
return (registration ?? player.PlayerTeamRegistrations.First()).JerseyNumber;
}
/// <summary>
/// Fills each roster player's season-scoped medical record status, habilitado flag, and jersey number from the matching PlayerTeamRegistration, since the plain Player to PublicPlayerResponse map cannot resolve them on its own.
/// </summary>
private static void PopulateRosterEligibility(
List<PublicPlayerResponse> destPlayers, ICollection<Player>? srcPlayers, Guid? tournamentId)
{
if (tournamentId is null || srcPlayers is null || srcPlayers.Count == 0)
{
return;
}
Dictionary<Guid, Player> srcById = srcPlayers.ToDictionary(player => player.Id);
foreach (PublicPlayerResponse destPlayer in destPlayers)
{
if (!srcById.TryGetValue(destPlayer.Id, out Player? srcPlayer)
|| srcPlayer.PlayerTeamRegistrations is null)
{
continue;
}
PlayerTeamRegistration? registration = srcPlayer.PlayerTeamRegistrations
.FirstOrDefault(reg => reg.TournamentId == tournamentId.Value);
if (registration is null)
{
continue;
}
destPlayer.MedicalRecordStatus = registration.MedicalRecordStatus;
destPlayer.IsHabilitado = registration.IsHabilitado;
destPlayer.JerseyNumber = registration.JerseyNumber;
}
}
}
@@ -0,0 +1,26 @@
using Application.DTOs.Match.Response;
using Application.DTOs.MatchSeries.Response;
using AutoMapper;
using Domain.Entities.Models;
using System.Linq;
namespace API.AutoMapperProfiles;
public class MatchSeriesProfile : Profile
{
public MatchSeriesProfile()
{
_ = CreateMap<MatchSeries, MatchSeriesResponse>()
.ForMember(dest => dest.HomeTeamName, opt => opt.MapFrom(src => src.HomeTeam != null ? src.HomeTeam.Name : string.Empty))
.ForMember(dest => dest.VisitorTeamName, opt => opt.MapFrom(src => src.VisitorTeam != null ? src.VisitorTeam.Name : string.Empty))
.ForMember(dest => dest.WinningTeamName, opt => opt.MapFrom(src => src.WinningTeam != null ? src.WinningTeam.Name : null))
.ForMember(dest => dest.Games, opt => opt.MapFrom(src => src.Matches.OrderBy(m => m.GameNumber)));
_ = CreateMap<Match, SeriesGameResponse>()
.IncludeBase<Match, MinimalMatchResponse>()
.ForMember(dest => dest.GameNumber, opt => opt.MapFrom(src => src.GameNumber ?? 0));
}
}
@@ -0,0 +1,37 @@
using Application.DTOs.Abstract.Response;
using AutoMapper;
using System.Collections.Generic;
namespace API.AutoMapperProfiles;
/// <summary>
/// Converts a paginated response's items from the source entity type to the destination DTO type while preserving its paging metadata.
/// </summary>
/// <typeparam name="TSource">The source item type.</typeparam>
/// <typeparam name="TDestination">The destination item type.</typeparam>
public class PaginatedResponseConverter<TSource, TDestination>
: ITypeConverter<PaginatedResponse<TSource>, PaginatedResponse<TDestination>>
{
/// <summary>
/// Maps the source paginated response's items to the destination type.
/// </summary>
/// <param name="source">The paginated response being converted.</param>
/// <param name="destination">The unused destination instance supplied by AutoMapper.</param>
/// <param name="context">The mapping context used to convert the items.</param>
/// <returns>A new paginated response with mapped items and the source paging metadata.</returns>
public PaginatedResponse<TDestination> Convert(
PaginatedResponse<TSource> source,
PaginatedResponse<TDestination> destination,
ResolutionContext context)
{
return new()
{
Page = source.Page,
PageSize = source.PageSize,
TotalCount = source.TotalCount,
Items = context.Mapper.Map<List<TDestination>>(source.Items)
};
}
}
@@ -0,0 +1,14 @@
using Application.DTOs.Abstract.Response;
using AutoMapper;
namespace API.AutoMapperProfiles;
public class PaginatedResponseProfile : Profile
{
public PaginatedResponseProfile()
{
CreateMap(typeof(PaginatedResponse<>), typeof(PaginatedResponse<>))
.ConvertUsing(typeof(PaginatedResponseConverter<,>));
}
}
@@ -0,0 +1,27 @@
using Application.DTOs.Player.Request;
using Application.DTOs.Player.Response;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class PlayerProfile : Profile
{
public PlayerProfile()
{
_ = CreateMap<Player, PublicPlayerResponse>()
.ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.FullName))
.ReverseMap();
_ = CreateMap<Player, AdminPlayerResponse>()
.IncludeBase<Player, PublicPlayerResponse>();
_ = CreateMap<CreatePlayerRequest, Player>();
_ = CreateMap<UpdatePlayerRequest, Player>();
_ = CreateMap<PlayerTeamRegistration, PlayerRegistrationResponse>();
}
}
@@ -0,0 +1,29 @@
using Application.DTOs.PlayerSanction.Request;
using Application.DTOs.PlayerSanction.Response;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class PlayerSanctionProfile : Profile
{
public PlayerSanctionProfile()
{
_ = CreateMap<CreatePlayerSanctionRequest, PlayerSanction>();
_ = CreateMap<PlayerSanction, PlayerSanctionResponse>()
.ForMember(dest => dest.PlayerFullName,
opt => opt.MapFrom(src => src.Player != null ? src.Player.FullName : null))
.ForMember(dest => dest.TeamName,
opt => opt.MapFrom(src => src.Team != null ? src.Team.Name : null))
// FechasRemaining and IsActive are computed against finished rounds and populated by the controller, not mapped here.
.ForMember(dest => dest.FechasRemaining, opt => opt.Ignore())
.ForMember(dest => dest.IsActive, opt => opt.Ignore())
.ReverseMap()
.ForMember(dest => dest.Player, opt => opt.Ignore())
.ForMember(dest => dest.Team, opt => opt.Ignore());
_ = CreateMap<UpdatePlayerSanctionRequest, PlayerSanction>();
}
}
@@ -0,0 +1,24 @@
using Application.DTOs.PlayerStatistic.Request;
using Application.DTOs.PlayerStatistic.Response;
using AutoMapper;
using Domain.Entities.Models;
using System;
namespace API.AutoMapperProfiles;
public class PlayerStatisticProfile : Profile
{
public PlayerStatisticProfile()
{
_ = CreateMap<CreatePlayerStatisticRequest, PlayerStatistic>();
_ = CreateMap<PlayerStatistic, PlayerStatisticResponse>()
.ForMember(dest => dest.MatchDate, opt => opt.MapFrom(src => src.Match != null ? (DateTime?) src.Match.MatchDate : null))
.ReverseMap();
_ = CreateMap<UpdatePlayerStatisticRequest, PlayerStatistic>();
}
}
@@ -0,0 +1,21 @@
using Application.DTOs.PointDeductions.Request;
using Application.DTOs.PointDeductions.Response;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class PointDeductionProfile : Profile
{
public PointDeductionProfile()
{
_ = CreateMap<CreatePointDeductionRequest, TeamPointDeduction>();
_ = CreateMap<TeamPointDeduction, PointDeductionResponse>()
.ForMember(
dest => dest.TeamName,
opt => opt.MapFrom(src => src.Team != null ? src.Team.Name : null));
}
}
@@ -0,0 +1,22 @@
using Application.DTOs.Divisions.Response;
using Application.DTOs.PointDeductions.Response;
using Application.Utils.Helper.Standings;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class PositionProfile : Profile
{
public PositionProfile()
{
_ = CreateMap<Position, PositionResponse>();
_ = CreateMap<AppliedPointDeduction, AppliedPointDeductionResponse>();
// The nested Position to PositionResponse mapping above is applied automatically to each group's Positions collection.
_ = CreateMap<GroupStandings, GroupStandingsResponse>();
}
}
@@ -0,0 +1,22 @@
using Application.DTOs.Season.Request;
using Application.DTOs.Season.Response;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class SeasonProfile : Profile
{
public SeasonProfile()
{
_ = CreateMap<Season, SeasonResponse>();
_ = CreateMap<Tournament, SeasonTournamentResponse>();
_ = CreateMap<CreateSeasonRequest, Season>();
_ = CreateMap<UpdateSeasonRequest, Season>();
}
}
@@ -0,0 +1,24 @@
using Application.DTOs.Stage.Request;
using Application.DTOs.Stage.Response;
using Application.DTOs.Tournament.Response;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class StageProfile : Profile
{
public StageProfile()
{
_ = CreateMap<CreateStageRequest, Stage>();
_ = CreateMap<Stage, StageResponse>()
.ReverseMap();
_ = CreateMap<UpdateStageRequest, Stage>();
// Tournament cloning (HU-cloning): additive, name-convention mapping —
// carries no dates, no DrawnAt, no match data.
_ = CreateMap<Stage, StageStructureResponse>();
}
}
@@ -0,0 +1,31 @@
using Application.DTOs.Team.Request;
using Application.DTOs.Team.Response;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class TeamProfile : Profile
{
public TeamProfile()
{
_ = CreateMap<Team, TeamResponse>()
.ForMember(dest => dest.ClubId, opt => opt.MapFrom(src => src.ClubId))
.ForMember(dest => dest.TournamentName, opt => opt.MapFrom(src => src.Tournament != null ? src.Tournament.Name : null))
.ReverseMap();
_ = CreateMap<Team, TeamDetailedMatchResponse>()
.ReverseMap();
_ = CreateMap<CreateTeamRequest, Team>()
.ForMember(dest => dest.ThreeLetterCode, opt => opt.MapFrom(src => src.ThreeLetterCode.ToUpper()));
_ = CreateMap<UpdateTeamRequest, Team>()
.ForMember(dest => dest.ThreeLetterCode, opt => opt.MapFrom(src => src.ThreeLetterCode != null ? src.ThreeLetterCode.ToUpper() : null))
.ForMember(dest => dest.JerseyStyle, opt => opt.Condition(src => src.JerseyStyle != null))
.ForMember(dest => dest.ShirtSecondaryColor, opt => opt.Condition(src => src.ShirtSecondaryColor != null))
.ForMember(dest => dest.ShirtTertiaryColor, opt => opt.Condition(src => src.ShirtTertiaryColor != null));
}
}
@@ -0,0 +1,24 @@
using Application.DTOs.TeamStaff.Request;
using Application.DTOs.TeamStaff.Response;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class TeamStaffProfile : Profile
{
public TeamStaffProfile()
{
_ = CreateMap<CreateTeamStaffRequest, TeamStaff>();
_ = CreateMap<TeamStaff, TeamStaffResponse>()
.ForMember(
dest => dest.Role,
opt => opt.MapFrom(src => src.Role.ToString()))
.ForMember(
dest => dest.TeamName,
opt => opt.MapFrom(src => src.Team != null ? src.Team.Name : null));
}
}
@@ -0,0 +1,36 @@
using Application.DTOs.Tournament.Request;
using Application.DTOs.Tournament.Response;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class TournamentProfile : Profile
{
public TournamentProfile()
{
_ = CreateMap<Tournament, TournamentResponse>()
.ForMember(dest => dest.Divisions, opt => opt.MapFrom(src => src.Divisions))
// SeasonId flows by name convention; SeasonName is resolved from the Season navigation, degrading to null when the tournament is ungrouped or the season was not included.
.ForMember(dest => dest.SeasonName, opt => opt.MapFrom(src => src.Season != null ? src.Season.Name : null))
.ForMember(dest => dest.SeasonSlug, opt => opt.MapFrom(src => src.Season != null ? src.Season.Slug : null))
.ReverseMap();
// Tournament cloning (HU-cloning): the structure-tree read is additive and
// maps by name convention only — Divisions resolves to DivisionStructureResponse
// once DivisionProfile registers that map, carrying no instance data.
_ = CreateMap<Tournament, TournamentStructureResponse>();
_ = CreateMap<CreateTournamentRequest, Tournament>();
_ = CreateMap<UpdateTournamentRequest, Tournament>()
// Status is intentionally not mapped here since a status change is a guarded state-machine transition with fixture side effects driven through TournamentService.ChangeStatusAsync, never a blind field overwrite from a generic update.
.ForMember(dest => dest.Status, opt => opt.Ignore())
// Category is not mapped on update since a tournament's gender category is fixed at creation; flipping it later would silently mix it with divisions already created under the original category.
.ForMember(dest => dest.Category, opt => opt.Ignore())
// StartDate is fixed at creation since it drives when the tournament is understood to have happened for season grouping and champions history, so it must never move after the fact regardless of status.
.ForMember(dest => dest.StartDate, opt => opt.Ignore());
}
}
@@ -0,0 +1,21 @@
using Application.DTOs.Venue.Request;
using Application.DTOs.Venue.Response;
using AutoMapper;
using Domain.Entities.Models;
namespace API.AutoMapperProfiles;
public class VenueProfile : Profile
{
public VenueProfile()
{
_ = CreateMap<Venue, VenueResponse>()
.ReverseMap();
_ = CreateMap<CreateVenueRequest, Venue>();
_ = CreateMap<UpdateVenueRequest, Venue>();
}
}
@@ -0,0 +1,95 @@
using Application.Interfaces.Backup;
using Domain.Enums;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace API.BackgroundServices;
/// <summary>
/// Drives the scheduled database backup by ticking a PeriodicTimer and calling the same shared IBackupOperationsService write path the manual admin endpoint uses, no-oping entirely when BackupOptions.Enabled is false.
/// </summary>
public sealed class DatabaseBackupHostedService(
IServiceScopeFactory scopeFactory,
BackupOptions options,
ILogger<DatabaseBackupHostedService> logger) : BackgroundService
{
/// <summary>
/// Test-only hook that overrides the interval otherwise derived from BackupOptions.IntervalHours, letting tests use a short, deterministic interval instead of sleeping for real hours-scale durations.
/// </summary>
public TimeSpan? IntervalOverride { get; init; }
private Task? _inFlightRun;
/// <summary>
/// Ticks on a PeriodicTimer for the lifetime of the host; cancellation via stoppingToken during host shutdown is the expected exit path, not an error.
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!options.Enabled)
{
logger.LogInformation("Backup:Enabled is false — DatabaseBackupHostedService will not run.");
return;
}
TimeSpan interval = IntervalOverride ?? TimeSpan.FromHours(Math.Max(options.IntervalHours, 1));
using PeriodicTimer timer = new(interval);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
_inFlightRun = RunBackupAttemptAsync(stoppingToken);
}
}
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(ex, "Backup hosted service stopping: cancellation requested.");
}
finally
{
if (_inFlightRun is not null)
{
await _inFlightRun.ConfigureAwait(false);
}
}
}
private async Task RunBackupAttemptAsync(CancellationToken ct)
{
try
{
using IServiceScope scope = scopeFactory.CreateScope();
IBackupOperationsService operations = scope.ServiceProvider.GetRequiredService<IBackupOperationsService>();
BackupOperationResult result = await operations.CreateBackupAsync(BackupOrigin.Job, ct);
switch (result.Outcome)
{
case BackupOperationOutcome.Busy:
logger.LogWarning("Skipping scheduled backup attempt: another backup/restore operation is already in progress.");
break;
case BackupOperationOutcome.Failed:
logger.LogError("Scheduled backup attempt failed: {Message}", result.Message);
break;
case BackupOperationOutcome.Completed:
logger.LogInformation("Scheduled backup completed: stored {StoragePath}.", result.Record?.StoragePath);
break;
case BackupOperationOutcome.NotFound:
default:
break;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Unexpected error during scheduled backup attempt.");
}
}
}
+6
View File
@@ -0,0 +1,6 @@
@Server_HostAddress = http://localhost:5176
GET {{Server_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,42 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.AuditLogs.Request;
using Application.DTOs.AuditLogs.Response;
using Application.Interfaces.Services;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Read-only access to the sensitive-action audit trail, restricted to Admin or Owner so those shared accounts stay accountable.
/// </summary>
[Route("api/audit-logs")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class AuditLogController(IAuditService auditService, IMapper mapper) : ControllerBase
{
/// <summary>
/// Lists audit entries newest first, with pagination and optional actor or action filters.
/// </summary>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<AuditLogResponse>))]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> GetAuditLogs(
[FromQuery] AuditLogFilteredRequest filter)
{
PaginatedResponse<AuditLog> entries = await auditService.GetAuditLogsAsync(filter);
PaginatedResponse<AuditLogResponse> response = mapper.Map<PaginatedResponse<AuditLogResponse>>(entries);
return Ok(response);
}
}
@@ -0,0 +1,173 @@
using API.Utils.Helpers;
using Application.DTOs.Auth.Request;
using Application.DTOs.Auth.Response;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Authentication and account-lifecycle endpoints; login, magic links, guest sessions, and password reset are anonymous, while registering or inviting a user requires Admin or Owner.
/// </summary>
[ApiController]
[Route("api/auth")]
public class AuthController(IAuthenticationService authenticationService) : ControllerBase
{
/// <summary>
/// Creates a fully activated account with a caller-set password; requires Admin or Owner rather than being a public self-registration endpoint.
/// </summary>
[Authorize(Roles = Roles.AdminOrOwner)]
[HttpPost("register")]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(RegisterUserResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<RegisterUserResponse>> Register(
[FromBody] RegisterUserRequest request, CancellationToken ct)
{
string callerRole = User.FindFirst(ClaimTypes.Role)?.Value
?? throw new UnauthorizedAccessException(ErrorMessages.Auth.RoleClaimMissing);
Guid callerId = Guid.Parse(
User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? throw new UnauthorizedAccessException(ErrorMessages.Auth.IdClaimMissing));
RegisterUserResponse response =
await authenticationService.RegisterAsync(request, callerRole, callerId, ct);
return CreatedAtAction(nameof(Register), new { userId = response.UserId }, response);
}
/// <summary>
/// Creates a user by email only, with no password, and emails a magic activation link; requires Admin or Owner.
/// </summary>
[Authorize(Roles = Roles.AdminOrOwner)]
[HttpPost("invite")]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(InviteUserResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<InviteUserResponse>> Invite(
[FromBody] InviteUserRequest request, CancellationToken ct)
{
string callerRole = User.FindFirst(ClaimTypes.Role)?.Value
?? throw new UnauthorizedAccessException(ErrorMessages.Auth.RoleClaimMissing);
Guid callerId = Guid.Parse(
User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? throw new UnauthorizedAccessException(ErrorMessages.Auth.IdClaimMissing));
InviteUserResponse response =
await authenticationService.InviteUserAsync(request, callerRole, callerId, ct);
return CreatedAtAction(nameof(Invite), new { userId = response.UserId }, response);
}
/// <summary>
/// Consumes the activation token from the invitation email, sets the user's first password, and returns a ready-to-use JWT.
/// </summary>
[AllowAnonymous]
[HttpPost("activate")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenResponse>> Activate(
[FromBody] ActivateAccountRequest request, CancellationToken ct)
{
return Ok(await authenticationService.ActivateAccountAsync(request, ct));
}
/// <summary>
/// Emails a password-reset magic link for the given email and always returns 200 so it never reveals whether the email has an account.
/// </summary>
[AllowAnonymous]
[HttpPost("password-reset/request")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> RequestPasswordReset(
[FromBody] RequestPasswordResetRequest request, CancellationToken ct)
{
await authenticationService.RequestPasswordResetAsync(request, ct);
return Ok();
}
[AllowAnonymous]
[HttpPost("login")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenResponse>> Login(
[FromBody] LogInUserRequest request, CancellationToken ct)
{
return Ok(await authenticationService.LoginAsync(request, ct));
}
[AllowAnonymous]
[HttpPost("magic-link/request")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MagicLinkResponse))]
public async Task<ActionResult<MagicLinkResponse>> RequestMagicLink(
[FromBody] MagicLinkRequest request, CancellationToken ct)
{
return Ok(await authenticationService.RequestMagicLinkAsync(request, ct));
}
[AllowAnonymous]
[HttpPost("magic-link/login")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenResponse>> MagicLinkLogin(
[FromBody] MagicLinkLoginRequest request, CancellationToken ct)
{
return Ok(await authenticationService.MagicLinkLoginAsync(request, ct));
}
[AllowAnonymous]
[HttpPost("guest")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))]
public async Task<ActionResult<TokenResponse>> Guest(CancellationToken ct)
{
return Ok(await authenticationService.GuestAsync(ct));
}
[AllowAnonymous]
[HttpPost("refresh")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenResponse>> Refresh(
[FromBody] RefreshTokenRequest request, CancellationToken ct)
{
return Ok(await authenticationService.RefreshAsync(request, ct));
}
/// <summary>
/// Verifies the password-reset token from the email link, sets the new password, and returns a ready-to-use JWT, logging the user in automatically.
/// </summary>
[AllowAnonymous]
[HttpPost("password-reset/confirm")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenResponse>> ConfirmPasswordReset(
[FromBody] PasswordResetConfirmRequest request, CancellationToken ct)
{
return Ok(await authenticationService.ConfirmPasswordResetAsync(request, ct));
}
[AllowAnonymous]
[HttpPost("logout")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Logout(CancellationToken ct)
{
(string _, Guid id) = User.GetCallerClaims();
await authenticationService.LogoutAsync(id, ct);
return NoContent();
}
}
@@ -0,0 +1,104 @@
using Application.DTOs.Backup.Response;
using Application.Interfaces.Backup;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Admin-only management of database backups: lists the catalog, triggers a manual backup, deletes a backup, and restores from one, with outcomes mapped explicitly to status codes.
/// </summary>
[Route("api/backups")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class BackupController(IBackupOperationsService operations) : ControllerBase
{
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IReadOnlyList<BackupRecordResponse>))]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<IReadOnlyList<BackupRecordResponse>>> GetAll(CancellationToken ct)
{
IReadOnlyList<BackupRecord> records = await operations.ListNewestFirstAsync(ct);
IReadOnlyList<BackupRecordResponse> response = records.Select(BackupRecordResponse.FromEntity).ToList();
return Ok(response);
}
/// <summary>
/// Triggers a manual backup, returning 409 if a backup or restore is already in progress and 500 if the backup itself fails.
/// </summary>
[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BackupRecordResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> Create(CancellationToken ct)
{
BackupOperationResult result = await operations.CreateBackupAsync(BackupOrigin.Manual, ct);
return result.Outcome switch
{
BackupOperationOutcome.Completed => Ok(result.Record),
BackupOperationOutcome.Busy => Conflict(result.Message),
_ => StatusCode(StatusCodes.Status500InternalServerError, result.Message),
};
}
/// <summary>
/// Deletes a catalogued backup, returning 404 if no backup matches the id and 409 if a backup or restore is currently in progress.
/// </summary>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
BackupOperationResult result = await operations.DeleteBackupAsync(id, ct);
return result.Outcome switch
{
BackupOperationOutcome.Completed => NoContent(),
BackupOperationOutcome.NotFound => NotFound(),
BackupOperationOutcome.Busy => Conflict(result.Message),
_ => StatusCode(StatusCodes.Status500InternalServerError, result.Message),
};
}
/// <summary>
/// Restores the database from a catalogued backup, returning on success the automatic safety backup record taken just before the restore, not the restored backup itself; returns 404 if no backup matches the id and 409 if a backup or restore is already in progress.
/// </summary>
[HttpPost("{id:guid}/restore")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BackupRecordResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> Restore(Guid id, CancellationToken ct)
{
BackupOperationResult result = await operations.RestoreBackupAsync(id, ct);
return result.Outcome switch
{
BackupOperationOutcome.Completed => Ok(result.Record),
BackupOperationOutcome.NotFound => NotFound(),
BackupOperationOutcome.Busy => Conflict(result.Message),
_ => StatusCode(StatusCodes.Status500InternalServerError, result.Message),
};
}
}
@@ -0,0 +1,191 @@
using API.Utils;
using Application.DTOs.Abstract.Response;
using Application.DTOs.BlogPosts.Request;
using Application.DTOs.BlogPosts.Response;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Infrastructure.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages blog posts; reads are public while writes require Admin or Owner, with individual actions opting back out via AllowAnonymous where the whole club should be able to read.
/// </summary>
/// <param name="blogPostService">The blog post service.</param>
/// <param name="supabaseHelper">The Supabase helper for storage operations.</param>
/// <param name="mapper">The AutoMapper instance.</param>
[Route("api/blogposts/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class BlogPostController(
IBlogPostService blogPostService,
SupabaseHelper supabaseHelper,
IMapper mapper
) : ControllerBase
{
/// <summary>
/// Creates a blog post from multipart form data, validating and uploading an included photo file to storage before the post is persisted.
/// </summary>
/// <param name="blogPostRequest">The blog post creation request object containing the post details.</param>
/// <returns>The created blog post response with details of the new blog post.</returns>
[HttpPost()]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(BlogPostResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<BlogPostResponse>> CreateBlogPost([FromForm] CreateBlogPostRequest blogPostRequest)
{
if (blogPostRequest.PhotoFile is not null && !blogPostRequest.PhotoFile.IsValidImageFile())
{
return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile);
}
string? photoUrl = blogPostRequest.PhotoFile is null
? null
: await supabaseHelper.UploadImageAsync<BlogPost>(
blogPostRequest.PhotoFile.OpenReadStream(),
blogPostRequest.PhotoFile.FileName);
BlogPost blogPost = mapper.Map<BlogPost>(blogPostRequest);
blogPost.PhotoUrl = photoUrl;
BlogPost createdBlogPost = await blogPostService.CreateBlogPostAsync(blogPost);
BlogPostResponse blogPostResponse = mapper.Map<BlogPostResponse>(createdBlogPost);
return new ObjectResult(blogPostResponse) { StatusCode = StatusCodes.Status201Created };
}
/// <summary>
/// Updates a blog post resolved by id or by its slug, the same lookup the GET endpoint uses, including unpublished drafts so a draft can be edited before it's published.
/// </summary>
/// <param name="idOrSlug">The GUID id or slug of the blog post to update.</param>
/// <param name="blogPostRequest">The blog post request with updated content.</param>
/// <returns>Returns 200 OK with the updated blog post response if the update was successful, or 400 Bad Request if the blog post was not found.</returns>
[HttpPut("{idOrSlug}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPostResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdateBlogPost(string idOrSlug, [FromForm] UpdateBlogPostRequest blogPostRequest)
{
// Resolves by id or slug, matching the GET, so the admin can save a post the same way it was opened instead of forcing a GUID URL.
// includeUnpublished is true so a draft can still be edited.
BlogPost? existingPost = await blogPostService.GetBlogPostByIdOrSlugAsync(idOrSlug, includeUnpublished: true);
if (existingPost is null)
{
return this.NotFoundProblem(nameof(BlogPost), idOrSlug);
}
mapper.Map(blogPostRequest, existingPost);
await blogPostService.UpdateBlogPostAsync(existingPost);
BlogPostResponse blogPostResponse = mapper.Map<BlogPostResponse>(existingPost);
return Ok(blogPostResponse);
}
/// <summary>
/// Replaces a blog post's photo, validating the new file as an image and uploading it to storage before updating the post's PhotoUrl.
/// </summary>
/// <param name="idOrSlug">The GUID id or slug of the blog post to update the photo.</param>
/// <param name="photoRequest">The update blog post photo request.</param>
/// <returns>Returns 200 OK if the photo was successfully updated.</returns>
[HttpPut("{idOrSlug}/photo")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> UpdateBlogPostPhoto(string idOrSlug, [FromForm] UpdateBlogPostPhotoRequest photoRequest)
{
if (!photoRequest.PhotoFile.IsValidImageFile())
{
return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile);
}
BlogPost? blogPost = await blogPostService.GetBlogPostByIdOrSlugAsync(idOrSlug, includeUnpublished: true);
if (blogPost is null)
{
return this.NotFoundProblem(nameof(BlogPost), idOrSlug);
}
blogPost.PhotoUrl = await supabaseHelper.UploadImageAsync<BlogPost>(
photoRequest.PhotoFile.OpenReadStream(),
photoRequest.PhotoFile.FileName);
await blogPostService.UpdateBlogPostAsync(blogPost);
return Ok();
}
/// <summary>
/// Retrieves a blog post by id or slug; Admin or Owner can also resolve unpublished drafts, and a public non-draft read increments the view counter while an Admin/Owner preview does not.
/// </summary>
/// <param name="idOrSlug">Blog post identifier as a GUID, or slug.</param>
/// <returns>The blog post with the specified id or slug.</returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPostResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BlogPostResponse>> GetBlogPostById(string idOrSlug)
{
// Only Admin/Owner may resolve drafts; anonymous or public callers get 404 for an unpublished post.
bool includeUnpublished = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Owner);
BlogPost? blogPost = await blogPostService.GetBlogPostByIdOrSlugAsync(idOrSlug, includeUnpublished);
if (blogPost is null)
{
return this.NotFoundProblem(nameof(BlogPost), idOrSlug);
}
// The view counter reflects genuine public reads only, since an Admin/Owner opening the post to preview or edit it must not inflate the count.
if (!includeUnpublished)
{
blogPost.Views++;
await blogPostService.UpdateBlogPostAsync(blogPost);
}
BlogPostResponse blogPostResponse = mapper.Map<BlogPostResponse>(blogPost);
return Ok(blogPostResponse);
}
/// <summary>
/// Deletes a blog post by its id.
/// </summary>
/// <param name="id">The id of the blog post to delete.</param>
/// <returns>Returns 204 No Content if the blog post was successfully deleted, or 400 Bad Request if not found.</returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> DeleteBlogPostById(Guid id)
{
await blogPostService.DeleteBlogPostAsync(id);
return NoContent();
}
/// <summary>
/// Retrieves filtered, paginated blog posts; public and anonymous callers only see published posts while Admin or Owner also see drafts.
/// </summary>
/// <param name="filterRequest">The filtering and pagination parameters.</param>
/// <returns>A paginated response containing the filtered blog posts.</returns>
[AllowAnonymous]
[HttpGet()]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<BlogPostResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PaginatedResponse<BlogPostResponse>>> GetFilteredBlogPosts([FromQuery] GetBlogPostsFilteredRequest filterRequest)
{
// Admin/Owner also see drafts in this listing; public callers only see published posts.
bool includeUnpublished = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Owner);
PaginatedResponse<BlogPost> paginatedPosts = await blogPostService.GetAllBlogPostsAsync(filterRequest, includeUnpublished);
PaginatedResponse<BlogPostResponse> response = mapper.Map<PaginatedResponse<BlogPostResponse>>(paginatedPosts);
return Ok(response);
}
}
@@ -0,0 +1,42 @@
using API.Utils;
using Application.DTOs.Champions.Response;
using Application.Interfaces.Services;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Exposes the club's champions history across finished tournaments; reads are public.
/// </summary>
/// <param name="championService">The champion service.</param>
[Route("api/champions")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class ChampionController(IChampionService championService) : ControllerBase
{
/// <summary>
/// Lists each division's first-place champion across every finished tournament, optionally scoped to one season, omitting divisions without a decided champion.
/// </summary>
/// <param name="seasonId">Optional season filter as a GUID; when omitted, spans all seasons.</param>
/// <returns>
/// Returns 200 OK with the champions history.
/// </returns>
[AllowAnonymous]
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<ChampionHistoryResponse>))]
public async Task<ActionResult<List<ChampionHistoryResponse>>> GetChampionsHistory([FromQuery] Guid? seasonId)
{
List<ChampionHistoryResponse> history = await championService.GetChampionsHistoryAsync(seasonId);
return Ok(history);
}
}
@@ -0,0 +1,154 @@
using API.Utils;
using Application.DTOs.Club.Request;
using Application.DTOs.Club.Response;
using Application.Interfaces.Services;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages the stable cross-season club identity; reading a club's history is public while the idempotent backfill is a staff maintenance action.
/// </summary>
/// <param name="clubService">The club service.</param>
[Route("api/clubs/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class ClubController(IClubService clubService) : ControllerBase
{
/// <summary>
/// Retrieves a club and its trajectory across seasons, including the per-season teams that belong to it and the tournaments each was registered in.
/// </summary>
/// <param name="idOrSlug">The club's GUID id or its slug.</param>
/// <returns>
/// <para>Returns 200 OK with the club history when found.</para>
/// <para>Returns 404 Not Found when no club matches.</para>
/// </returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubHistoryResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ClubHistoryResponse>> GetClubHistory(string idOrSlug)
{
ClubHistoryResponse? history = await clubService.GetClubHistoryAsync(idOrSlug);
if (history is null)
{
return this.NotFoundProblem(nameof(Club), idOrSlug);
}
return Ok(history);
}
/// <summary>
/// Idempotently links every unlinked team to a stable club, creating clubs as needed; safe to re-run since a second call reports zeros.
/// </summary>
/// <returns>Returns 200 OK with how many clubs were created and teams linked.</returns>
[HttpPost("backfill")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubBackfillResult))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ClubBackfillResult>> BackfillClubs()
{
ClubBackfillResult result = await clubService.BackfillClubsAsync();
return Ok(result);
}
/// <summary>
/// Retrieves every club's stable identity summary, used to populate the "link to parent club" picker.
/// </summary>
/// <returns>Returns 200 OK with every club, ordered by name.</returns>
[HttpGet()]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<ClubSummaryResponse>))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<IEnumerable<ClubSummaryResponse>>> GetAllClubs()
{
IEnumerable<ClubSummaryResponse> clubs = await clubService.GetAllClubsAsync();
return Ok(clubs);
}
/// <summary>
/// Renames a club. The club's slug never changes, so its public URL stays stable.
/// </summary>
/// <param name="id">The club to rename.</param>
/// <param name="request">The new display name.</param>
/// <returns>
/// <para>Returns 200 OK with the club's updated history when renamed.</para>
/// <para>Returns 404 Not Found when the club doesn't exist.</para>
/// <para>Returns 409 Conflict when the name is blank.</para>
/// </returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubHistoryResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ClubHistoryResponse>> RenameClub(Guid id, [FromBody] RenameClubRequest request)
{
ClubHistoryResponse history = await clubService.RenameClubAsync(id, request.Name);
return Ok(history);
}
/// <summary>
/// Links a club as a squad of a parent institution club, so both are shown together as one institution.
/// </summary>
/// <param name="id">The squad club to link.</param>
/// <param name="request">The parent institution club it becomes a squad of.</param>
/// <returns>
/// <para>Returns 200 OK with the club's updated history when linked.</para>
/// <para>Returns 404 Not Found when either club doesn't exist.</para>
/// <para>Returns 409 Conflict when the link would create a chain deeper than institution -> squads.</para>
/// </returns>
[HttpPut("{id:guid}/parent")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubHistoryResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ClubHistoryResponse>> LinkClubParent(Guid id, [FromBody] LinkClubParentRequest request)
{
ClubHistoryResponse history = await clubService.LinkClubToParentAsync(id, request.ParentClubId);
return Ok(history);
}
/// <summary>
/// Clears a club's parent institution link, if any.
/// </summary>
/// <param name="id">The squad club to unlink.</param>
/// <returns>
/// <para>Returns 200 OK with the club's updated history.</para>
/// <para>Returns 404 Not Found when the club doesn't exist.</para>
/// </returns>
[HttpDelete("{id:guid}/parent")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubHistoryResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ClubHistoryResponse>> UnlinkClubParent(Guid id)
{
ClubHistoryResponse history = await clubService.UnlinkClubParentAsync(id);
return Ok(history);
}
/// <summary>
/// Deletes a club, blocking the delete while it still has teams or squad clubs linked to it.
/// </summary>
/// <param name="id">The club to delete.</param>
/// <returns>
/// <para>Returns 204 No Content when deleted.</para>
/// <para>Returns 404 Not Found when the club doesn't exist.</para>
/// <para>Returns 409 Conflict when it still has teams or squad clubs linked to it.</para>
/// </returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult> DeleteClub(Guid id)
{
await clubService.DeleteClubAsync(id);
return NoContent();
}
}
@@ -0,0 +1,49 @@
using Application.DTOs.DataMaintenance.Response;
using Application.Interfaces.Services;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Threading;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Admin-only tools for resetting tournament-domain data to a clean, realistic sample state, never touching identity.
/// </summary>
[Route("api/data-maintenance")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class DataMaintenanceController(IDataMaintenanceService dataMaintenanceService) : ControllerBase
{
/// <summary>
/// Deletes every tournament-domain row. Identity is untouched.
/// </summary>
[HttpPost("wipe")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DataWipeResult))]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<DataWipeResult>> Wipe(CancellationToken ct)
{
DataWipeResult result = await dataMaintenanceService.WipeSampleDataAsync(ct);
return Ok(result);
}
/// <summary>
/// Seeds 2 complete sample tournaments, returning 409 if the database already has tournament data since it must be wiped first.
/// </summary>
[HttpPost("seed")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DataSeedResult))]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DataSeedResult>> Seed(CancellationToken ct)
{
DataSeedResult result = await dataMaintenanceService.SeedSampleDataAsync(ct);
return Ok(result);
}
}
@@ -0,0 +1,191 @@
using API.Utils;
using Application.DTOs.Abstract.Response;
using Application.DTOs.Divisions.Request;
using Application.DTOs.Divisions.Response;
using Application.Interfaces.Services;
using Application.Utils.Helper.Standings;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages divisions; reads are public while writes require Owner or Admin.
/// </summary>
/// <param name="divisionService">The division service.</param>
/// <param name="mapper">The AutoMapper instance.</param>
[Route("api/divisions/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class DivisionController(
IDivisionService divisionService,
IMapper mapper
) : ControllerBase
{
/// <summary>
/// Creates a new division.
/// </summary>
/// <param name="divisionRequest">The division request.</param>
/// <returns>The created division response.
/// <para>Returns 201 Created with the division response if the creation was successful.</para>
/// <para>Returns 403 Forbidden if the user is not authenticated.</para>
/// </returns>
[HttpPost()]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(DetailedDivisionResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<DivisionResponse>> CreateDivision(CreateDivisionRequest divisionRequest)
{
Division mappedDivision = mapper.Map<Division>(divisionRequest);
Division createdDivision = await divisionService.CreateDivisionAsync(mappedDivision);
DivisionResponse divisionResponse = mapper.Map<DivisionResponse>(createdDivision);
return CreatedAtAction(nameof(GetDivisionById), new { idOrSlug = divisionResponse.Id }, divisionResponse);
}
/// <summary>
/// Retrieves a division by its id or its public slug.
/// </summary>
/// <param name="idOrSlug">The GUID id or slug of the division to retrieve.</param>
/// <returns>The division with the specified id or slug.
/// <para>Returns 200 Ok with the division response if it was found.</para>
/// <para>Returns 404 Not Found if the division with the provided id or slug was not found.</para>
/// </returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}/detail")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedDivisionResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DivisionResponse>> GetDivisionById(string idOrSlug)
{
Division? division = await divisionService.GetSimpleDivisionByIdOrSlugAsync(idOrSlug);
if (division is null)
{
return this.NotFoundProblem(nameof(Division), idOrSlug);
}
DivisionResponse divisionResponse = mapper.Map<DivisionResponse>(division);
await PopulateStandingsAsync(divisionResponse);
return Ok(divisionResponse);
}
/// <summary>
/// Deletes a division by its id.
/// </summary>
/// <param name="id">The id of the division to delete.</param>
/// <returns>
/// Returns 200 Ok if the division was successfully deleted.
/// Returns 400 Bad Request if the division with the provided id was not found.
/// Returns 403 Forbidden if the user is not authenticated.
/// </returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> DeleteDivisionById(Guid id)
{
await divisionService.DeleteDivisionAsync(id);
return NoContent();
}
/// <summary>
/// Updates a division by its id.
/// </summary>
/// <param name="id">The id of the division to update.</param>
/// <param name="divisionRequest">
/// The updated division information. If TournamentId is set, the
/// division, and everything under it, is moved to that tournament.
/// </param>
/// <returns>
/// Returns 200 Ok with the updated division response if the update was successful.
/// Returns 404 Not Found if the division, or the target tournament when reassigning, was not found.
/// Returns 403 Forbidden if the user is not authenticated.
/// </returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DivisionResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> UpdateDivisionById(Guid id, UpdateDivisionRequest divisionRequest)
{
Division? existingDivision = await divisionService.GetFullDivisionByIdAsync(id);
if (existingDivision is null)
{
return this.NotFoundProblem(nameof(Division), id);
}
mapper.Map(divisionRequest, existingDivision);
if (divisionRequest.TournamentId is Guid tournamentId)
{
bool tournamentAssigned = await divisionService.TryAssignTournamentAsync(existingDivision, tournamentId);
if (!tournamentAssigned)
{
return this.NotFoundProblem(nameof(Tournament), tournamentId);
}
}
await divisionService.UpdateDivisionAsync(existingDivision);
DivisionResponse divisionResponse = mapper.Map<DivisionResponse>(existingDivision);
return Ok(divisionResponse);
}
/// <summary>
/// Retrieves filtered divisions with pagination.
/// </summary>
/// <param name="filterRequest">The filtering and pagination parameters.</param>
/// <returns>A paginated response containing the filtered divisions.</returns>
[AllowAnonymous]
[HttpGet()]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<DetailedDivisionResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PaginatedResponse<DetailedDivisionResponse>>> GetFilteredDivisions([FromQuery] GetDivisionsFilteredRequest filterRequest)
{
PaginatedResponse<Division> paginatedDivisions = await divisionService.GetAllDivisionsAsync(filterRequest);
PaginatedResponse<DivisionResponse> response = mapper.Map<PaginatedResponse<DivisionResponse>>(paginatedDivisions);
// Each division's standings must be populated here too, since the divisions table's team counter reads Positions.Length, and leaving it null made every row show 0 teams even when the division was fully populated.
foreach (DivisionResponse divisionResponse in response.Items)
{
await PopulateStandingsAsync(divisionResponse);
}
return Ok(response);
}
/// <summary>
/// Fills a division response's standings from its Group stages, setting GroupStandings to one table per group and Positions to the pooled union across all groups so the team counter reflects every group's teams.
/// </summary>
private async Task PopulateStandingsAsync(DivisionResponse divisionResponse)
{
List<GroupStandings> groups = await divisionService.GetGroupStandingsByDivisionIdAsync(divisionResponse.Id);
divisionResponse.GroupStandings = mapper.Map<List<GroupStandingsResponse>>(groups);
List<Position> pooledPositions = [.. groups
.SelectMany(group => group.Positions)
.GroupBy(position => position.TeamId)
.Select(group => group.First())];
divisionResponse.Positions = mapper.Map<List<PositionResponse>>(pooledPositions);
}
}
@@ -0,0 +1,110 @@
using Application.DTOs.Divisions.Request;
using Application.DTOs.Stage.Response;
using Application.DTOs.Team.Response;
using Application.Interfaces.Services;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages a division's team roster and its sub-group structure; every route requires Owner or Admin.
/// </summary>
/// <param name="divisionRosterService">Service for the division-level team roster and its sub-group structure.</param>
/// <param name="mapper">AutoMapper instance for mapping between entities and DTOs.</param>
[Route("api/divisions/{divisionId:guid}/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class DivisionRosterController(
IDivisionRosterService divisionRosterService,
IMapper mapper) : ControllerBase
{
/// <summary>
/// Lists every team currently enrolled in the division, independent of any stage placement.
/// </summary>
[HttpGet("roster")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<TeamResponse>))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<List<TeamResponse>>> GetRoster(Guid divisionId)
{
List<Team> roster = await divisionRosterService.GetRosterAsync(divisionId);
return Ok(mapper.Map<List<TeamResponse>>(roster));
}
/// <summary>
/// Enrolls one or more teams in the division's roster, returning the roster as it stands afterward.
/// </summary>
[HttpPost("roster")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<TeamResponse>))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<List<TeamResponse>>> EnrollTeams(Guid divisionId, EnrollTeamsRequest request)
{
await divisionRosterService.EnrollTeamsAsync(divisionId, request.TeamIds);
List<Team> roster = await divisionRosterService.GetRosterAsync(divisionId);
return Ok(mapper.Map<List<TeamResponse>>(roster));
}
/// <summary>
/// Removes one or more teams from the division's roster, cascading to their current stage placements first.
/// </summary>
[HttpDelete("roster")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult> UnenrollTeams(Guid divisionId, UnenrollTeamsRequest request)
{
await divisionRosterService.UnenrollTeamsAsync(divisionId, request.TeamIds);
return NoContent();
}
/// <summary>
/// Replaces the division's sub-group stages with a new count, re-balancing the untouched roster across them.
/// </summary>
[HttpPost("sub-groups/rebuild")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<StageResponse>))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<List<StageResponse>>> RebuildSubGroups(Guid divisionId, RebuildSubGroupsRequest request)
{
List<Stage> stages = await divisionRosterService.RebuildSubGroupsAsync(divisionId, request.SubGroupCount);
return Ok(mapper.Map<List<StageResponse>>(stages));
}
/// <summary>
/// Clears every current sub-group placement and re-deals the whole roster in a fresh balanced distribution.
/// </summary>
[HttpPost("roster/auto-distribute")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult> AutoDistributeRoster(Guid divisionId)
{
await divisionRosterService.AutoDistributeRosterAsync(divisionId);
return NoContent();
}
/// <summary>
/// Manually moves one enrolled team from one sub-group to another, re-validating only the minimum sub-group size.
/// </summary>
[HttpPost("sub-groups/reassign")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult> ReassignTeamToSubGroup(Guid divisionId, ReassignTeamToSubGroupRequest request)
{
await divisionRosterService.ReassignTeamToSubGroupAsync(request.TeamId, request.FromStageId, request.ToStageId);
return NoContent();
}
}
@@ -0,0 +1,40 @@
using Application.DTOs.Backup.Response;
using Application.Interfaces.Backup;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
/// <summary>
/// Reports maintenance-mode status and lets an admin force-clear a stuck maintenance window.
/// </summary>
[Route("api/maintenance")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class MaintenanceController(IMaintenanceModeState maintenanceModeState) : ControllerBase
{
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MaintenanceStatusResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public ActionResult<MaintenanceStatusResponse> GetStatus()
{
MaintenanceStatusResponse response = new(
maintenanceModeState.IsActive, maintenanceModeState.Reason, maintenanceModeState.EnteredAtUtc);
return Ok(response);
}
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public IActionResult Exit()
{
maintenanceModeState.Exit();
return NoContent();
}
}
@@ -0,0 +1,380 @@
using API.Utils;
using Application.DTOs.Abstract.Response;
using Application.DTOs.Match.Request;
using Application.DTOs.Match.Response;
using Application.DTOs.PlayerStatistic.Request;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages matches; reads are public but writes require Owner or Admin.
/// </summary>
/// <param name="matchService">The Match service.</param>
/// <param name="stageTeamMatchService">The stage-team match service.</param>
/// <param name="matchSeriesService">The playoff series service.</param>
/// <param name="playerStatisticService">The player-statistic service.</param>
/// <param name="stageService">The stage service, used for bracket-round advancement.</param>
/// <param name="mapper">The AutoMapper instance.</param>
[Route("api/matches/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class MatchController(
IMatchService matchService,
IStageTeamMatchService stageTeamMatchService,
IMatchSeriesService matchSeriesService,
IPlayerStatisticService playerStatisticService,
IStageService stageService,
IMapper mapper) : ControllerBase
{
/// <summary>
/// Creates a new match.
/// </summary>
/// <param name="matchRequest">The match request DTO.</param>
/// <returns>The created match response.</returns>
[HttpPost()]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(MinimalMatchResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<MinimalMatchResponse>> CreateMatch(CreateMatchRequest matchRequest)
{
Match mappedMatch = mapper.Map<Match>(matchRequest);
// A court cannot host two matches less than 2 hours apart.
if (mappedMatch.VenueId.HasValue
&& await matchService.HasVenueScheduleConflictAsync(
mappedMatch.VenueId.Value, mappedMatch.MatchDate, Guid.Empty))
{
return this.BadRequestProblem(ErrorMessages.Match.VenueScheduleConflict);
}
Match createdMatch = await matchService.CreateMatchAsync(mappedMatch);
MinimalMatchResponse matchResponse = mapper.Map<MinimalMatchResponse>(createdMatch);
return new ObjectResult(matchResponse) { StatusCode = StatusCodes.Status201Created };
}
/// <summary>
/// Generates automated matches for a given stage.
/// </summary>
/// <param name="id">The unique identifier of the stage for which matches will be generated.</param>
/// <returns>
/// Returns a list of DetailedMatchResponse objects representing the generated matches.
/// </returns>
[HttpPost("generate/{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<DetailedMatchResponse>))]
public async Task<ActionResult> GenerateMatches(Guid id)
{
List<Match> response = await matchService.CreateAutomatedMatchesAsync(stageId: id);
return Ok(mapper.Map<List<DetailedMatchResponse>>(response));
}
/// <summary>
/// Retrieves a match by its id with detailed values.
/// </summary>
/// <param name="id">The id of the match.</param>
/// <returns>The match response DTO.</returns>
[AllowAnonymous]
[HttpGet("{id:guid}/detail")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DetailedMatchResponse>> GetMatchByIdWithScorers(Guid id)
{
Match? match = await matchService.GetMatchByIdWithScorersAsync(id);
if (match is null)
{
return this.NotFoundProblem(nameof(Match), id);
}
DetailedMatchResponse matchResponse = mapper.Map<DetailedMatchResponse>(match);
return Ok(matchResponse);
}
/// <summary>
/// Retrieves a match by its id or its public slug.
/// </summary>
/// <param name="idOrSlug">Match identifier as a GUID or slug.</param>
/// <returns>The match response DTO.</returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DetailedMatchResponse>> GetMatchById(string idOrSlug)
{
Match? match = await matchService.GetMatchByIdOrSlugAsync(idOrSlug);
if (match is null)
{
return this.NotFoundProblem(nameof(Match), idOrSlug);
}
DetailedMatchResponse matchResponse = mapper.Map<DetailedMatchResponse>(match);
return Ok(matchResponse);
}
/// <summary>
/// Updates a match's scheduled date or venue, rejecting the change if the match already started or finished, its teams aren't assigned to the stage, or the new slot conflicts with another match.
/// </summary>
/// <param name="id">The id of the match to update.</param>
/// <param name="updateRequest">The request containing the new match date and/or venue.</param>
/// <returns>Returns 200 OK with the updated match, 400 Bad Request if the change is rejected, or 404 Not Found if no match matches the id.</returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdateMatchDate(Guid id, UpdateMatchRequest updateRequest)
{
Match? existingMatch = await matchService.GetMatchByIdAsync(id);
if (existingMatch is null)
{
return this.NotFoundProblem(nameof(Match), id);
}
if (existingMatch.IsFinished || existingMatch.MatchDate <= DateTime.Now)
{
return this.BadRequestProblem(ErrorMessages.Match.CannotUpdateStartedOrFinished);
}
List<Guid> teamsId = [];
if (existingMatch.HomeTeamId.HasValue)
{
teamsId.Add(existingMatch.HomeTeamId.Value);
}
if (existingMatch.VisitorTeamId.HasValue)
{
teamsId.Add(existingMatch.VisitorTeamId.Value);
}
bool canUpdate = await stageTeamMatchService.AllTeamsAssignedToStage(stageId: existingMatch.StageId, TeamIds: [.. teamsId.Distinct()]);
if (!canUpdate)
{
return this.BadRequestProblem(ErrorMessages.Match.TeamsNotAssignedToStage);
}
mapper.Map(updateRequest, existingMatch);
// A court cannot host two matches less than 2 hours apart.
if (existingMatch.VenueId.HasValue
&& await matchService.HasVenueScheduleConflictAsync(
existingMatch.VenueId.Value, existingMatch.MatchDate, existingMatch.Id))
{
return this.BadRequestProblem(ErrorMessages.Match.VenueScheduleConflict);
}
await matchService.UpdateMatchAsync(existingMatch);
DetailedMatchResponse detailedMatch = mapper.Map<DetailedMatchResponse>(existingMatch);
return Ok(detailedMatch);
}
/// <summary>
/// Retrieves a stage's matches grouped by matchday, using the round as the grouping key rather than the calendar date.
/// </summary>
/// <param name="stageId">The id of the stage whose fixture is requested.</param>
/// <returns>The matches grouped and ordered by round.</returns>
[AllowAnonymous]
[HttpGet("stage/{stageId:guid}/by-round")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<RoundMatchesResponse>))]
public async Task<ActionResult<List<RoundMatchesResponse>>> GetStageMatchesByRound(Guid stageId)
{
List<Match> matches = await matchService.GetStageMatchesByRoundAsync(stageId);
List<RoundMatchesResponse> rounds = [.. matches
.GroupBy(match => match.Round)
.OrderBy(group => group.Key ?? int.MaxValue)
.Select(group => new RoundMatchesResponse
{
Round = group.Key,
Matches = mapper.Map<List<DetailedMatchResponse>>(group.ToList()),
})];
return Ok(rounds);
}
/// <summary>
/// Reprograms or suspends a match, marking it suspended and optionally moving it to a new date without changing its round or the rest of the fixture.
/// </summary>
/// <param name="id">The id of the match to suspend/reprogram.</param>
/// <param name="suspendRequest">The request with an optional new date.</param>
/// <returns>The updated match response.</returns>
[HttpPut("{id:guid}/suspend")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> SuspendMatch(Guid id, SuspendMatchRequest suspendRequest)
{
Match? existingMatch = await matchService.GetMatchByIdAsync(id);
if (existingMatch is null)
{
return this.NotFoundProblem(nameof(Match), id);
}
DateTime effectiveDate = suspendRequest.MatchDate ?? existingMatch.MatchDate;
// A court cannot host two matches less than 2 hours apart.
if (existingMatch.VenueId.HasValue
&& await matchService.HasVenueScheduleConflictAsync(
existingMatch.VenueId.Value, effectiveDate, existingMatch.Id))
{
return this.BadRequestProblem(ErrorMessages.Match.VenueScheduleConflict);
}
Match? updatedMatch = await matchService.SuspendMatchAsync(id, suspendRequest.MatchDate);
if (updatedMatch is null)
{
return this.NotFoundProblem(nameof(Match), id);
}
DetailedMatchResponse detailedMatch = mapper.Map<DetailedMatchResponse>(updatedMatch);
return Ok(detailedMatch);
}
/// <summary>
/// Deletes a match by its id.
/// </summary>
/// <param name="id">The id of the match to delete.</param>
/// <returns>Returns 204 No Content on success.</returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> DeleteMatchById(Guid id)
{
await matchService.DeleteMatchAsync(id);
return NoContent();
}
/// <summary>
/// Retrieves filtered matches with pagination.
/// </summary>
/// <param name="filterRequest">The filtering and pagination parameters.</param>
/// <returns>A paginated response containing the filtered matches.</returns>
[AllowAnonymous]
[HttpGet()]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<DetailedMatchResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PaginatedResponse<DetailedMatchResponse>>> GetFilteredMatches([FromQuery] GetMatchesFilteredRequest filterRequest)
{
PaginatedResponse<Match> paginatedMatches = await matchService.GetAllMatchesAsync(filterRequest);
PaginatedResponse<DetailedMatchResponse> response = mapper.Map<PaginatedResponse<DetailedMatchResponse>>(paginatedMatches);
return Ok(response);
}
/// <summary>
/// Records a match's final score, which may decide a playoff series and auto-advance a bracket winner into the next round.
/// </summary>
/// <param name="id">The id of the match to update.</param>
/// <param name="scoreRequest">The request with updated scores.</param>
/// <returns>Returns 200 OK with the updated match, or 404 Not Found if no match matches the id.</returns>
[HttpPut("{id:guid}/score")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdateMatchScore(Guid id, UpdateMatchScoreRequest scoreRequest)
{
Match? updatedMatch = await matchService.LoadMatchResultAsync(id, scoreRequest.HomeScore, scoreRequest.VisitorScore);
if (updatedMatch is null)
{
return this.NotFoundProblem(nameof(Match), id);
}
if (updatedMatch.SeriesId.HasValue)
{
await matchSeriesService.RecalculateSeriesWinnerAsync(updatedMatch.SeriesId.Value);
}
// Pushes a newly-decided bracket slot's winner into the next round, a no-op for group-stage matches, an undecided mid-series game, or the Final; runs after the series recalculation above so it sees the up-to-date decision.
await stageService.TryAdvanceStageWinnerAsync(updatedMatch.StageId);
DetailedMatchResponse detailedMatch = mapper.Map<DetailedMatchResponse>(updatedMatch);
return Ok(detailedMatch);
}
/// <summary>
/// Finishes a match by loading both teams' scoring sheets in one operation, deriving the final score as the sum of each team's listed player points instead of accepting a typed score directly.
/// </summary>
/// <param name="id">The id of the match to finish.</param>
/// <param name="request">Both teams' per-player points.</param>
/// <returns>The finalized match.</returns>
[HttpPut("{id:guid}/result-from-sheets")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult> LoadMatchResultFromSheets(Guid id, LoadMatchResultFromSheetsRequest request)
{
request.MatchId = id;
Match? updatedMatch = await playerStatisticService.LoadMatchResultFromSheetsAsync(request);
if (updatedMatch is null)
{
return this.NotFoundProblem(nameof(Match), id);
}
if (updatedMatch.SeriesId.HasValue)
{
await matchSeriesService.RecalculateSeriesWinnerAsync(updatedMatch.SeriesId.Value);
}
// Pushes a newly-decided bracket slot's winner into the next round, a no-op for group-stage matches, an undecided mid-series game, or the Final; runs after the series recalculation above so it sees the up-to-date decision.
await stageService.TryAdvanceStageWinnerAsync(updatedMatch.StageId);
DetailedMatchResponse detailedMatch = mapper.Map<DetailedMatchResponse>(updatedMatch);
return Ok(detailedMatch);
}
/// <summary>
/// Marks a match as a walkover, awarding the regulation default result to the present team.
/// </summary>
/// <param name="id">The id of the match to mark as a walkover.</param>
/// <param name="walkOverRequest">The request identifying the present team.</param>
/// <returns>The updated match response.</returns>
[HttpPut("{id:guid}/walkover")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult> LoadWalkOver(Guid id, LoadWalkOverRequest walkOverRequest)
{
Match? updatedMatch = await matchService.LoadWalkOverAsync(id, walkOverRequest.PresentTeamId, walkOverRequest.PresentTeamScore);
if (updatedMatch is null)
{
return this.NotFoundProblem(nameof(Match), id);
}
if (updatedMatch.SeriesId.HasValue)
{
await matchSeriesService.RecalculateSeriesWinnerAsync(updatedMatch.SeriesId.Value);
}
// Pushes a newly-decided bracket slot's winner into the next round, a no-op for group-stage matches, an undecided mid-series game, or the Final; runs after the series recalculation above so it sees the up-to-date decision.
await stageService.TryAdvanceStageWinnerAsync(updatedMatch.StageId);
DetailedMatchResponse detailedMatch = mapper.Map<DetailedMatchResponse>(updatedMatch);
return Ok(detailedMatch);
}
}
@@ -0,0 +1,93 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.MatchSeries.Request;
using Application.DTOs.MatchSeries.Response;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages best-of-N playoff series between two teams; reads are public but writes require Owner or Admin.
/// </summary>
/// <param name="matchSeriesService">Service for series business logic and persistence operations.</param>
/// <param name="mapper">AutoMapper instance for mapping between entities and DTOs.</param>
[Route("api/match-series/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class MatchSeriesController(IMatchSeriesService matchSeriesService, IMapper mapper) : ControllerBase
{
/// <summary>
/// Creates a new best-of-N series between two teams at a stage.
/// </summary>
/// <param name="request">The series creation request DTO.</param>
/// <returns>The created series response.</returns>
[HttpPost()]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(MatchSeriesResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<MatchSeriesResponse>> CreateSeries(CreateMatchSeriesRequest request)
{
MatchSeries createdSeries = await matchSeriesService.CreateSeriesAsync(request.StageId, request.HomeTeamId, request.VisitorTeamId);
MatchSeriesResponse response = mapper.Map<MatchSeriesResponse>(createdSeries);
return CreatedAtAction(nameof(GetSeriesById), new { id = response.Id }, response);
}
/// <summary>
/// Retrieves a series by its id.
/// </summary>
/// <param name="id">The id of the series.</param>
/// <returns>The series entity, including its games.</returns>
[AllowAnonymous]
[HttpGet("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MatchSeriesResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MatchSeriesResponse>> GetSeriesById(Guid id)
{
MatchSeries? series = await matchSeriesService.GetSeriesByIdAsync(id);
return series is null ? (ActionResult<MatchSeriesResponse>) NotFound(ErrorMessages.MatchSeries.NotFoundById(id)) : (ActionResult<MatchSeriesResponse>) Ok(mapper.Map<MatchSeriesResponse>(series));
}
/// <summary>
/// Retrieves filtered and paginated series.
/// </summary>
/// <param name="filterRequest">The filter and pagination parameters.</param>
/// <returns>Paginated list of series.</returns>
[AllowAnonymous]
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<MatchSeriesResponse>))]
public async Task<ActionResult<PaginatedResponse<MatchSeriesResponse>>> GetFilteredSeries([FromQuery] GetMatchSeriesFilteredRequest filterRequest)
{
PaginatedResponse<MatchSeries> paginatedSeries = await matchSeriesService.GetAllSeriesAsync(filterRequest);
PaginatedResponse<MatchSeriesResponse> response = mapper.Map<PaginatedResponse<MatchSeriesResponse>>(paginatedSeries);
return Ok(response);
}
/// <summary>
/// Schedules the next game of an existing series.
/// </summary>
/// <param name="id">The id of the series to add a game to.</param>
/// <param name="request">The game scheduling request.</param>
/// <returns>The created game.</returns>
[HttpPost("{id:guid}/games")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SeriesGameResponse>> AddGameToSeries(Guid id, AddGameToSeriesRequest request)
{
Match game = await matchSeriesService.AddGameToSeriesAsync(id, request.MatchDate, request.VenueId);
return CreatedAtAction(nameof(GetSeriesById), new { id }, mapper.Map<SeriesGameResponse>(game));
}
}
@@ -0,0 +1,145 @@
using API.Utils;
using Application.DTOs.MedicalRecord.Request;
using Application.DTOs.MedicalRecord.Response;
using Application.Interfaces.Services;
using Application.Interfaces.Storage;
using Application.Utils.Constants;
using Domain.Constants;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Security.Claims;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages player medical records and per-season eligibility, scoped to a player, team, and tournament rather than to the player globally.
/// </summary>
/// <param name="medicalRecordService">The medical-record service.</param>
/// <param name="medicalRecordStorage">The medical-record file storage boundary.</param>
[Route("api/medical-records/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class MedicalRecordController(
IMedicalRecordService medicalRecordService,
IMedicalRecordStorage medicalRecordStorage) : ControllerBase
{
/// <summary>
/// Uploads a player's medical-record PDF for a team and tournament, starting the record in Pending status until it is approved.
/// </summary>
/// <param name="request">The player, team, tournament, and PDF file.</param>
/// <returns>The resulting medical-record state, with status Pending.</returns>
[HttpPost()]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(MedicalRecordResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<MedicalRecordResponse>> UploadMedicalRecord([FromForm] UploadMedicalRecordRequest request)
{
if (!request.File.IsValidPdfFile())
{
return this.BadRequestProblem(ErrorMessages.MedicalRecord.InvalidPdfFile);
}
// Rejects the upload before touching storage when the ficha is already Approved, since an approved record is view or download only.
MedicalRecordResponse? current = await medicalRecordService.GetAsync(
request.PlayerId, request.TeamId, request.TournamentId);
if (current?.Status == MedicalRecordStatus.Approved)
{
return Conflict(ErrorMessages.MedicalRecord.AlreadyApproved);
}
string fileReference = await medicalRecordStorage.StoreAsync(
request.TeamId,
request.PlayerId,
request.File.FileName,
request.File.OpenReadStream());
MedicalRecordResponse response = await medicalRecordService.RecordUploadAsync(
request.PlayerId, request.TeamId, request.TournamentId,
fileReference, request.File.FileName, GetActor());
return new ObjectResult(response) { StatusCode = StatusCodes.Status201Created };
}
/// <summary>
/// Approves or rejects a player's medical record for a team and tournament, where approval grants eligibility and rejection can include an optional reason.
/// </summary>
/// <param name="request">The player, team, tournament, decision, and reason.</param>
/// <returns>The resulting medical-record state.</returns>
[HttpPut("review")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MedicalRecordResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<MedicalRecordResponse>> ReviewMedicalRecord(ReviewMedicalRecordRequest request)
{
MedicalRecordResponse response = await medicalRecordService.ReviewAsync(
request.PlayerId, request.TeamId, request.TournamentId,
request.Approve, request.Reason, GetActor());
return Ok(response);
}
/// <summary>
/// Returns the current medical-record and eligibility state of a player's season registration.
/// </summary>
/// <param name="playerId">The player.</param>
/// <param name="teamId">The team the player is registered to.</param>
/// <param name="tournamentId">The tournament, i.e. the season.</param>
/// <returns>The medical-record state, or 404 when none exists.</returns>
[HttpGet()]
[Authorize(Roles = Roles.AdminOrOwner)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MedicalRecordResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MedicalRecordResponse>> GetMedicalRecord(
[FromQuery] Guid playerId, [FromQuery] Guid teamId, [FromQuery] Guid tournamentId)
{
MedicalRecordResponse? response = await medicalRecordService.GetAsync(playerId, teamId, tournamentId);
return response is null
? this.NotFoundProblem(nameof(MedicalRecordResponse), $"{playerId}/{teamId}/{tournamentId}")
: Ok(response);
}
/// <summary>
/// Downloads a player's medical-record PDF by streaming it through the API rather than via a public URL, since the storage area is private.
/// </summary>
/// <param name="playerId">The player.</param>
/// <param name="teamId">The team the player is registered to.</param>
/// <param name="tournamentId">The tournament, i.e. the season.</param>
/// <returns>The stored PDF, or 404 when no record/file exists.</returns>
[HttpGet("download")]
[Authorize(Roles = Roles.AdminOrOwner)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DownloadMedicalRecord(
[FromQuery] Guid playerId, [FromQuery] Guid teamId, [FromQuery] Guid tournamentId)
{
MedicalRecordResponse? record = await medicalRecordService.GetAsync(playerId, teamId, tournamentId);
if (record?.FileUrl is null)
{
return this.NotFoundProblem(nameof(MedicalRecordResponse), $"{playerId}/{teamId}/{tournamentId}");
}
byte[] content = await medicalRecordStorage.DownloadAsync(record.FileUrl);
string fileName = string.IsNullOrWhiteSpace(record.FileName) ? "medical-record.pdf" : record.FileName;
return File(content, "application/pdf", fileName);
}
private string GetActor()
{
return User?.Identity?.Name
?? User?.FindFirstValue(ClaimTypes.NameIdentifier)
?? AuditConstants.SystemUser;
}
}
@@ -0,0 +1,249 @@
using API.Utils;
using Application.DTOs.Abstract.Response;
using Application.DTOs.Player.Request;
using Application.DTOs.Player.Response;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages players; public reads return minimal data, while full details and every write require staff roles.
/// </summary>
/// <param name="playerService">The Player service.</param>
/// <param name="teamService">The Team service.</param>
/// <param name="mapper">The AutoMapper instance.</param>
[Route("api/players/")]
[ApiController]
public class PlayerController(
IPlayerService playerService,
ITeamService teamService,
IMapper mapper
) : ControllerBase
{
/// <summary>
/// Creates a player under the specified team, returning the full admin-facing player response rather than the trimmed public shape.
/// </summary>
/// <param name="playerRequest">The player request.</param>
/// <returns>The created Player response.
/// <para>Returns 201 Created with the Player response if the creation was successful.</para>
/// <para>Returns 400 Bad Request if the team was not found or is not linked to a tournament.</para>
/// <para>Returns 403 Forbidden if the user lacks the Admin or Owner role.</para>
/// </returns>
[Authorize(Roles = Roles.AdminOrOwner)]
[HttpPost()]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(AdminPlayerResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AdminPlayerResponse>> CreatePlayerAsync(CreatePlayerRequest playerRequest)
{
Guid teamId = playerRequest.TeamId;
Team? existingTeam = await teamService.GetTeamByIdAsync(teamId);
if (existingTeam is null)
{
return this.BadRequestProblem(ErrorMessages.Team.NotFound(teamId));
}
if (existingTeam.TournamentId is null)
{
return this.BadRequestProblem(ErrorMessages.Team.NotInTournament(teamId));
}
Player mappedPlayer = mapper.Map<Player>(playerRequest);
Player createdPlayer = await playerService.CreatePlayerAsync(mappedPlayer, existingTeam.TournamentId.Value);
AdminPlayerResponse playerResponse = mapper.Map<AdminPlayerResponse>(createdPlayer);
return CreatedAtRoute("GetPlayerById", new { idOrSlug = createdPlayer.Id }, playerResponse);
}
/// <summary>
/// Registers a player onto a team's roster for a season, enforcing one-team-per-tournament, the roster-size cap, and unique dorsal numbers.
/// </summary>
/// <param name="playerId">The player to register.</param>
/// <param name="request">The team, tournament and optional dorsal.</param>
/// <returns>
/// <para>Returns 200 OK with the registration outcome.</para>
/// <para>Returns 409 Conflict if a roster invariant is violated.</para>
/// <para>Returns 403 Forbidden if the user is not authorized.</para>
/// </returns>
[Authorize(Roles = Roles.AdminOrOwner)]
[HttpPost("{playerId:guid}/registration")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerRegistrationResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PlayerRegistrationResponse>> RegisterPlayerToTeam(
Guid playerId, [FromBody] RegisterPlayerToTeamRequest request)
{
PlayerTeamRegistration registration = await playerService.RegisterPlayerToTeamAsync(
playerId, request.TeamId, request.TournamentId, request.JerseyNumber);
return Ok(mapper.Map<PlayerRegistrationResponse>(registration));
}
/// <summary>
/// Retrieves a player by its id or its public slug.
/// </summary>
/// <param name="idOrSlug">The GUID id or slug of the player to retrieve.</param>
/// <returns>The Player with the specified id or slug.
/// <para>Returns 200 OK with the Player response if it was found.</para>
/// <para>Returns 404 Not Found if the Player with the provided id or slug was not found.</para>
/// </returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}", Name = "GetPlayerById")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PublicPlayerResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PublicPlayerResponse>> GetPlayerByIdAsync(string idOrSlug)
{
Player? player = await playerService.GetPlayerByIdOrSlugAsync(idOrSlug);
if (player is null)
{
return this.NotFoundProblem(nameof(Player), idOrSlug);
}
PublicPlayerResponse playerResponse = mapper.Map<PublicPlayerResponse>(player);
return Ok(playerResponse);
}
/// <summary>
/// Retrieves a player by its id or its slug, with complete data for the admin view.
/// </summary>
/// <param name="idOrSlug">The GUID id or exact slug of the player to retrieve.</param>
/// <returns>The player's complete admin data.
/// <para>Returns 200 OK with the player's admin data if the player was found.</para>
/// <para>Returns 404 Not Found if no player matches the provided id or slug.</para>
/// </returns>
[Authorize(Roles = Roles.AdminOrOwner)]
[HttpGet("admin/{idOrSlug}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AdminPlayerResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AdminPlayerResponse>> GetPlayerByIdCompleteDataAsync(string idOrSlug)
{
Player? player = await playerService.GetPlayerByIdOrSlugAsync(idOrSlug);
if (player is null)
{
return this.NotFoundProblem(nameof(Player), idOrSlug);
}
AdminPlayerResponse playerResponse = mapper.Map<AdminPlayerResponse>(player);
return Ok(playerResponse);
}
/// <summary>
/// Updates a player by its id.
/// </summary>
/// <param name="id">The id of the player to update.</param>
/// <param name="playerRequest">The player request.</param>
/// <returns>
/// Returns 200 OK with the updated player if the update was successful.
/// Returns 400 Bad Request if the player's team was not found or is not linked to a tournament.
/// Returns 404 Not Found if no player matches the provided id.
/// Returns 403 Forbidden if the user lacks the Admin or Owner role.
/// </returns>
[Authorize(Roles = Roles.AdminOrOwner)]
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UpdatePlayerAsync(Guid id, UpdatePlayerRequest playerRequest)
{
Player? existingPlayer = await playerService.GetPlayerByIdAsync(id);
if (existingPlayer is null)
{
return this.NotFoundProblem(nameof(Player), id);
}
mapper.Map(playerRequest, existingPlayer);
Team? currentTeam = await teamService.GetTeamByIdAsync(existingPlayer.TeamId);
if (currentTeam is null)
{
return this.BadRequestProblem(ErrorMessages.Team.NotFound(existingPlayer.TeamId));
}
if (currentTeam.TournamentId is null)
{
return this.BadRequestProblem(ErrorMessages.Team.NotInTournament(existingPlayer.TeamId));
}
await playerService.UpdatePlayerAsync(existingPlayer, currentTeam.TournamentId.Value);
return Ok(existingPlayer);
}
/// <summary>
/// Deletes a player by its id.
/// </summary>
/// <param name="id">The id of the Player to delete.</param>
/// <returns>
/// Returns 204 No Content if the Player was successfully deleted.
/// Returns 403 Forbidden if the user lacks the Admin or Owner role.
/// </returns>
[Authorize(Roles = Roles.AdminOrOwner)]
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeletePlayerByIdAsync(Guid id)
{
await playerService.DeletePlayerAsync(id);
return NoContent();
}
/// <summary>
/// Retrieves filtered players with pagination.
/// </summary>
/// <param name="filterRequest">The filtering and pagination parameters.</param>
/// <returns>A paginated response containing the filtered players.</returns>
[AllowAnonymous]
[HttpGet("public")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<PublicPlayerResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PaginatedResponse<PublicPlayerResponse>>> GetFilteredPlayersAsync([FromQuery] PlayerFilterRequestBase filterRequest)
{
PaginatedResponse<Player> paginatedPlayers = await playerService.GetAllPlayersAsync(filterRequest);
PaginatedResponse<PublicPlayerResponse> response = mapper.Map<PaginatedResponse<PublicPlayerResponse>>(paginatedPlayers);
return Ok(response);
}
/// <summary>
/// Retrieves filtered players with pagination and detailed information, for admin use only.
/// </summary>
/// <param name="filterRequest">The filtering and pagination parameters.</param>
/// <returns>A paginated response containing the filtered players.</returns>
/// <response code="200">Returns a paginated list of filtered players</response>
/// <response code="400">Returns 400 if there is an invalid filter parameter or the filter results in no data</response>
/// <response code="403">Returns 403 if the user does not have the required admin permissions</response>
[Authorize(Roles = Roles.AdminOrOwner)]
[HttpGet("")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<AdminPlayerResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<PaginatedResponse<AdminPlayerResponse>>> GetFilteredPlayersPrivateAsync([FromQuery] GetPlayersFilteredRequest filterRequest)
{
PaginatedResponse<Player> paginatedPlayers = await playerService.GetAllPlayersAsync(filterRequest);
PaginatedResponse<AdminPlayerResponse> response = mapper.Map<PaginatedResponse<AdminPlayerResponse>>(paginatedPlayers);
return Ok(response);
}
}
@@ -0,0 +1,235 @@
using API.Utils;
using Application.DTOs.Abstract.Response;
using Application.DTOs.PlayerSanction.Request;
using Application.DTOs.PlayerSanction.Response;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages player sanctions; reads are public but writes require Owner or Admin.
/// </summary>
/// <param name="playerSanctionService">The Player Sanction service.</param>
/// <param name="mapper">The AutoMapper instance.</param>
[Route("api/player-sanctions/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class PlayerSanctionController(IPlayerSanctionService playerSanctionService, IMapper mapper) : ControllerBase
{
/// <summary>
/// Creates a sanction against a player, team, or staff member, enriched with the subject's display name and fechas-based status.
/// </summary>
/// <param name="playerSanctionRequest">The player sanction request DTO.</param>
/// <returns>The created player sanction response.</returns>
[HttpPost()]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(PlayerSanctionResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<PlayerSanctionResponse>> CreatePlayerSanction(CreatePlayerSanctionRequest playerSanctionRequest)
{
PlayerSanction mappedSanction = mapper.Map<PlayerSanction>(playerSanctionRequest);
PlayerSanction createdSanction = await playerSanctionService.CreatePlayerSanctionAsync(mappedSanction);
PlayerSanctionResponse sanctionResponse = await ToResponseAsync(createdSanction);
return CreatedAtAction(nameof(GetPlayerSanctionById), new { idOrSlug = sanctionResponse.Id }, sanctionResponse);
}
/// <summary>
/// Retrieves a player sanction by its id or its slug.
/// </summary>
/// <param name="idOrSlug">Player sanction identifier as a GUID or slug.</param>
/// <returns>The player sanction response DTO.</returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerSanctionResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlayerSanctionResponse>> GetPlayerSanctionById(string idOrSlug)
{
PlayerSanction? sanction = await playerSanctionService.GetPlayerSanctionByIdOrSlugAsync(idOrSlug);
if (sanction is null)
{
return this.NotFoundProblem(nameof(PlayerSanction), idOrSlug);
}
PlayerSanctionResponse sanctionResponse = await ToResponseAsync(sanction);
return Ok(sanctionResponse);
}
/// <summary>
/// Retrieves a paginated list of player sanctions filtered by the specified criteria.
/// </summary>
/// <param name="filterRequest">The filtering parameters for player sanctions.</param>
/// <returns>
/// Returns a PaginatedResponse{PlayerSanctionResponse} containing the filtered sanctions.
/// Possible HTTP responses:
/// <list type="bullet">
/// <item><description>200 OK - The filtered sanctions were retrieved successfully.</description></item>
/// <item><description>400 Bad Request - The request parameters were invalid.</description></item>
/// </list>
/// </returns>
[AllowAnonymous]
[HttpGet("find")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<PlayerSanctionResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<PaginatedResponse<PlayerSanctionResponse>>> GetFilteredPlayersPrivateAsync([FromQuery] GetPlayerSanctionsFilteredRequest filterRequest)
{
PaginatedResponse<PlayerSanction> paginatedPlayerSanctions = await playerSanctionService.GetPlayerSanctionsAsync(filterRequest);
PaginatedResponse<PlayerSanctionResponse> response = mapper.Map<PaginatedResponse<PlayerSanctionResponse>>(paginatedPlayerSanctions);
List<PlayerSanctionResponse> enrichedItems = [];
foreach (PlayerSanction sanction in paginatedPlayerSanctions.Items)
{
enrichedItems.Add(await ToResponseAsync(sanction));
}
response.Items = enrichedItems;
return Ok(response);
}
/// <summary>
/// Updates a player sanction's fields and re-enriches the response with the subject's display name and fechas-based status.
/// </summary>
/// <param name="id">The id of the sanction to update.</param>
/// <param name="updateRequest">The request with updated sanction data.</param>
/// <returns>Returns 200 OK with the updated sanction, or 404 Not Found if no sanction matches the id.</returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerSanctionResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdatePlayerSanction(Guid id, UpdatePlayerSanctionRequest updateRequest)
{
PlayerSanction? existingSanction = await playerSanctionService.GetPlayerSanctionByIdAsync(id);
if (existingSanction is null)
{
return this.NotFoundProblem(nameof(PlayerSanction), id);
}
mapper.Map(updateRequest, existingSanction);
await playerSanctionService.UpdatePlayerSanctionAsync(existingSanction);
return Ok(await ToResponseAsync(existingSanction));
}
/// <summary>
/// Submits an appeal against a sanction, moving it into pending review.
/// </summary>
/// <param name="id">The id of the sanction being appealed.</param>
/// <param name="appealRequest">The appeal reason.</param>
/// <returns>The updated sanction response. Returns 400 Bad Request if an appeal is already pending for this sanction.</returns>
[HttpPut("{id:guid}/appeal")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerSanctionResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlayerSanctionResponse>> AppealPlayerSanction(
Guid id, AppealPlayerSanctionRequest appealRequest)
{
PlayerSanction? existingSanction = await playerSanctionService.GetPlayerSanctionByIdAsync(id);
if (existingSanction is null)
{
return this.NotFoundProblem(nameof(PlayerSanction), id);
}
if (existingSanction.AppealStatus == SanctionAppealStatus.Pending)
{
return this.BadRequestProblem(ErrorMessages.PlayerSanction.AppealAlreadyPending);
}
existingSanction.AppealStatus = SanctionAppealStatus.Pending;
existingSanction.AppealReason = appealRequest.Reason;
existingSanction.AppealDate = DateTime.UtcNow;
existingSanction.AppealResolution = null;
existingSanction.AppealResolvedDate = null;
await playerSanctionService.UpdatePlayerSanctionAsync(existingSanction);
return Ok(await ToResponseAsync(existingSanction));
}
/// <summary>
/// Resolves a sanction's pending appeal as accepted or rejected, where accepting lifts the sanction immediately without deleting the sanction record.
/// </summary>
/// <param name="id">The id of the sanction whose appeal is resolved.</param>
/// <param name="resolveRequest">The accepted or rejected decision and resolution notes.</param>
/// <returns>The updated sanction response. Returns 400 Bad Request if the sanction has no pending appeal to resolve.</returns>
[HttpPut("{id:guid}/appeal/resolve")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerSanctionResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlayerSanctionResponse>> ResolvePlayerSanctionAppeal(
Guid id, ResolveAppealRequest resolveRequest)
{
PlayerSanction? existingSanction = await playerSanctionService.GetPlayerSanctionByIdAsync(id);
if (existingSanction is null)
{
return this.NotFoundProblem(nameof(PlayerSanction), id);
}
if (existingSanction.AppealStatus != SanctionAppealStatus.Pending)
{
return this.BadRequestProblem(ErrorMessages.PlayerSanction.NoPendingAppealToResolve);
}
existingSanction.AppealStatus = resolveRequest.Accepted
? SanctionAppealStatus.Accepted
: SanctionAppealStatus.Rejected;
existingSanction.AppealResolution = resolveRequest.Resolution;
existingSanction.AppealResolvedDate = DateTime.UtcNow;
await playerSanctionService.UpdatePlayerSanctionAsync(existingSanction);
return Ok(await ToResponseAsync(existingSanction));
}
/// <summary>
/// Deletes a player sanction by its id.
/// </summary>
/// <param name="id">The id of the player sanction to delete.</param>
/// <returns>Returns 204 No Content on success.</returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> DeletePlayerSanctionById(Guid id)
{
await playerSanctionService.DeletePlayerSanctionAsync(id);
return NoContent();
}
/// <summary>
/// Maps a sanction to its response and enriches it with the fechas-based remaining/active status, labelled in fechas rather than calendar days.
/// </summary>
private async Task<PlayerSanctionResponse> ToResponseAsync(PlayerSanction sanction)
{
PlayerSanctionResponse response = mapper.Map<PlayerSanctionResponse>(sanction);
// Resolves who the sanction targets so the list or detail always shows the subject, regardless of which navigations the query happened to load.
(string? playerFullName, string? teamName, string? staffName) =
await playerSanctionService.ResolveSubjectAsync(sanction);
response.PlayerFullName = playerFullName;
response.TeamName = teamName;
response.StaffName = staffName;
int? fechasRemaining = await playerSanctionService.GetFechasRemainingAsync(sanction);
response.FechasRemaining = fechasRemaining;
response.IsActive = fechasRemaining.HasValue
? fechasRemaining.Value > 0
: sanction.Duration > 0;
return response;
}
}
@@ -0,0 +1,151 @@
using API.Utils;
using Application.DTOs.Abstract.Response;
using Application.DTOs.PlayerStatistic.Request;
using Application.DTOs.PlayerStatistic.Response;
using Application.Interfaces.Services;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages player statistics; reads are public but writes require Owner or Admin.
/// </summary>
/// <param name="playerStatisticService">The Player Statistic service.</param>
/// <param name="mapper">The Auto_mapper instance.</param>
[Route("api/player-statistics/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class PlayerStatisticController(IPlayerStatisticService playerStatisticService, IMapper mapper) : ControllerBase
{
/// <summary>
/// Records a single player-statistic entry directly, without the roster, eligibility, or score-total validation used by the match-sheet endpoint.
/// </summary>
/// <param name="playerStatisticRequest">The player statistic request DTO.</param>
/// <returns>The created player statistic response.</returns>
[HttpPost()]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(PlayerStatisticResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<PlayerStatisticResponse>> CreatePlayerStatistic(CreatePlayerStatisticRequest playerStatisticRequest)
{
PlayerStatistic mappedStatistic = mapper.Map<PlayerStatistic>(playerStatisticRequest);
PlayerStatistic createdStatistic = await playerStatisticService.CreatePlayerStatisticAsync(mappedStatistic);
PlayerStatisticResponse statisticResponse = mapper.Map<PlayerStatisticResponse>(createdStatistic);
return new ObjectResult(statisticResponse) { StatusCode = StatusCodes.Status201Created };
}
/// <summary>
/// Loads a whole team's scoring sheet for a match, validating that per-player points sum to the team's final score and every player is rostered and eligible.
/// </summary>
/// <param name="request">The match, team, and per-player points.</param>
/// <returns>The persisted Points statistics for the team in this match.</returns>
[HttpPost("match-sheet")]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(PaginatedResponse<PlayerStatisticResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult> LoadMatchSheet(LoadMatchSheetRequest request)
{
List<PlayerStatistic> created = await playerStatisticService.LoadTeamMatchSheetAsync(request);
List<PlayerStatisticResponse> response = mapper.Map<List<PlayerStatisticResponse>>(created);
return new ObjectResult(response) { StatusCode = StatusCodes.Status201Created };
}
/// <summary>
/// Retrieves a paginated, filtered list of player statistics.
/// </summary>
/// <param name="filterRequest">The filtering and pagination parameters.</param>
/// <returns>A paginated response containing the filtered player statistics.</returns>
[AllowAnonymous]
[HttpGet()]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<PlayerStatisticResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PaginatedResponse<PlayerStatisticResponse>>> GetFilteredPlayerStatistics(
[FromQuery] GetPlayerStatisticsFilteredRequest filterRequest)
{
PaginatedResponse<PlayerStatistic> paginatedStatistics =
await playerStatisticService.GetPlayerStatisticsAsync(filterRequest);
PaginatedResponse<PlayerStatisticResponse> response =
mapper.Map<PaginatedResponse<PlayerStatisticResponse>>(paginatedStatistics);
return Ok(response);
}
/// <summary>
/// Retrieves a player statistic by its id.
/// </summary>
/// <param name="id">The id of the player statistic.</param>
/// <returns>The player statistic response DTO.</returns>
[AllowAnonymous]
[HttpGet("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerStatisticResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlayerStatisticResponse>> GetPlayerStatisticById(Guid id)
{
PlayerStatistic? statistic = await playerStatisticService.GetPlayerStatisticByIdAsync(id);
if (statistic is null)
{
return this.NotFoundProblem(nameof(PlayerStatistic), id);
}
PlayerStatisticResponse statisticResponse = mapper.Map<PlayerStatisticResponse>(statistic);
return Ok(statisticResponse);
}
/// <summary>
/// Updates a player statistic by its id.
/// </summary>
/// <param name="id">The id of the statistic to update.</param>
/// <param name="updateRequest">The request with updated statistics.</param>
/// <returns>Returns 204 No Content on success, or 404 Not Found if no statistic matches the id.</returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdatePlayerStatistic(Guid id, UpdatePlayerStatisticRequest updateRequest)
{
PlayerStatistic? existingStatistic = await playerStatisticService.GetPlayerStatisticByIdAsync(id);
if (existingStatistic is null)
{
return this.NotFoundProblem(nameof(PlayerStatistic), id);
}
mapper.Map(updateRequest, existingStatistic);
await playerStatisticService.UpdatePlayerStatisticAsync(existingStatistic);
return NoContent();
}
/// <summary>
/// Deletes a player statistic by its id.
/// </summary>
/// <param name="id">The id of the player statistic to delete.</param>
/// <returns>Returns 204 No Content on success.</returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> DeletePlayerStatisticById(Guid id)
{
await playerStatisticService.DeletePlayerStatisticAsync(id);
return NoContent();
}
}
@@ -0,0 +1,102 @@
using API.Utils;
using Application.DTOs.PointDeductions.Request;
using Application.DTOs.PointDeductions.Response;
using Application.Interfaces.Services;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages disciplinary point deductions applied to teams within a division; creating and deleting require Admin or Owner while listing is public.
/// </summary>
/// <param name="deductionService">The point-deduction service.</param>
/// <param name="divisionService">The division service, used to validate the division.</param>
/// <param name="teamService">The team service, used to validate the team.</param>
/// <param name="mapper">The AutoMapper instance.</param>
[Route("api/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class PointDeductionController(
ITeamPointDeductionService deductionService,
IDivisionService divisionService,
ITeamService teamService,
IMapper mapper) : ControllerBase
{
/// <summary>
/// Applies a point deduction to a team in a division.
/// </summary>
/// <param name="divisionId">The division whose standings the penalty affects.</param>
/// <param name="request">The deduction, including team, points, and reason.</param>
/// <returns>
/// 201 Created with the deduction; 404 Not Found when the division or
/// team does not exist; 403 Forbidden for non-staff callers.
/// </returns>
[HttpPost("divisions/{divisionId:guid}/point-deductions")]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(PointDeductionResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<PointDeductionResponse>> CreatePointDeduction(
Guid divisionId, CreatePointDeductionRequest request)
{
Division? division = await divisionService.GetSimpleDivisionByIdAsync(divisionId);
if (division is null)
{
return this.NotFoundProblem(nameof(Division), divisionId);
}
Team? team = await teamService.GetTeamByIdAsync(request.TeamId);
if (team is null)
{
return this.NotFoundProblem(nameof(Team), request.TeamId);
}
TeamPointDeduction deduction = mapper.Map<TeamPointDeduction>(request);
deduction.DivisionId = divisionId;
TeamPointDeduction created = await deductionService.CreateAsync(deduction);
PointDeductionResponse response = mapper.Map<PointDeductionResponse>(created);
return new ObjectResult(response) { StatusCode = StatusCodes.Status201Created };
}
/// <summary>
/// Lists every point deduction applied in a division, public so standings can show each penalty.
/// </summary>
/// <param name="divisionId">The division whose deductions to list.</param>
/// <returns>200 OK with the deductions, newest first.</returns>
[AllowAnonymous]
[HttpGet("divisions/{divisionId:guid}/point-deductions")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<PointDeductionResponse>))]
public async Task<ActionResult<List<PointDeductionResponse>>> GetPointDeductions(Guid divisionId)
{
List<TeamPointDeduction> deductions = await deductionService.GetByDivisionIdAsync(divisionId);
return Ok(mapper.Map<List<PointDeductionResponse>>(deductions));
}
/// <summary>
/// Removes a point deduction by its id.
/// </summary>
/// <param name="id">The id of the deduction to remove.</param>
/// <returns>204 No Content; 403 Forbidden for non-staff callers.</returns>
[HttpDelete("point-deductions/{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> DeletePointDeduction(Guid id)
{
await deductionService.DeleteAsync(id);
return NoContent();
}
}
@@ -0,0 +1,47 @@
using Application.DTOs.Roster.Request;
using Application.DTOs.Roster.Response;
using Application.Interfaces.Services;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Controller for team roster operations. Writes require Owner or Admin.
/// </summary>
/// <param name="rosterCopyService">The service that clones rosters across seasons.</param>
[Route("api/teams/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class RosterController(
IRosterCopyService rosterCopyService
) : ControllerBase
{
/// <summary>
/// Clones a roster from a source team's season into this team's target season, creating a registration per player and skipping ones already registered.
/// </summary>
/// <param name="id">The target team to copy the roster into.</param>
/// <param name="request">The source team and season and the target season.</param>
/// <returns>
/// <para>Returns 200 OK with how many registrations were created or skipped.</para>
/// <para>Returns 403 Forbidden if the user is not authorized.</para>
/// </returns>
[Authorize(Roles = Roles.AdminOrOwner)]
[HttpPost("{id:guid}/roster/copy")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RosterCopyResult))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<RosterCopyResult>> CopyRoster(Guid id, [FromBody] CopyRosterRequest request)
{
RosterCopyResult result = await rosterCopyService.CopyRosterAsync(
request.SourceTeamId, request.SourceTournamentId, id, request.TargetTournamentId);
return Ok(result);
}
}
@@ -0,0 +1,47 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.Match.Request;
using Application.DTOs.Scorer.Request;
using Application.DTOs.Scorer.Response;
using Application.Interfaces.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Read-only scorer ranking aggregations, always public: goals by team and by player.
/// </summary>
[ApiController]
[Route("api/[controller]/")]
[AllowAnonymous]
public class ScorerController(IScorerService scorerService) : ControllerBase
{
/// <summary>
/// Ranks teams by goals scored across the filtered matches, the Goleadores-by-team view.
/// </summary>
/// <param name="filter">The match filtering and pagination parameters.</param>
/// <returns>A paginated team scorer ranking.</returns>
[HttpGet("by-team")]
public async Task<ActionResult> GetFilteredScorerByTeam([FromQuery] GetMatchesFilteredRequest filter)
{
PaginatedResponse<ScorerByTeamResponse> response = await scorerService.GetAllScorersByTeamAsync(filter);
return Ok(response);
}
/// <summary>
/// Ranks individual players by goals scored, the Goleadores-by-player view.
/// </summary>
/// <param name="filter">The scorer filtering and pagination parameters.</param>
/// <returns>A paginated player scorer ranking.</returns>
[HttpGet("by-player")]
public async Task<ActionResult> GetFilteredScorerByPlayer([FromQuery] GetScorerFilteredRequest filter)
{
PaginatedResponse<ScorerByPlayerResponse> response = await scorerService.GetAllScorersByPlayerAsync(filter);
return Ok(response);
}
}
@@ -0,0 +1,147 @@
using API.Utils;
using Application.DTOs.Season.Request;
using Application.DTOs.Season.Response;
using Application.Interfaces.Services;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages Seasons, the top-level grouping of a period's tournaments. Reads are public; writes require Owner or Admin.
/// </summary>
/// <param name="seasonService">The Season service.</param>
/// <param name="mapper">The AutoMapper instance.</param>
[Route("api/seasons/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class SeasonController(ISeasonService seasonService, IMapper mapper) : ControllerBase
{
/// <summary>
/// Creates a new season asynchronously.
/// </summary>
/// <param name="seasonRequest">The season creation request.</param>
/// <returns>The created Season response.
/// <para>Returns 201 Created with the Season response if the creation was successful.</para>
/// <para>Returns 403 Forbidden if the user is not authorized.</para>
/// </returns>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(SeasonResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<SeasonResponse>> CreateSeason(CreateSeasonRequest seasonRequest)
{
Season season = mapper.Map<Season>(seasonRequest);
await seasonService.CreateSeasonAsync(season);
SeasonResponse seasonResponse = mapper.Map<SeasonResponse>(season);
return CreatedAtAction(nameof(GetSeasonById), new { idOrSlug = seasonResponse.Id }, seasonResponse);
}
/// <summary>
/// Retrieves a season by its id or its public slug asynchronously.
/// </summary>
/// <param name="idOrSlug">The season's GUID id or slug to retrieve.</param>
/// <returns>The Season with the specified id or slug.
/// <para>Returns 200 OK with the Season response if it was found.</para>
/// <para>Returns 404 Not Found if the Season with the provided id or slug was not found.</para>
/// </returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(SeasonResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SeasonResponse>> GetSeasonById(string idOrSlug)
{
Season? season = await seasonService.GetSeasonByIdOrSlugAsync(idOrSlug);
if (season is null)
{
return this.NotFoundProblem(nameof(Season), idOrSlug);
}
SeasonResponse seasonResponse = mapper.Map<SeasonResponse>(season);
return Ok(seasonResponse);
}
/// <summary>
/// Updates a season by its id asynchronously.
/// </summary>
/// <param name="id">The id of the season to update.</param>
/// <param name="seasonRequest">The season update request.</param>
/// <returns>
/// Returns 204 No Content if the update was successful.
/// Returns 404 Not Found if the Season with the provided id was not found.
/// Returns 403 Forbidden if the user is not authorized.
/// </returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UpdateSeason(Guid id, UpdateSeasonRequest seasonRequest)
{
Season? existingSeason = await seasonService.GetSeasonByIdAsync(id);
if (existingSeason is null)
{
return this.NotFoundProblem(nameof(Season), id);
}
mapper.Map(seasonRequest, existingSeason);
await seasonService.UpdateSeasonAsync(existingSeason);
return NoContent();
}
/// <summary>
/// Deletes a season by its id asynchronously.
/// </summary>
/// <param name="id">The id of the Season to delete.</param>
/// <returns>
/// Returns 204 No Content if the Season was successfully deleted.
/// Returns 404 Not Found if the Season with the provided id was not found.
/// Returns 403 Forbidden if the user is not authorized.
/// </returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> DeleteSeasonById(Guid id)
{
Season? season = await seasonService.GetSeasonByIdAsync(id);
if (season is null)
{
return this.NotFoundProblem(nameof(Season), id);
}
await seasonService.DeleteSeasonAsync(id);
return NoContent();
}
/// <summary>
/// Retrieves all seasons asynchronously.
/// </summary>
/// <returns>A list of all Season responses.</returns>
[AllowAnonymous]
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<SeasonResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<IEnumerable<SeasonResponse>>> GetAllSeasons()
{
IEnumerable<Season> seasons = await seasonService.GetAllSeasonsAsync();
IEnumerable<SeasonResponse> response = mapper.Map<IEnumerable<SeasonResponse>>(seasons);
return Ok(response);
}
}
@@ -0,0 +1,242 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.Match.Response;
using Application.DTOs.Stage.Request;
using Application.DTOs.Stage.Response;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages Stage entities: creation, retrieval, update, deletion, and team assignment. Reads are public; writes require Owner or Admin.
/// </summary>
/// <param name="stageService">Service for Stage business logic and persistence operations.</param>
/// <param name="mapper">AutoMapper instance for mapping between entities and DTOs.</param>
[Route("api/stages/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class StageController(IStageService stageService, IMapper mapper) : ControllerBase
{
/// <summary>
/// Creates a new Stage.
/// </summary>
/// <param name="stageRequest">The Stage creation request DTO.</param>
/// <returns>The created Stage response.</returns>
[HttpPost()]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(StageResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<StageResponse>> CreateStage(CreateStageRequest stageRequest)
{
Stage mappedStage = mapper.Map<Stage>(stageRequest);
Stage createdStage = await stageService.CreateStageAsync(mappedStage);
StageResponse stageResponse = mapper.Map<StageResponse>(createdStage);
return CreatedAtAction(nameof(GetStageById), new { idOrSlug = createdStage.Id }, stageResponse);
}
/// <summary>
/// Retrieves a Stage by its id or its public slug.
/// </summary>
/// <param name="idOrSlug">The Stage's GUID id or slug.</param>
/// <returns>The Stage entity.</returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Stage))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<StageResponse>> GetStageById(string idOrSlug)
{
Stage? stage = await stageService.GetStageByIdOrSlugAsync(idOrSlug);
if (stage == null)
{
return NotFound(ErrorMessages.Stage.NotFoundById(idOrSlug));
}
StageResponse stageResponse = mapper.Map<StageResponse>(stage);
return Ok(stageResponse);
}
/// <summary>
/// Retrieves filtered and paginated Stages.
/// </summary>
/// <param name="filterRequest">The filter and pagination parameters.</param>
/// <returns>Paginated list of Stages.</returns>
[AllowAnonymous]
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<StageResponse>))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<PaginatedResponse<Stage>>> GetFilteredStages([FromQuery] GetStagesFilteredRequest filterRequest)
{
PaginatedResponse<Stage> paginatedStages = await stageService.GetAllStagesAsync(filterRequest);
PaginatedResponse<StageResponse> paginatedResponse = mapper.Map<PaginatedResponse<StageResponse>>(paginatedStages);
return Ok(paginatedResponse);
}
/// <summary>
/// Updates an existing Stage.
/// </summary>
/// <param name="id">The id of the Stage to update.</param>
/// <param name="stageRequest">The updated Stage data.</param>
/// <returns>The updated Stage entity.</returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StageResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<StageResponse>> UpdateStage(Guid id, UpdateStageRequest stageRequest)
{
Stage? existingStage = await stageService.GetStageByIdAsync(id);
if (existingStage == null)
{
return NotFound(ErrorMessages.Stage.NotFoundById(id));
}
mapper.Map(stageRequest, existingStage);
await stageService.UpdateStageAsync(existingStage);
StageResponse stageResponse = mapper.Map<StageResponse>(existingStage);
return Ok(stageResponse);
}
/// <summary>
/// Deletes a Stage by its id.
/// </summary>
/// <param name="id">The id of the Stage to delete.</param>
/// <returns>Result of the delete operation.</returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DeleteStage(Guid id)
{
await stageService.DeleteStageAsync(id);
return NoContent();
}
/// <summary>
/// Assigns one or more teams to a specific stage.
/// </summary>
/// <param name="id">The unique identifier of the stage to which teams will be assigned.</param>
/// <param name="request">The assignment request containing team IDs and assignment mode.</param>
/// <returns>
/// Returns HTTP 200 OK if the assignment is successful, or 404 Not Found if the stage does not exist.
/// </returns>
[HttpPost("{id:guid}/assign-team")]
public async Task<ActionResult> AssignmentTeam(Guid id, AssignmentTeamRequest request)
{
Stage? stage = await stageService.GetStageByIdAsync(id);
if (stage == null)
{
return NotFound(ErrorMessages.Stage.NotFoundById(id));
}
await stageService.AssignTeamsToStageAsync(stage, request.TeamIds, request.Auto);
return Ok();
}
/// <summary>
/// Unassigns one or more teams from a specific stage.
/// </summary>
/// <param name="id">The unique identifier of the stage from which teams will be unassigned.</param>
/// <param name="request">The unassignment request containing team IDs.</param>
/// <returns>
/// Returns HTTP 200 OK if the unassignment is successful, or 404 Not Found if the stage does not exist.
/// </returns>
[HttpDelete("{id:guid}/unassign-team")]
public async Task<ActionResult> UnassignmentTeam(Guid id, UnassignmentTeamRequest request)
{
Stage? stage = await stageService.GetStageByIdAsync(id);
if (stage == null)
{
return NotFound(ErrorMessages.Stage.NotFoundById(id));
}
await stageService.UnassignTeamsFromStageAsync(stage, request.TeamIds);
return Ok();
}
/// <summary>
/// Seeds an elimination stage's matches from the group-stage standings using the classic 1v8, 4v5, 2v7, 3v6 bracket seed order.
/// </summary>
/// <param name="id">The elimination stage to seed.</param>
/// <returns>
/// Returns HTTP 200 OK with the now-seeded matches, or 404 Not Found
/// if the stage does not exist.
/// </returns>
[HttpPost("{id:guid}/seed")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<DetailedMatchResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> SeedKnockoutStage(Guid id)
{
Stage? stage = await stageService.GetStageByIdAsync(id);
if (stage == null)
{
return NotFound(ErrorMessages.Stage.NotFoundById(id));
}
List<Match> seededMatches = await stageService.SeedKnockoutStageAsync(id);
return Ok(mapper.Map<List<DetailedMatchResponse>>(seededMatches));
}
/// <summary>
/// Computes a first-round pairing for a groupless bracket without persisting it, returning a signed token that a later commit can replay exactly.
/// </summary>
[HttpPost("{id:guid}/preview-draw")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DrawPreviewResult))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DrawPreviewResult>> PreviewDraw(Guid id, DrawRequest request)
{
Stage? stage = await stageService.GetStageByIdAsync(id);
if (stage == null)
{
return NotFound(ErrorMessages.Stage.NotFoundById(id));
}
DrawPreviewResult preview = await stageService.PreviewDrawAsync(id, request.Mode, request.ManualOrder);
return Ok(preview);
}
/// <summary>
/// Seeds a groupless bracket from a previewed token or a manual order, stamping DrawnAt and auditing the draw.
/// </summary>
[HttpPost("{id:guid}/draw")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<DetailedMatchResponse>))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<List<DetailedMatchResponse>>> CommitDraw(Guid id, DrawRequest request)
{
Stage? stage = await stageService.GetStageByIdAsync(id);
if (stage == null)
{
return NotFound(ErrorMessages.Stage.NotFoundById(id));
}
List<Match> seededMatches = await stageService.CommitDrawAsync(id, request.Mode, request.DrawToken, request.ManualOrder);
return Ok(mapper.Map<List<DetailedMatchResponse>>(seededMatches));
}
}
@@ -0,0 +1,62 @@
using API.Utils;
using Application.DTOs.Statistics.Response;
using Application.Interfaces.Services;
using Domain.Entities.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Read-only historical player statistics; always public.
/// </summary>
[ApiController]
[Route("api/statistics/")]
[AllowAnonymous]
public class StatisticsController(IStatisticsService statisticsService) : ControllerBase
{
/// <summary>
/// A player's statistic card: total and average points and games played, per season and overall.
/// </summary>
/// <param name="playerId">The player's id.</param>
[HttpGet("players/{playerId:guid}/card")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerStatisticCardResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlayerStatisticCardResponse>> GetPlayerCard(Guid playerId)
{
PlayerStatisticCardResponse? card = await statisticsService.GetPlayerCardAsync(playerId);
if (card is null)
{
return this.NotFoundProblem(nameof(Player), playerId);
}
return Ok(card);
}
/// <summary>
/// A player's trajectory across seasons: team, stats, and sanctions for each season.
/// </summary>
/// <param name="playerId">The player's id.</param>
[HttpGet("players/{playerId:guid}/history")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerHistoryResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlayerHistoryResponse>> GetPlayerHistory(Guid playerId)
{
PlayerHistoryResponse? history = await statisticsService.GetPlayerHistoryAsync(playerId);
if (history is null)
{
return this.NotFoundProblem(nameof(Player), playerId);
}
return Ok(history);
}
}
@@ -0,0 +1,294 @@
using API.Utils;
using Application.DTOs.Abstract.Response;
using Application.DTOs.Team.Request;
using Application.DTOs.Team.Response;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Infrastructure.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages teams. Reads are public; writes require Owner or Admin.
/// </summary>
/// <param name="teamService">The team service for handling team-related operations.</param>
/// <param name="supabaseHelper">The Supabase helper for storage operations.</param>
/// <param name="mapper">The AutoMapper instance for mapping data models.</param>
[Route("api/teams/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class TeamController(
ITeamService teamService,
SupabaseHelper supabaseHelper,
IMapper mapper
) : ControllerBase
{
/// <summary>
/// Creates a new team.
/// </summary>
/// <param name="teamRequest">The team creation request object containing the team details.</param>
/// <returns>The created team response with details of the new team.</returns>
[HttpPost()]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TeamResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<TeamResponse>> CreateTeam([FromForm] CreateTeamRequest teamRequest)
{
if (!teamRequest.LogoFile.IsValidImageFile())
{
return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile);
}
string logoUrl = await supabaseHelper.UploadImageAsync<Team>(teamRequest.LogoFile.OpenReadStream(), teamRequest.LogoFile.FileName);
Team team = mapper.Map<Team>(teamRequest);
team.LogoUrl = logoUrl;
Team createdTeam = await teamService.CreateTeamAsync(team);
TeamResponse teamResponse = mapper.Map<TeamResponse>(createdTeam);
return CreatedAtAction(nameof(GetTeamById), new { idOrSlug = createdTeam.Id }, teamResponse);
}
/// <summary>
/// Updates a team by its id.
/// </summary>
/// <param name="id">The id of the team to update.</param>
/// <param name="teamRequest">The team request excluding logo update.</param>
/// <returns>
/// Returns 204 No Content if the update was successful.
/// Returns 404 Not Found if the team with the provided id was not found.
/// Returns 403 Forbidden if the user is not authorized.
/// </returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UpdateTeam(Guid id, UpdateTeamRequest teamRequest)
{
Team? existingTeam = await teamService.GetTeamByIdAsync(id);
if (existingTeam is null)
{
return this.NotFoundProblem(nameof(Team), id);
}
// A team's name and three-letter code are frozen while it participates in an Ongoing tournament, so the check must run before the request is mapped over the entity, while the original identity is still available to compare.
await teamService.EnsureTeamIdentityEditableAsync(existingTeam, teamRequest.Name, teamRequest.ThreeLetterCode);
mapper.Map(teamRequest, existingTeam);
await teamService.UpdateTeamAsync(existingTeam);
return NoContent();
}
/// <summary>
/// Updates the logo of a team.
/// </summary>
/// <param name="id">The id of the team to update the logo.</param>
/// <param name="logoRequest">The update team logo request.</param>
/// <returns>Returns 200 OK if the logo was successfully updated.</returns>
[HttpPut("{id:guid}/logo")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> UpdateTeamLogo(Guid id, [FromForm] UpdateTeamLogoRequest logoRequest)
{
if (!logoRequest.LogoFile.IsValidImageFile())
{
return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile);
}
Team? team = await teamService.GetTeamByIdAsync(id);
if (team is null)
{
return this.NotFoundProblem(nameof(Team), id);
}
team.LogoUrl = await supabaseHelper.UploadImageAsync<Team>(
logoRequest.LogoFile.OpenReadStream(),
logoRequest.LogoFile.FileName);
await teamService.UpdateTeamAsync(team);
return Ok();
}
/// <summary>
/// Retrieves a team by its id or slug; the embedded roster is scoped to one season.
/// </summary>
/// <param name="idOrSlug">The team's GUID id or slug to retrieve.</param>
/// <param name="tournamentId">
/// Optional: the season whose roster to embed. Defaults to the team's
/// own current tournament, meaning today's roster, when omitted — pass this
/// to look up the roster the team had during a past season instead.
/// </param>
/// <returns>The team with the specified id or slug.
/// <para>Returns 200 OK with the team response if it was found.</para>
/// <para>Returns 404 Not Found if the team with the provided id or slug was not found.</para>
/// </returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TeamResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TeamResponse>> GetTeamById(string idOrSlug, [FromQuery] Guid? tournamentId = null)
{
Team? team = await teamService.GetTeamByIdOrSlugAsync(idOrSlug, tournamentId);
if (team is null)
{
return this.NotFoundProblem(nameof(Team), idOrSlug);
}
TeamResponse teamResponse = mapper.Map<TeamResponse>(team);
return Ok(teamResponse);
}
/// <summary>
/// Retrieves the team's current group-stage standing row for a tournament, powering the profile summary card.
/// </summary>
/// <param name="idOrSlug">The team's GUID id or slug.</param>
/// <param name="tournamentId">
/// Optional: the tournament to summarize. Defaults to the team's own current
/// tournament when omitted.
/// </param>
/// <returns>
/// <para>Returns 200 OK with the standing row when the team is in a
/// group-stage table for the tournament.</para>
/// <para>Returns 200 OK with a null body when the team is in no group-stage
/// standing: playoff-only, unassigned, or with no finished matches yet.</para>
/// <para>Returns 404 Not Found when the team does not exist.</para>
/// </returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}/summary")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TeamSummaryResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TeamSummaryResponse>> GetTeamSummary(string idOrSlug, [FromQuery] Guid? tournamentId = null)
{
Team? team = await teamService.GetTeamByIdOrSlugAsync(idOrSlug, tournamentId);
if (team is null)
{
return this.NotFoundProblem(nameof(Team), idOrSlug);
}
TeamSummaryResponse? summary = await teamService.GetTeamSummaryAsync(team.Id, tournamentId ?? team.TournamentId);
return Ok(summary);
}
/// <summary>
/// Retrieves the team's home or visitor matches in a tournament, oriented from the team's perspective and ordered by date ascending.
/// </summary>
/// <param name="idOrSlug">The team's GUID id or slug.</param>
/// <param name="tournamentId">
/// Optional: the tournament to list matches for. Defaults to the team's own
/// current tournament when omitted.
/// </param>
/// <returns>
/// <para>Returns 200 OK with the team's matches, empty when there are none.</para>
/// <para>Returns 404 Not Found when the team does not exist.</para>
/// </returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}/matches")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<TeamMatchResponse>))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<TeamMatchResponse>>> GetTeamMatches(string idOrSlug, [FromQuery] Guid? tournamentId = null)
{
Team? team = await teamService.GetTeamByIdOrSlugAsync(idOrSlug, tournamentId);
if (team is null)
{
return this.NotFoundProblem(nameof(Team), idOrSlug);
}
List<TeamMatchResponse> matches = await teamService.GetTeamMatchesAsync(team.Id, tournamentId ?? team.TournamentId);
return Ok(matches);
}
/// <summary>
/// Retrieves every tournament the team has participated in, newest first, with season info.
/// </summary>
/// <param name="idOrSlug">The team's GUID id or slug.</param>
/// <returns>
/// <para>Returns 200 OK with the team's participations, empty when none.</para>
/// <para>Returns 404 Not Found when the team does not exist.</para>
/// </returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}/participations")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<TeamParticipationResponse>))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<TeamParticipationResponse>>> GetTeamParticipations(string idOrSlug)
{
Team? team = await teamService.GetTeamByIdOrSlugAsync(idOrSlug);
if (team is null)
{
return this.NotFoundProblem(nameof(Team), idOrSlug);
}
List<TeamParticipationResponse> participations = await teamService.GetTeamParticipationsAsync(team.Id, team.TournamentId);
return Ok(participations);
}
/// <summary>
/// Deletes a team by its id.
/// </summary>
/// <param name="id">The id of the team to delete.</param>
/// <returns>
/// Returns 204 No Content if the team was successfully deleted.
/// Returns 404 Not Found if the team with the provided id was not found.
/// Returns 403 Forbidden if the user is not authorized.
/// </returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> DeleteTeamById(Guid id)
{
Team? team = await teamService.GetTeamByIdAsync(id);
if (team is null)
{
return this.NotFoundProblem(nameof(Team), id);
}
if (Uri.TryCreate(team.LogoUrl, UriKind.Absolute, out Uri? logoUri)
&& (logoUri.Scheme == Uri.UriSchemeHttp || logoUri.Scheme == Uri.UriSchemeHttps))
{
await supabaseHelper.DeleteImageAsync<Team>(logoUri.Segments[^1]);
}
await teamService.DeleteTeamAsync(id);
return NoContent();
}
/// <summary>
/// Retrieves filtered teams with pagination.
/// </summary>
/// <param name="filterRequest">The filtering and pagination parameters.</param>
/// <returns>A paginated response containing the filtered teams.</returns>
[AllowAnonymous]
[HttpGet()]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<TeamResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PaginatedResponse<TeamResponse>>> GetFilteredTeams([FromQuery] GetTeamsFilteredRequest filterRequest)
{
PaginatedResponse<Team> paginatedTeams = await teamService.GetAllTeamsAsync(filterRequest);
PaginatedResponse<TeamResponse> response = mapper.Map<PaginatedResponse<TeamResponse>>(paginatedTeams);
return Ok(response);
}
}
@@ -0,0 +1,105 @@
using API.Utils;
using Application.DTOs.TeamStaff.Request;
using Application.DTOs.TeamStaff.Response;
using Application.Interfaces.Services;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages a team's technical staff, scoped per team and tournament; creating and deleting require Admin or Owner, listing is public.
/// </summary>
/// <param name="staffService">The team-staff service.</param>
/// <param name="teamService">The team service, used to validate the team.</param>
/// <param name="tournamentService">The tournament service, used to validate the tournament.</param>
/// <param name="mapper">The AutoMapper instance.</param>
[Route("api/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class TeamStaffController(
ITeamStaffService staffService,
ITeamService teamService,
ITournamentService tournamentService,
IMapper mapper) : ControllerBase
{
/// <summary>
/// Adds a member to a team's technical staff for a given tournament.
/// </summary>
/// <param name="teamId">The team the staff member belongs to.</param>
/// <param name="request">The staff member's full name, role, and tournament.</param>
/// <returns>
/// Returns 201 Created with the staff member; 404 Not Found when the team or
/// tournament does not exist; 403 Forbidden for non-staff callers.
/// </returns>
[HttpPost("teams/{teamId:guid}/staff")]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TeamStaffResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<TeamStaffResponse>> CreateTeamStaff(
Guid teamId, CreateTeamStaffRequest request)
{
Team? team = await teamService.GetTeamByIdAsync(teamId);
if (team is null)
{
return this.NotFoundProblem(nameof(Team), teamId);
}
Tournament? tournament = await tournamentService.GetTournamentByIdAsync(request.TournamentId);
if (tournament is null)
{
return this.NotFoundProblem(nameof(Tournament), request.TournamentId);
}
TeamStaff staff = mapper.Map<TeamStaff>(request);
staff.TeamId = teamId;
TeamStaff created = await staffService.CreateAsync(staff);
TeamStaffResponse response = mapper.Map<TeamStaffResponse>(created);
return new ObjectResult(response) { StatusCode = StatusCodes.Status201Created };
}
/// <summary>
/// Lists a team's technical staff for a given tournament; public so a team's profile can show its staff.
/// </summary>
/// <param name="teamId">The team whose staff to list.</param>
/// <param name="tournamentId">The tournament season to scope the staff to.</param>
/// <returns>Returns 200 OK with the staff in the order they were added.</returns>
[AllowAnonymous]
[HttpGet("teams/{teamId:guid}/staff")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<TeamStaffResponse>))]
public async Task<ActionResult<List<TeamStaffResponse>>> GetTeamStaff(
Guid teamId, [FromQuery] Guid tournamentId)
{
List<TeamStaff> staff =
await staffService.GetByTeamAndTournamentAsync(teamId, tournamentId);
return Ok(mapper.Map<List<TeamStaffResponse>>(staff));
}
/// <summary>
/// Removes a staff member by their id.
/// </summary>
/// <param name="id">The id of the staff member to remove.</param>
/// <returns>Returns 204 No Content; 403 Forbidden for non-staff callers.</returns>
[HttpDelete("staff/{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> DeleteTeamStaff(Guid id)
{
await staffService.DeleteAsync(id);
return NoContent();
}
}
@@ -0,0 +1,364 @@
using API.Utils;
using Application.DTOs.Abstract.Response;
using Application.DTOs.Champions.Response;
using Application.DTOs.Divisions.Response;
using Application.DTOs.Team.Response;
using Application.DTOs.Tournament.Request;
using Application.DTOs.Tournament.Response;
using Application.Interfaces.Services;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages tournaments: create, retrieve, update, delete, filter, and register teams. Reads are public; writes require Owner or Admin.
/// </summary>
[Route("api/tournaments/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class TournamentController(
ITournamentService tournamentService,
ITeamService teamService,
IChampionService championService,
IMapper mapper) : ControllerBase
{
/// <summary>
/// Creates a new tournament.
/// </summary>
/// <param name="tournamentRequest">Tournament creation data.</param>
/// <returns>
/// Returns 201 Created with the created tournament details.
/// Returns 400 Bad Request if the request is invalid.
/// Returns 403 Forbidden if the user is not authorized.
/// </returns>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TournamentResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<TournamentResponse>> CreateTournamentAsync(CreateTournamentRequest tournamentRequest)
{
Tournament mappedTournament = mapper.Map<Tournament>(tournamentRequest);
Tournament createdTournament = await tournamentService.CreateTournamentAsync(mappedTournament);
TournamentResponse tournamentResponse = mapper.Map<TournamentResponse>(createdTournament);
return new ObjectResult(tournamentResponse) { StatusCode = StatusCodes.Status201Created };
}
/// <summary>
/// Creates a whole tournament, including its divisions' cups, points, playoff mappings, and stages, in one atomic transaction.
/// </summary>
/// <param name="request">The full tournament-wizard payload.</param>
/// <returns>
/// Returns 201 Created with the created tournament, including its divisions.
/// Returns 400 Bad Request if the payload is invalid or a rule, for
/// example a division category mismatch, aborts the atomic create.
/// Returns 403 Forbidden if the user is not authorized.
/// </returns>
[HttpPost("full")]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TournamentResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<TournamentResponse>> CreateFullTournamentAsync(CreateFullTournamentRequest request)
{
Tournament createdTournament = await tournamentService.CreateFullTournamentAsync(request);
TournamentResponse tournamentResponse = mapper.Map<TournamentResponse>(createdTournament);
return new ObjectResult(tournamentResponse) { StatusCode = StatusCodes.Status201Created };
}
/// <summary>
/// Adds one division, with its group stage, cups, and playoff mappings, to an existing tournament in one atomic transaction, only while OpenForRegistration.
/// </summary>
/// <param name="tournamentId">The parent tournament's id.</param>
/// <param name="request">The division's structure, zone or cross-cup.</param>
/// <returns>
/// Returns 201 Created with the created division.
/// Returns 404 Not Found if the tournament does not exist.
/// Returns 409 Conflict if the tournament is not OpenForRegistration or
/// the division's category does not match the tournament's.
/// </returns>
[HttpPost("{tournamentId:guid}/divisions/full")]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(DivisionResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DivisionResponse>> AddFullDivision(
Guid tournamentId, CreateFullDivisionRequest request)
{
Tournament? tournament = await tournamentService.GetTournamentByIdAsync(tournamentId);
if (tournament is null)
{
return this.NotFoundProblem(nameof(Tournament), tournamentId);
}
Division createdDivision = await tournamentService.AddFullDivisionAsync(tournament, request);
DivisionResponse divisionResponse = mapper.Map<DivisionResponse>(createdDivision);
return new ObjectResult(divisionResponse) { StatusCode = StatusCodes.Status201Created };
}
/// <summary>
/// Retrieves a tournament by its unique identifier or its public slug.
/// </summary>
/// <param name="idOrSlug">Tournament's GUID identifier or slug.</param>
/// <returns>
/// Returns 200 OK with tournament details if found.
/// Returns 404 Not Found if not found.
/// </returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TournamentResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TournamentResponse>> GetTournamentById(string idOrSlug)
{
Tournament? tournament = await tournamentService.GetTournamentByIdOrSlugAsync(idOrSlug);
if (tournament is null)
{
return this.NotFoundProblem(nameof(Tournament), idOrSlug);
}
TournamentResponse tournamentResponse = mapper.Map<TournamentResponse>(tournament);
return Ok(tournamentResponse);
}
/// <summary>
/// Retrieves a source tournament's full cloneable structure tree — every
/// division's scoring/cup configuration, Stages, and PlayoffMappings — for
/// the tournament-cloning wizard-prefill flow. Carries no instance data
/// (teams, matches, rosters); additive, never replaces TournamentResponse
/// or DivisionResponse.
/// </summary>
/// <param name="idOrSlug">Tournament's GUID identifier or slug.</param>
/// <returns>
/// Returns 200 OK with the tournament's structure tree if found.
/// Returns 404 Not Found if not found.
/// </returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}/structure")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TournamentStructureResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TournamentStructureResponse>> GetTournamentStructure(string idOrSlug)
{
Tournament? tournament = await tournamentService.GetTournamentByIdOrSlugAsync(idOrSlug);
if (tournament is null)
{
return this.NotFoundProblem(nameof(Tournament), idOrSlug);
}
Tournament? structure = await tournamentService.GetTournamentStructureAsync(tournament.Id);
TournamentStructureResponse response = mapper.Map<TournamentStructureResponse>(structure);
return Ok(response);
}
/// <summary>
/// Retrieves each division's podium: champion, runner-up, and third place, decided by playoff or group standings, with undecided places left null.
/// </summary>
/// <param name="idOrSlug">Tournament's GUID identifier or slug.</param>
/// <returns>
/// Returns 200 OK with one podium per division.
/// Returns 404 Not Found if the tournament does not exist.
/// </returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}/champions")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<PodiumResponse>))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<PodiumResponse>>> GetTournamentChampions(string idOrSlug)
{
Tournament? tournament = await tournamentService.GetTournamentByIdOrSlugAsync(idOrSlug);
if (tournament is null)
{
return this.NotFoundProblem(nameof(Tournament), idOrSlug);
}
List<PodiumResponse> podiums = await championService.GetTournamentChampionsAsync(tournament.Id);
return Ok(podiums);
}
/// <summary>
/// Updates a tournament's descriptive fields; a Status change is routed through a forward-only state machine instead of being written directly.
/// </summary>
/// <param name="id">Tournament's GUID identifier.</param>
/// <param name="tournamentRequest">Tournament update data.</param>
/// <returns>
/// Returns 200 OK if updated successfully.
/// Returns 404 Not Found if the tournament does not exist.
/// Returns 403 Forbidden if unauthorized.
/// </returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TournamentResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UpdateTournamentAsync(Guid id, UpdateTournamentRequest tournamentRequest)
{
Tournament? existingTournament = await tournamentService.GetTournamentByIdAsync(id);
if (existingTournament is null)
{
return this.NotFoundProblem(nameof(Tournament), id);
}
// Status is intentionally excluded from this mapping since descriptive fields update freely here, but a status change is a guarded state-machine transition handled below.
mapper.Map(tournamentRequest, existingTournament);
await tournamentService.UpdateTournamentAsync(existingTournament);
// Routes any requested status change through the forward-only state machine; a no-op transition where the status is unchanged is ignored by the service, so re-sending the current status on a plain edit is harmless.
if (tournamentRequest.Status is TournamentStatus requestedStatus)
{
await tournamentService.ChangeStatusAsync(id, requestedStatus);
}
return NoContent();
}
/// <summary>
/// Deletes a tournament by its identifier.
/// </summary>
/// <param name="id">Tournament's GUID identifier.</param>
/// <returns>
/// Returns 200 OK if deleted successfully.
/// Returns 400 Bad Request if not found.
/// Returns 403 Forbidden if unauthorized.
/// </returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> DeleteTournamentById(Guid id)
{
await tournamentService.DeleteTournamentAsync(id);
return NoContent();
}
/// <summary>
/// Retrieves tournaments filtered and paginated according to provided parameters.
/// </summary>
/// <param name="filterRequest">Filtering and pagination parameters.</param>
/// <returns>
/// Returns 200 OK with paginated tournament results.
/// Returns 400 Bad Request if parameters are invalid.
/// </returns>
[AllowAnonymous]
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<TournamentResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PaginatedResponse<TournamentResponse>>> GetFilteredTournaments([FromQuery] GetTournamentsFilteredRequest filterRequest)
{
PaginatedResponse<Tournament> paginatedTournaments = await tournamentService.GetAllTournamentsAsync(filterRequest);
PaginatedResponse<TournamentResponse> response = mapper.Map<PaginatedResponse<TournamentResponse>>(paginatedTournaments);
return Ok(response);
}
/// <summary>
/// Enrolls a team into the tournament's registration phase, either as a new team or an existing club with an optional roster copy, in one transaction.
/// </summary>
/// <param name="tournamentId">Tournament's GUID identifier.</param>
/// <param name="request">The enroll payload.</param>
/// <returns>
/// Returns 201 Created with the enrolled team, its roster scoped to this tournament.
/// Returns 400 Bad Request when the payload shape is invalid: not exactly one of
/// ExistingTeamId or NewTeamName, or CopyRosterFromTournamentId without ExistingTeamId.
/// Returns 404 Not Found when the tournament or an existing team does not exist.
/// Returns 409 Conflict when the tournament is not OpenForRegistration or the team
/// is already enrolled.
/// </returns>
[HttpPost("{tournamentId:guid}/enroll-team")]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TeamResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<TeamResponse>> EnrollTeam(Guid tournamentId, EnrollTeamRequest request)
{
bool hasExistingTeam = request.ExistingTeamId.HasValue;
bool hasNewTeamName = !string.IsNullOrWhiteSpace(request.NewTeamName);
// Exactly one of ExistingTeamId / NewTeamName must be provided.
if (hasExistingTeam == hasNewTeamName)
{
return BadRequest("Provide exactly one of ExistingTeamId or NewTeamName.");
}
// CopyRosterFromTournamentId is only valid together with ExistingTeamId.
if (request.CopyRosterFromTournamentId.HasValue && !hasExistingTeam)
{
return BadRequest("CopyRosterFromTournamentId is only allowed together with ExistingTeamId.");
}
Tournament? tournament = await tournamentService.GetTournamentByIdAsync(tournamentId);
if (tournament is null)
{
return this.NotFoundProblem(nameof(Tournament), tournamentId);
}
Team enrolledTeam = await teamService.EnrollTeamAsync(
tournament,
request.ExistingTeamId,
request.NewTeamName,
request.CopyRosterFromTournamentId);
TeamResponse teamResponse = mapper.Map<TeamResponse>(enrolledTeam);
return new ObjectResult(teamResponse) { StatusCode = StatusCodes.Status201Created };
}
/// <summary>
/// Reports whether the tournament can be completed once started and the blocking issues when it cannot.
/// </summary>
/// <param name="tournamentId">Tournament's GUID identifier.</param>
/// <returns>
/// Returns 200 OK with the completability report, CanStart plus Issues.
/// Returns 404 Not Found when the tournament does not exist.
/// Returns 403 Forbidden if unauthorized.
/// </returns>
[HttpGet("{tournamentId:guid}/completability")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TournamentCompletabilityResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<TournamentCompletabilityResponse>> GetCompletability(Guid tournamentId)
{
TournamentCompletabilityResponse response = await tournamentService.GetCompletabilityAsync(tournamentId);
return Ok(response);
}
/// <summary>
/// Removes a team from the tournament during its registration or pre-start window, clearing only this tournament's footprint for the team.
/// </summary>
/// <param name="tournamentId">Tournament's GUID identifier.</param>
/// <param name="teamId">Team's GUID identifier.</param>
/// <returns>
/// Returns 204 No Content when the team is removed.
/// Returns 404 Not Found when the tournament does not exist or the team is not enrolled.
/// Returns 409 Conflict when the tournament has already started and is not in a removable phase.
/// Returns 403 Forbidden if unauthorized.
/// </returns>
[HttpDelete("{tournamentId:guid}/teams/{teamId:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> UnenrollTeam(Guid tournamentId, Guid teamId)
{
Tournament? tournament = await tournamentService.GetTournamentByIdAsync(tournamentId);
if (tournament is null)
{
return this.NotFoundProblem(nameof(Tournament), tournamentId);
}
await teamService.UnenrollTeamAsync(tournament, teamId);
return NoContent();
}
}
@@ -0,0 +1,139 @@
using API.Utils.Helpers;
using Application.DTOs.Abstract.Response;
using Application.DTOs.User.Request;
using Application.DTOs.User.Response;
using Application.Interfaces.Services;
using Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// User management endpoints for CRUD and logout; access rules are enforced in IUserManagementService.
/// </summary>
[ApiController]
[Route("api/users")]
[Authorize]
public class UserController(
IUserManagementService userManagementService,
IAuditService auditService) : ControllerBase
{
/// <summary>
/// Lists users paginated and filtered; Admins see every user, Owners only their subordinates, other roles get 403.
/// </summary>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<UserResponse>))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<PaginatedResponse<UserResponse>>> GetAll(
[FromQuery] UserFilteredRequest filter, CancellationToken ct)
{
(string? role, Guid id) = User.GetCallerClaims();
return Ok(await userManagementService.GetAllAsync(role, id, filter, ct));
}
/// <summary>
/// Retrieves a single user by id; Admins may view anyone, Owners themselves or their subordinates, other roles only themselves.
/// </summary>
[HttpGet("{userId:guid}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserResponse>> GetById(Guid userId, CancellationToken ct)
{
(string? role, Guid id) = User.GetCallerClaims();
return Ok(await userManagementService.GetByIdAsync(role, id, userId, ct));
}
/// <summary>
/// Updates a user's profile fields and, if Role is set, reassigns the target's role; only Admins and Owners may reassign, and nobody may change their own.
/// </summary>
[HttpPut("{userId:guid}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserResponse>> Update(
Guid userId, [FromBody] UpdateUserRequest request, CancellationToken ct)
{
(string? role, Guid id) = User.GetCallerClaims();
return Ok(await userManagementService.UpdateAsync(role, id, userId, request, ct));
}
/// <summary>
/// Changes a user's password; CurrentPassword is required for a self-service change, optional when an Admin or Owner changes another's.
/// </summary>
[HttpPut("{userId:guid}/password")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ChangePassword(
Guid userId, [FromBody] ChangePasswordRequest request, CancellationToken ct)
{
(string? role, Guid id) = User.GetCallerClaims();
await userManagementService.ChangePasswordAsync(role, id, userId, request, ct);
return NoContent();
}
/// <summary>
/// Forces a password reset, generating a new temporary password server-side and recording an audit log entry.
/// </summary>
[HttpPost("{userId:guid}/password/reset")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ResetPasswordResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResetPasswordResponse>> ResetPassword(
Guid userId, CancellationToken ct)
{
(string? role, Guid id) = User.GetCallerClaims();
ResetPasswordResponse response = await userManagementService.ResetPasswordAsync(role, id, userId, ct);
// The target's email is looked up separately, since ResetPasswordResponse only carries the id and the audit trail needs to show who, not just an opaque guid.
UserResponse resetUser = await userManagementService.GetByIdAsync(role, id, userId, ct);
await auditService.LogAsync(
AuditAction.PasswordReset,
targetType: "User",
targetId: userId.ToString(),
targetName: resetUser.Email,
ct: ct);
return Ok(response);
}
/// <summary>
/// Deletes a user. Admins may delete any user; Owners only their own subordinates.
/// </summary>
[HttpDelete("{userId:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid userId, CancellationToken ct)
{
(string? role, Guid id) = User.GetCallerClaims();
await userManagementService.DeleteAsync(role, id, userId, ct);
return NoContent();
}
/// <summary>
/// Activates or deactivates a user account via Identity lockout; a deactivated account cannot log in.
/// </summary>
[HttpPut("{userId:guid}/active")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserResponse>> SetActive(
Guid userId, [FromBody] SetUserActiveRequest request, CancellationToken ct)
{
(string? role, Guid id) = User.GetCallerClaims();
return Ok(await userManagementService.SetActiveAsync(role, id, userId, request.IsActive, ct));
}
}
@@ -0,0 +1,201 @@
using API.Utils;
using Application.DTOs.Venue.Request;
using Application.DTOs.Venue.Response;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using AutoMapper;
using Domain.Entities.Models;
using Domain.Enums;
using Infrastructure.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Controllers;
/// <summary>
/// Manages Venues. Reads are public; writes require Owner or Admin.
/// </summary>
/// <param name="venueService">The Venue service.</param>
/// <param name="supabaseHelper">The Supabase helper for storage operations.</param>
/// <param name="mapper">The AutoMapper instance.</param>
[Route("api/venues/")]
[ApiController]
[Authorize(Roles = Roles.AdminOrOwner)]
public class VenueController(IVenueService venueService, SupabaseHelper supabaseHelper, IMapper mapper) : ControllerBase
{
/// <summary>
/// Creates a new venue asynchronously.
/// </summary>
/// <param name="venueRequest">The venue creation request.</param>
/// <returns>The created Venue response.
/// <para>Returns 201 Created with the Venue response if the creation was successful.</para>
/// <para>Returns 403 Forbidden if the user is not authorized.</para>
/// </returns>
[HttpPost()]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(VenueResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<VenueResponse>> CreateVenue([FromForm] CreateVenueRequest venueRequest)
{
Venue venue = mapper.Map<Venue>(venueRequest);
if (venueRequest.ImageFile is not null)
{
venue.PhotoUrl = await supabaseHelper.UploadImageAsync<Venue>(
venueRequest.ImageFile.OpenReadStream(),
venueRequest.ImageFile.FileName);
}
await venueService.CreateVenueAsync(venue);
VenueResponse venueResponse = mapper.Map<VenueResponse>(venue);
return CreatedAtAction(nameof(GetVenueById), new { idOrSlug = venueResponse.Id }, venueResponse);
}
/// <summary>
/// Retrieves a venue by its id or its public slug asynchronously.
/// </summary>
/// <param name="idOrSlug">The venue's GUID id or slug to retrieve.</param>
/// <returns>The Venue with the specified id or slug.
/// <para>Returns 200 OK with the Venue response if it was found.</para>
/// <para>Returns 404 Not Found if the Venue with the provided id or slug was not found.</para>
/// </returns>
[AllowAnonymous]
[HttpGet("{idOrSlug}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(VenueResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<VenueResponse>> GetVenueById(string idOrSlug)
{
Venue? venue = await venueService.GetVenueByIdOrSlugAsync(idOrSlug);
if (venue is null)
{
return this.NotFoundProblem(nameof(Venue), idOrSlug);
}
VenueResponse venueResponse = mapper.Map<VenueResponse>(venue);
return Ok(venueResponse);
}
/// <summary>
/// Updates a venue by its id asynchronously.
/// </summary>
/// <param name="id">The id of the venue to update.</param>
/// <param name="venueRequest">The venue update request.</param>
/// <returns>
/// Returns 200 OK if the update was successful.
/// Returns 404 Not Found if the Venue with the provided id was not found.
/// Returns 403 Forbidden if the user is not authorized.
/// </returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UpdateVenue(Guid id, UpdateVenueRequest venueRequest)
{
Venue? existingVenue = await venueService.GetVenueByIdAsync(id);
if (existingVenue is null)
{
return this.NotFoundProblem(nameof(Venue), id);
}
mapper.Map(venueRequest, existingVenue);
await venueService.UpdateVenueAsync(existingVenue);
return NoContent();
}
/// <summary>
/// Updates the photo of a venue.
/// </summary>
/// <param name="id">The id of the venue to update the photo.</param>
/// <param name="photoRequest">The update venue photo request.</param>
/// <returns>Returns 200 OK if the photo was successfully updated.</returns>
[HttpPut("{id:guid}/photo")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdateVenuePhoto(Guid id, [FromForm] UpdateVenuePhotoRequest photoRequest)
{
if (!photoRequest.ImageFile.IsValidImageFile())
{
return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile);
}
Venue? venue = await venueService.GetVenueByIdAsync(id);
if (venue is null)
{
return this.NotFoundProblem(nameof(Venue), id);
}
venue.PhotoUrl = await supabaseHelper.UploadImageAsync<Venue>(
photoRequest.ImageFile.OpenReadStream(),
photoRequest.ImageFile.FileName);
await venueService.UpdateVenueAsync(venue);
return Ok();
}
/// <summary>
/// Deletes a venue by its id asynchronously.
/// </summary>
/// <param name="id">The id of the Venue to delete.</param>
/// <returns>
/// Returns 204 No Content if the Venue was successfully deleted.
/// Returns 404 Not Found if the Venue with the provided id was not found.
/// Returns 409 Conflict if the Venue is still referenced by one or more matches.
/// Returns 403 Forbidden if the user is not authorized.
/// </returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeleteVenueById(Guid id)
{
Venue? venue = await venueService.GetVenueByIdAsync(id);
if (venue is null)
{
return this.NotFoundProblem(nameof(Venue), id);
}
// Runs the integrity guard first, which throws 409 when the venue is still referenced by matches, so the stored photo is only removed once the venue row is actually gone.
await venueService.DeleteVenueAsync(id);
if (Uri.TryCreate(venue.PhotoUrl, UriKind.Absolute, out Uri? photoUri)
&& (photoUri.Scheme == Uri.UriSchemeHttp || photoUri.Scheme == Uri.UriSchemeHttps))
{
await supabaseHelper.DeleteImageAsync<Venue>(photoUri.Segments[^1]);
}
return NoContent();
}
/// <summary>
/// Retrieves all venues asynchronously.
/// </summary>
/// <returns>A list of all Venue responses.</returns>
[AllowAnonymous]
[HttpGet()]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<VenueResponse>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<IEnumerable<VenueResponse>>> GetAllVenues()
{
IEnumerable<Venue> venues = await venueService.GetAllVenuesAsync();
IEnumerable<VenueResponse> response = mapper.Map<IEnumerable<VenueResponse>>(venues);
return Ok(response);
}
}
+102
View File
@@ -0,0 +1,102 @@
using API.BackgroundServices;
using API.Utils;
using API.Utils.Middlewares;
using Application.Utils.Options;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using Serilog.Events;
using System;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Host.AddSerilogConfig(builder.Configuration);
builder.Services
.AddAutoMapper(cfg => { }, typeof(Program).Assembly)
.AddDbContextConfig(builder.Configuration)
.AddCorsConfig(builder.Configuration)
.RegisterScoped()
.RegisterSingletons()
.AddCustomAuthorization()
.AddCustomAuthentication(builder.Configuration)
.AddCustomSwagger(builder.Configuration)
.AddEmailConfig(builder.Configuration)
.AddIdentityConfig(builder.Configuration)
.AddBackupConfig(builder.Configuration)
.AddHealthChecksConfig()
.AddExceptionHandler<GlobalExceptionHandler>()
.AddProblemDetails();
// Configurable roster limits; an absent section falls back to defaults.
builder.Services.Configure<RosterOptions>(
builder.Configuration.GetSection(RosterOptions.SectionName));
builder.Services.AddControllers().AddCustomJsonOptions();
if (builder.Configuration.GetValue<bool>(ConfigurationKeys.Backup.Enabled))
{
builder.Services.AddHostedService(sp => sp.GetRequiredService<DatabaseBackupHostedService>());
}
WebApplication app = builder.Build();
await app.ExecuteMigrationsAndSeedAsync();
app.UseSwaggerConfig(builder.Environment)
.UseSerilogRequestLogging(options => options.GetLevel = GetRequestLoggingLevel)
.UseCors()
.UseMiddleware<MaintenanceModeMiddleware>()
.UseAuthentication()
.UseAuthorization()
.UseMiddleware<MustChangePasswordMiddleware>()
.UseExceptionHandlerConfig()
.UseLoggingToRequestContextMiddleware(builder.Configuration);
app.MapControllers();
app.MapHealthCheckEndpoints();
app.LogStartupBanner();
try
{
await app.RunAsync();
}
catch (Exception ex)
{
Log.Fatal(ex, LogMessages.TerminatedUnexpectedly);
}
finally
{
await Log.CloseAndFlushAsync();
}
// The docker-compose healthcheck polls /health/ready every 30s, so logging every successful poll at Information would drown out real request logs; failures still surface at Error regardless of path, so a flapping health check remains visible.
static LogEventLevel GetRequestLoggingLevel(HttpContext httpContext, double elapsedMs, Exception? ex)
{
if (ex is not null || httpContext.Response.StatusCode > 499)
{
return LogEventLevel.Error;
}
if (httpContext.Request.Path.StartsWithSegments("/health"))
{
return LogEventLevel.Verbose;
}
return LogEventLevel.Information;
}
/// <summary>
/// Visibility-only shim making Program a public partial type since WebApplicationFactory needs it for integration tests.
/// </summary>
public partial class Program
{
protected Program() { }
}
@@ -0,0 +1,67 @@
{
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:5194",
"dotnetRunMessages": true
},
"https": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:7122;http://localhost:5194",
"dotnetRunMessages": true
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Facundo": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Facundo"
},
"applicationUrl": "https://localhost:5001/"
},
"Franco": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Franco"
},
"applicationUrl": "https://localhost:5001/"
},
"Tomas": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Tomas"
},
"applicationUrl": "https://localhost:5001/"
}
},
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:59691/",
"sslPort": 44349
}
}
}
@@ -0,0 +1,71 @@
namespace API.Utils;
/// <summary>
/// Centralized configuration key paths read at startup, so a typo in a key name is caught by the compiler instead of silently returning null or a default value at runtime.
/// </summary>
public static class ConfigurationKeys
{
public const string DbConnection = "DbConnection";
public const string AllowedOrigins = "AllowedOrigins";
public const string UseLoggingMiddleware = "UseLoggingMiddleware";
public static class Jwt
{
public const string Key = "JWT:Key";
public const string Issuer = "JWT:Issuer";
public const string Audience = "JWT:Audience";
}
public static class Swagger
{
public const string Title = "Swagger:Title";
public const string Version = "Swagger:Version";
}
public static class Smtp
{
public const string Host = "Smtp:Host";
public const string Port = "Smtp:Port";
public const string Username = "Smtp:Username";
public const string Password = "Smtp:Password";
public const string UseSsl = "Smtp:UseSsl";
public const string FromEmail = "Smtp:FromEmail";
public const string FromName = "Smtp:FromName";
}
public static class Backup
{
public const string Section = "Backup";
public const string Enabled = "Backup:Enabled";
}
public static class Seed
{
public const string Enabled = "Seed:Enabled";
/// <summary>
/// When true, the startup DataSeeder deletes existing sample domain data before seeding, forcing a clean, FK-safe reseed; defaults to false.
/// </summary>
public const string Reset = "Seed:Reset";
/// <summary>
/// Filesystem folder the startup DataSeeder reads team crest PNGs from to upload as real team logos, falling back to placeholders when absent.
/// </summary>
public const string LogosPath = "Seed:LogosPath";
/// <summary>
/// Filesystem path to the medical PDF the startup DataSeeder uploads for every should-be-habilitado seeded registration, skipping the backfill step without failing the seed when absent.
/// </summary>
public const string MedicalRecordPath = "Seed:MedicalRecordPath";
/// <summary>
/// How many consecutive seasons of history the startup DataSeeder builds, counting backwards from the most recent one and clamped to a safe range by the seeder.
/// </summary>
public const string Seasons = "Seed:Seasons";
/// <summary>
/// Roster size for every seeded team, defaulting to SampleTournamentBuilder.DefaultPlayersPerTeam and clamped to a safe range by the seeder.
/// </summary>
public const string PlayersPerTeam = "Seed:PlayersPerTeam";
}
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace API.Utils;
/// <summary>
/// Shared ControllerBase extensions for consistent HTTP responses across controllers.
/// </summary>
public static class ControllerBaseExtensions
{
/// <summary>
/// Builds a 404 response whose ProblemDetails body matches the shape emitted by GlobalExceptionHandler for unhandled exceptions.
/// </summary>
/// <param name="controller">The controller issuing the response.</param>
/// <param name="entity">The entity type that could not be found.</param>
/// <param name="id">The identifier that was looked up.</param>
/// <returns>An ObjectResult with a 404 status and ProblemDetails body.</returns>
public static ObjectResult NotFoundProblem(this ControllerBase controller, string entity, object id)
{
return controller.Problem(
detail: $"{entity} with id {id} not found.",
statusCode: StatusCodes.Status404NotFound,
title: "Not Found: The specified resource could not be found.");
}
/// <summary>
/// Builds a 400 response whose ProblemDetails body matches the shape emitted by GlobalExceptionHandler for unhandled exceptions, so the frontend's error handler (which reads the detail field) surfaces this message instead of a generic fallback.
/// </summary>
/// <param name="controller">The controller issuing the response.</param>
/// <param name="detail">The specific, user-facing reason the request was rejected.</param>
/// <returns>An ObjectResult with a 400 status and ProblemDetails body.</returns>
public static ObjectResult BadRequestProblem(this ControllerBase controller, string detail)
{
return controller.Problem(
detail: detail,
statusCode: StatusCodes.Status400BadRequest,
title: "Bad Request: The request could not be completed.");
}
}
@@ -0,0 +1,69 @@
using Application.Utils.Constants;
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace API.Utils.Converters;
/// <summary>
/// A custom JSON converter for DateTime that accepts many common input formats when reading but always writes a single canonical format.
/// </summary>
public class DateOnlyJsonConverter : JsonConverter<DateTime>
{
/// <summary>
/// The formats accepted when parsing a date from JSON, tried in order; only used for reading, since Write always emits a single canonical format.
/// </summary>
private static readonly string[] _formats =
[
"dd/MM/yyyy",
"MM/dd/yyyy",
"yyyy-MM-dd",
"yyyy/MM/dd",
"yyyyMMdd",
"dd-MM-yyyy",
"MM-dd-yyyy",
"dd.MM.yyyy",
"yyyy-MM-ddTHH:mm:ss",
"yyyy-MM-ddTHH:mm:ss.fff",
"yyyy-MM-ddTHH:mm:ssZ",
"yyyy-MM-ddTHH:mm:ss.fffZ",
"o",
"s",
"yyyy-MM-ddTHH:mm"
];
/// <summary>
/// Reads and converts the JSON string to a DateTime, trying each of the accepted formats in order until one matches.
/// </summary>
/// <param name="reader">The Utf8JsonReader to read from.</param>
/// <param name="typeToConvert">The type to convert, expected to be DateTime.</param>
/// <param name="options">The JsonSerializerOptions used during deserialization.</param>
/// <returns>A DateTime parsed from the JSON string.</returns>
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string? dateString = reader.GetString();
return DateTime.TryParseExact(
dateString,
_formats,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out DateTime parsedDate)
? DateTime.SpecifyKind(parsedDate, DateTimeKind.Unspecified)
: throw new JsonException(ErrorMessages.Serialization.InvalidDate(dateString));
}
/// <summary>
/// Writes a DateTime value as a JSON string using the canonical yyyy-MM-ddTHH:mm:ss.fffZ format, regardless of which accepted format it was originally read from.
/// </summary>
/// <param name="writer">The Utf8JsonWriter to write to.</param>
/// <param name="value">The DateTime value to serialize.</param>
/// <param name="options">The JsonSerializerOptions used during serialization.</param>
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture));
}
}
+39
View File
@@ -0,0 +1,39 @@
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Reflection;
namespace API.Utils;
/// <summary>
/// Displays the display name of an enum value in the Swagger UI.
/// </summary>
public class DisplayEnumSchemaFilter : ISchemaFilter
{
/// <summary>
/// Replaces an enum's raw member names in the generated schema with each value's DisplayAttribute.Name, falling back to the member name when absent, so Swagger shows the same friendly text as the UI.
/// </summary>
/// <param name="schema">The OpenAPI schema being generated for context's type.</param>
/// <param name="context">The schema generation context; used to check Type for enum-ness.</param>
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
if (context.Type.IsEnum)
{
Type enumType = context.Type;
schema.Enum = [.. Enum.GetNames(enumType)
.Select(name =>
{
MemberInfo[] memberInfo = enumType.GetMember(name);
DisplayAttribute? displayAttribute = memberInfo[0].GetCustomAttribute<DisplayAttribute>();
string displayName = displayAttribute?.Name ?? name;
return new OpenApiString(displayName) as IOpenApiAny;
})];
}
}
}
@@ -0,0 +1,17 @@
namespace API.Utils;
/// <summary>
/// Maps file-extension enums to the actual dotted extension string, so ImageFileExtension.Jpg maps to ".jpg".
/// </summary>
public static class FileExtensionMappings
{
public static string ToExtensionString(this ImageFileExtension extension)
{
return $".{extension.ToString().ToLowerInvariant()}";
}
public static string ToExtensionString(this SpreadsheetFileExtension extension)
{
return $".{extension.ToString().ToLowerInvariant()}";
}
}
@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Http;
using System;
using System.IO;
namespace API.Utils;
/// <summary>
/// Provides extension methods for file validation.
/// </summary>
public static class FileExtensions
{
private static readonly ImageFileExtension[] _validImageExtensions =
[
ImageFileExtension.Jpg,
ImageFileExtension.Jpeg,
ImageFileExtension.Png,
ImageFileExtension.Webp,
];
private static readonly SpreadsheetFileExtension[] _validSpreadsheetExtensions =
[
SpreadsheetFileExtension.Xls,
SpreadsheetFileExtension.Xlsx,
];
/// <summary>
/// Checks if the provided file is a valid JPEG, PNG, or WEBP image.
/// </summary>
/// <param name="file">The uploaded file to check.</param>
/// <returns>True if the file is a valid image, otherwise false.</returns>
public static bool IsValidImageFile(this IFormFile file)
{
if (file is null || file.Length is 0)
{
return false;
}
string fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
return Array.Exists(_validImageExtensions, extension => extension.ToExtensionString() == fileExtension);
}
private const string PdfExtension = ".pdf";
private const string PdfContentType = "application/pdf";
/// <summary>
/// Checks whether the uploaded file is a valid PDF by extension and, when present, content type. Used to gate medical-record uploads.
/// </summary>
/// <param name="file">The uploaded file to check.</param>
/// <returns>True if the file is a non-empty PDF, otherwise false.</returns>
public static bool IsValidPdfFile(this IFormFile file)
{
if (file is null || file.Length is 0)
{
return false;
}
string fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
bool extensionOk = fileExtension == PdfExtension;
bool contentTypeOk = string.IsNullOrEmpty(file.ContentType)
|| string.Equals(file.ContentType, PdfContentType, StringComparison.OrdinalIgnoreCase);
return extensionOk && contentTypeOk;
}
/// <summary>
/// Checks if the provided file is a valid XLSX or XLS Excel file.
/// </summary>
/// <param name="file">The uploaded file to check.</param>
/// <returns>True if the file is a valid Excel file, otherwise false.</returns>
public static bool IsValidExcelFile(this IFormFile file)
{
if (file is null || file.Length is 0)
{
return false;
}
string fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
return Array.Exists(_validSpreadsheetExtensions, extension => extension.ToExtensionString() == fileExtension);
}
}
@@ -0,0 +1,89 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace API.Utils;
/// <summary>
/// Handles global exceptions and returns standardized ProblemDetails responses.
/// </summary>
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_logger.LogInformation("GlobalHandlerException initialized.");
}
/// <summary>
/// Tries to handle exceptions globally and returns standardized ProblemDetails.
/// </summary>
/// <param name="httpContext">The current HTTP context.</param>
/// <param name="exception">The exception to handle.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous operation of handling the exception.</returns>
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
IExceptionHandlerFeature? exceptionHandlerFeature = httpContext.Features.Get<IExceptionHandlerFeature>();
if (exceptionHandlerFeature is not null)
{
string traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;
Exception exceptionDetails = exceptionHandlerFeature.Error;
_logger.LogError(exceptionDetails,
"An unhandled exception occurred on the {MachineName}. TraceId: {TraceId}.",
Environment.MachineName,
traceId);
(int statusCode, string title, bool exposeMessage) = MapException(exceptionDetails);
IResult result = Results.Problem(
title: title,
detail: exposeMessage
? exceptionDetails.Message
: "Ocurrió un error inesperado. Contactá a soporte con el ID de rastreo a continuación.",
statusCode: statusCode,
extensions: new Dictionary<string, object?> { ["traceId"] = traceId }
);
await result.ExecuteAsync(httpContext);
return true;
}
return false;
}
/// <summary>
/// Maps known exceptions to HTTP status codes and response titles, and decides whether the exception's own message is safe to expose to the client.
/// </summary>
/// <param name="exception">The exception to map.</param>
/// <returns>
/// A tuple containing the HTTP status code, a corresponding title for the response,
/// and whether the exception's message is safe to expose to the client.
/// </returns>
private static (int StatusCode, string Title, bool ExposeMessage) MapException(Exception exception)
{
return exception switch
{
ArgumentNullException => (StatusCodes.Status400BadRequest, "Bad Request: Required argument is missing.", true),
UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "Forbidden: You do not have permission to access this resource.", true),
KeyNotFoundException => (StatusCodes.Status404NotFound, "Not Found: The specified resource could not be found.", true),
InvalidOperationException => (StatusCodes.Status409Conflict, "Conflict: The operation is invalid in the current state.", true),
TimeoutException => (StatusCodes.Status408RequestTimeout, "Request Timeout: The operation took too long to complete.", false),
FormatException => (StatusCodes.Status400BadRequest, "Bad Request: Invalid format encountered.", true),
NotImplementedException => (StatusCodes.Status501NotImplemented, "Not Implemented: The requested functionality is not available.", true),
StackOverflowException => (StatusCodes.Status500InternalServerError, "Internal Server Error: Stack overflow occurred.", false),
OperationCanceledException => (StatusCodes.Status499ClientClosedRequest, "Client Closed Request: Operation was cancelled by the client.", true),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.", false)
};
}
}
@@ -0,0 +1,17 @@
using System;
using System.Security.Claims;
namespace API.Utils.Helpers;
public static class AuthHelper
{
/// <summary>
/// Extracts the caller's role and id from the Role and NameIdentifier claims, letting Guid.Parse throw a FormatException on an unauthenticated or malformed principal rather than returning a default value.
/// </summary>
public static (string role, Guid id) GetCallerClaims(this ClaimsPrincipal principal)
{
string role = principal.FindFirstValue(ClaimTypes.Role) ?? string.Empty;
string sub = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty;
return (role, Guid.Parse(sub));
}
}
@@ -0,0 +1,28 @@
using Application.Interfaces.Services;
using Domain.Constants;
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
namespace API.Utils.Helpers;
/// <summary>
/// Resolves the current caller's identity from the HTTP context for the audit trail, falling back to AuditConstants.SystemUser when there is no authenticated request.
/// </summary>
public sealed class HttpCurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUserAccessor
{
public string Actor
{
get
{
ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
string? identifier = user?.FindFirstValue(ClaimTypes.Email)
?? user?.FindFirstValue(ClaimTypes.NameIdentifier);
return string.IsNullOrWhiteSpace(identifier) ? AuditConstants.SystemUser : identifier;
}
}
}
@@ -0,0 +1,12 @@
namespace API.Utils;
/// <summary>
/// The image formats accepted for photo/logo uploads.
/// </summary>
public enum ImageFileExtension
{
Jpg,
Jpeg,
Png,
Webp,
}
+22
View File
@@ -0,0 +1,22 @@
namespace API.Utils;
/// <summary>
/// Centralized log message text used at application startup and shutdown.
/// </summary>
public static class LogMessages
{
public const string StartingUp = "----- Starting up -----";
public const string Started = "----- Started -----";
public const string TerminatedUnexpectedly = "Application terminated unexpectedly";
public const string Banner = @"
#### ## ## ## ##### ## ####
## ## ## ## ## ## ## ### ## ##
## ## ## ## ##### ## ##
## ## ## ## ## ## ## ##
## ## ## ## ## ## ## ## ##
#### ###### ###### ##### ###### ######
";
}
@@ -0,0 +1,37 @@
using Application.Interfaces.Backup;
using Microsoft.AspNetCore.Http;
using System;
using System.Threading.Tasks;
namespace API.Utils.Middlewares;
/// <summary>
/// Returns 503 for every request outside the allow-listed paths while maintenance mode is active.
/// </summary>
public class MaintenanceModeMiddleware(RequestDelegate next, IMaintenanceModeState maintenanceModeState)
{
private static readonly string[] AllowedPaths =
[
"/health",
"/api/maintenance",
];
public async Task InvokeAsync(HttpContext context)
{
if (maintenanceModeState.IsActive && !Array.Exists(AllowedPaths, p => context.Request.Path.StartsWithSegments(p)))
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
context.Response.Headers.RetryAfter = "30";
await context.Response.WriteAsJsonAsync(new
{
error = "The application is temporarily in maintenance mode.",
reason = maintenanceModeState.Reason,
});
return;
}
await next(context);
}
}
@@ -0,0 +1,39 @@
using Application.Utils.Constants.Auth;
using Microsoft.AspNetCore.Http;
using System;
using System.Security.Claims;
using System.Threading.Tasks;
namespace API.Utils.Middlewares;
/// <summary>
/// Rejects every request except the user-update and logout endpoints with 403 once CustomClaimTypes.MustChangePassword is set on the caller's token, until the password is changed.
/// </summary>
public class MustChangePasswordMiddleware(RequestDelegate next)
{
private static readonly string[] AllowedPaths =
[
"/api/users/",
"/api/auth/logout",
];
public async Task InvokeAsync(HttpContext context)
{
bool mustChange = context.User.FindFirstValue(CustomClaimTypes.MustChangePassword) == "true";
if (mustChange && !Array.Exists(AllowedPaths, p => context.Request.Path.StartsWithSegments(p)))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new
{
error = "Password change required.",
action = "PUT /api/users/{your-id}/password"
});
return;
}
await next(context);
}
}
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Threading.Tasks;
namespace API.Utils.Middlewares;
/// <summary>
/// Logs one compact, structured line per HTTP request: method, path, status code, elapsed time, and correlation id.
/// </summary>
public class RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
private const string LogTemplate =
"HTTP {Method} {PathAndQuery} responded {StatusCode} in {ElapsedMs} ms [{CorrelationId}]";
private const string HttpMethodOptions = "OPTIONS";
/// <summary>
/// Times the request, invokes the pipeline, and logs the outcome at a level matched to the response status code.
/// </summary>
/// <param name="context">The current HTTP context.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public async Task InvokeAsync(HttpContext context)
{
Stopwatch stopwatch = Stopwatch.StartNew();
await next(context);
stopwatch.Stop();
bool isPreflight = context.Request.Method == HttpMethodOptions;
int statusCode = context.Response.StatusCode;
LogLevel level = statusCode switch
{
_ when isPreflight => LogLevel.Debug,
>= 500 => LogLevel.Error,
>= 400 => LogLevel.Warning,
_ => LogLevel.Information,
};
logger.Log(
level,
LogTemplate,
context.Request.Method,
context.Request.Path + context.Request.QueryString,
statusCode,
stopwatch.ElapsedMilliseconds,
context.TraceIdentifier);
}
}
@@ -0,0 +1,10 @@
namespace API.Utils;
/// <summary>
/// The spreadsheet formats accepted for bulk-import uploads.
/// </summary>
public enum SpreadsheetFileExtension
{
Xls,
Xlsx,
}
@@ -0,0 +1,533 @@
using API.BackgroundServices;
using API.Utils.Converters;
using API.Utils.Middlewares;
using Application.Backup;
using Application.Interfaces.Backup;
using Application.Interfaces.Mappers;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Interfaces.Storage;
using Application.Services;
using Application.Utils.Constants;
using Application.Utils.Helper.SupabaseHelper;
using Application.Utils.Mappers;
using Domain.Enums;
using FluentEmail.MailKitSmtp;
using Infrastructure.Backup;
using Infrastructure.Email;
using Infrastructure.Identity;
using Infrastructure.Persistance;
using Infrastructure.Repositories;
using Infrastructure.Storage;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
using Serilog;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace API.Utils;
/// <summary>
/// Provides extension methods for configuring application startup, including logging, database context, CORS, authentication, authorization, Swagger, JSON options, and dependency injection.
/// </summary>
public static class StartupExtensions
{
/// <summary>
/// Configures Serilog logging for the application host.
/// </summary>
public static void AddSerilogConfig(this IHostBuilder hostBuilder, IConfiguration configuration)
{
hostBuilder.UseSerilog((context, config) => config.ReadFrom.Configuration(configuration));
}
/// <summary>
/// Adds and configures the application's database context and dependency injection for the database.
/// </summary>
public static IServiceCollection AddDbContextConfig(this IServiceCollection services, IConfiguration configuration)
{
string? connectionString = configuration.GetConnectionString(ConfigurationKeys.DbConnection);
if (connectionString is null)
{
Log.Fatal("Connection string is missing. Using default or fallback connection string.");
throw new ArgumentException(ErrorMessages.Configuration.ConnectionStringMissing);
}
services.AddDbContext<ApplicationDBContext>(options => options.UseNpgsql(connectionString, ConfigureNpgsql));
services.AddScoped<IClub12DBContext, ApplicationDBContext>();
services.AddScoped<MedicalRecordSeedBackfiller>();
services.AddScoped<DataSeeder>();
services.AddScoped<IDataMaintenanceService, DataMaintenanceService>();
return services;
}
/// <summary>
/// Shared Npgsql options for every DbContext so both stay in sync, with a bounded command timeout that turns a pathological query into a fast failure instead of a hung request.
/// </summary>
private static void ConfigureNpgsql(NpgsqlDbContextOptionsBuilder npgsql)
{
npgsql.CommandTimeout(30);
}
/// <summary>
/// Adds and configures CORS policies for the application.
/// </summary>
public static IServiceCollection AddCorsConfig(this IServiceCollection services, IConfiguration configuration)
{
services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins(configuration.GetSection(ConfigurationKeys.AllowedOrigins).Get<string[]>()!)
.AllowAnyHeader()
.AllowAnyMethod();
});
});
return services;
}
/// <summary>
/// Registers the self health check tagged live, which always reports Healthy, and the db health check tagged ready, which probes ApplicationDBContext connectivity so a misconfigured or unreachable connection string surfaces as not-ready instead of silently healthy.
/// </summary>
public static IServiceCollection AddHealthChecksConfig(this IServiceCollection services)
{
services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
.AddDbContextCheck<ApplicationDBContext>("db", tags: ["ready"]);
return services;
}
/// <summary>
/// Maps /health as the liveness endpoint using only the live-tagged self check, and /health/ready as the readiness endpoint using only the ready-tagged database check; both are anonymous since no fallback authorization policy exists.
/// </summary>
public static WebApplication MapHealthCheckEndpoints(this WebApplication app)
{
app.MapHealthChecks("/health", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("live"),
}).AllowAnonymous();
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("ready"),
}).AllowAnonymous();
return app;
}
/// <summary>
/// Runs EF migrations for both ApplicationDBContext and IdentityAppDbContext, then seeds the initial admin user.
/// </summary>
public static async Task ExecuteMigrationsAndSeedAsync(this WebApplication app)
{
await using AsyncServiceScope scope = app.Services.CreateAsyncScope();
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
await db.Database.MigrateAsync();
// The migration only adds the schema, so the data backfill runs here as an idempotent step to give every team a stable cross-season club identity regardless of which migration or seed path created it; a re-run is a cheap no-op once every team is already linked.
IClubService clubService = scope.ServiceProvider.GetRequiredService<IClubService>();
await clubService.BackfillClubsAsync();
IdentityAppDbContext identityDb = scope.ServiceProvider.GetRequiredService<IdentityAppDbContext>();
await identityDb.Database.MigrateAsync();
IdentitySeeder seeder = scope.ServiceProvider.GetRequiredService<IdentitySeeder>();
await seeder.SeedAsync();
IConfiguration configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
if (configuration.GetValue<bool>(ConfigurationKeys.Seed.Enabled))
{
bool reset = configuration.GetValue<bool>(ConfigurationKeys.Seed.Reset);
string? logosPath = configuration[ConfigurationKeys.Seed.LogosPath];
string? medicalRecordPath = configuration[ConfigurationKeys.Seed.MedicalRecordPath];
int seasons = configuration.GetValue(ConfigurationKeys.Seed.Seasons, 1);
int playersPerTeam = configuration.GetValue(
ConfigurationKeys.Seed.PlayersPerTeam, SampleTournamentBuilder.DefaultPlayersPerTeam);
DataSeeder dataSeeder = scope.ServiceProvider.GetRequiredService<DataSeeder>();
await dataSeeder.SeedAsync(reset, logosPath, medicalRecordPath, seasons, playersPerTeam);
}
}
/// <summary>
/// Logs the startup banner once the app is fully configured and about to start listening for requests.
/// </summary>
public static void LogStartupBanner(this WebApplication app)
{
Log.Information(LogMessages.StartingUp);
Log.Information(LogMessages.Banner);
Log.Information(LogMessages.Started);
}
/// <summary>
/// Configures Swagger middleware for API documentation in non-production environments.
/// </summary>
public static IApplicationBuilder UseSwaggerConfig(this IApplicationBuilder app, IHostEnvironment env)
{
if (!env.IsProduction())
{
app.UseSwagger();
app.UseSwaggerUI();
}
return app;
}
/// <summary>
/// Configures exception handling and status code pages middleware.
/// </summary>
public static IApplicationBuilder UseExceptionHandlerConfig(this IApplicationBuilder app)
{
app.UseStatusCodePages();
app.UseExceptionHandler();
return app;
}
/// <summary>
/// One policy per domain role — single source of truth via UserRoleType enum.
/// </summary>
private static readonly string[] _roleNames =
[
UserRoleType.ADMIN.ToRoleName(),
UserRoleType.OWNER.ToRoleName(),
UserRoleType.GUEST.ToRoleName(),
];
/// <summary>
/// Adds custom authorization policies based on UserRoleType domain roles.
/// </summary>
public static IServiceCollection AddCustomAuthorization(this IServiceCollection services)
{
services.AddAuthorization(options =>
{
foreach (string role in _roleNames)
{
options.AddPolicy(role, policy => policy.RequireRole(role));
}
});
return services;
}
/// <summary>
/// Adds and configures JWT authentication for the application.
/// </summary>
public static IServiceCollection AddCustomAuthentication(this IServiceCollection services, IConfiguration configuration)
{
string? jwtSecret = configuration.GetSection(ConfigurationKeys.Jwt.Key)?.Value;
if (string.IsNullOrEmpty(jwtSecret))
{
throw new ArgumentException(ErrorMessages.Configuration.JwtMissing);
}
services.AddAuthentication(options =>
{
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = configuration[ConfigurationKeys.Jwt.Issuer],
ValidAudience = configuration[ConfigurationKeys.Jwt.Audience],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret))
};
});
return services;
}
/// <summary>
/// Adds middleware for logging requests to the request context if enabled in configuration.
/// </summary>
public static IApplicationBuilder UseLoggingToRequestContextMiddleware(this IApplicationBuilder app, IConfiguration configuration)
{
bool useLoggingMiddleware = configuration.GetValue(ConfigurationKeys.UseLoggingMiddleware, false);
if (useLoggingMiddleware)
{
app.UseMiddleware<RequestLoggingMiddleware>();
Log.Information("Request logging middleware enabled.");
}
else
{
Log.Information("Request logging middleware disabled.");
}
return app;
}
/// <summary>
/// Adds custom JSON serialization options for MVC, including enum and date converters.
/// </summary>
public static void AddCustomJsonOptions(this IMvcBuilder builder)
{
builder.AddJsonOptions(options =>
{
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.JsonSerializerOptions.Converters.Add(new DateOnlyJsonConverter());
});
}
/// <summary>
/// Adds and configures Swagger for API documentation, including security definitions and schema filters.
/// </summary>
public static IServiceCollection AddCustomSwagger(this IServiceCollection services, IConfiguration configuration)
{
const string bearerScheme = "Bearer";
const string swaggerDocVersion = "v1";
services.AddSwaggerGen(context =>
{
context.SwaggerDoc(swaggerDocVersion, new OpenApiInfo
{
Title = configuration[ConfigurationKeys.Swagger.Title],
Version = configuration[ConfigurationKeys.Swagger.Version],
});
// Only API's own xml file was ever wired in here, so DTO/enum summaries defined outside the API project never reached Swagger's schema view even though the compiled xml exists for each project.
string[] xmlDocAssemblyNames = ["API", "Application", "Domain", "Infrastructure"];
foreach (string assemblyName in xmlDocAssemblyNames)
{
string xmlPath = Path.Combine(AppContext.BaseDirectory, $"{assemblyName}.xml");
if (File.Exists(xmlPath))
{
context.IncludeXmlComments(xmlPath);
}
}
context.ResolveConflictingActions(apiDescriptions => apiDescriptions.First());
context.AddSecurityDefinition(bearerScheme, new OpenApiSecurityScheme
{
Description = "JWT Authorization header using the Bearer scheme. Enter 'Bearer' [space] and then your token in the text input below.",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = bearerScheme
});
context.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = bearerScheme },
Scheme = "oauth2",
Name = bearerScheme,
In = ParameterLocation.Header
},
new List<string>()
}
});
context.SchemaFilter<DisplayEnumSchemaFilter>();
});
return services;
}
/// <summary>
/// Registers singleton services for dependency injection.
/// </summary>
public static IServiceCollection RegisterSingletons(this IServiceCollection services)
{
services.AddSingleton<SupabaseHelper>();
// Medical-record file storage reuses the shared Supabase client via ISupabaseRawStorage and confines files to their own medical-records area, separate from backups, registered here unconditionally because medical-record uploads always target Supabase.
services.AddSingleton<ISupabaseRawStorage>(sp => sp.GetRequiredService<SupabaseHelper>());
services.AddSingleton<IMedicalRecordStorage, SupabaseMedicalRecordStorage>();
return services;
}
/// <summary>
/// Registers Identity DbContext, ASP.NET Core Identity services, IAuthenticationService, IUserManagementService, and IdentitySeeder.
/// </summary>
public static IServiceCollection AddIdentityConfig(
this IServiceCollection services, IConfiguration configuration)
{
string? connectionString = configuration.GetConnectionString(ConfigurationKeys.DbConnection);
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentException(ErrorMessages.Configuration.ConnectionStringMissing);
}
services.AddDbContext<IdentityAppDbContext>(options => options.UseNpgsql(connectionString, ConfigureNpgsql));
services.AddIdentityCore<ApplicationUser>(options =>
{
options.User.RequireUniqueEmail = true;
})
.AddRoles<IdentityRole<Guid>>()
.AddEntityFrameworkStores<IdentityAppDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
services.AddScoped<IAuthenticationService, IdentityAuthenticationService>();
services.AddScoped<IUserManagementService, IdentityUserManagementService>();
services.AddScoped<IdentitySeeder>();
return services;
}
/// <summary>
/// Registers scoped services and repositories for dependency injection using reflection.
/// </summary>
public static IServiceCollection RegisterScoped(this IServiceCollection services)
{
string? serviceInterfaceNamespace = typeof(IDivisionService).Namespace;
string? serviceImplNamespace = typeof(DivisionService).Namespace;
string? repositoryInterfaceNamespace = typeof(IBlogPostRepository).Namespace;
string? repositoryImplNamespace = typeof(BlogPostRepository).Namespace;
string? mapperInterfaceNamespace = typeof(IScorerMapper).Namespace;
string? mapperImplNamespace = typeof(ScorerMapper).Namespace;
string serviceSuffix = "Service";
string repositorySuffix = "Repository";
string mapperSuffix = "Mapper";
ArgumentNullException.ThrowIfNull(serviceInterfaceNamespace);
ArgumentNullException.ThrowIfNull(serviceImplNamespace);
ArgumentNullException.ThrowIfNull(repositoryInterfaceNamespace);
ArgumentNullException.ThrowIfNull(repositoryImplNamespace);
ArgumentNullException.ThrowIfNull(mapperInterfaceNamespace);
ArgumentNullException.ThrowIfNull(mapperImplNamespace);
Assembly serviceAssembly = typeof(AuthService).Assembly;
Assembly IServiceAssembly = typeof(IAuthService).Assembly;
Assembly repoAssembly = typeof(BlogPostRepository).Assembly;
Assembly IRepoAssembly = typeof(IBlogPostRepository).Assembly;
Assembly mapperAssembly = typeof(ScorerMapper).Assembly;
Assembly IMapperAssembly = typeof(IScorerMapper).Assembly;
services.HelperRegisterScoped(serviceAssembly, IServiceAssembly, serviceSuffix, serviceInterfaceNamespace, serviceImplNamespace);
services.HelperRegisterScoped(repoAssembly, IRepoAssembly, repositorySuffix, repositoryInterfaceNamespace, repositoryImplNamespace);
services.HelperRegisterScoped(mapperAssembly, IMapperAssembly, mapperSuffix, mapperInterfaceNamespace, mapperImplNamespace);
services.AddScoped<IUnitOfWork, UnitOfWork>();
// Resolves the current caller's identity from the HTTP context so application and infrastructure services can record who without depending on the web layer.
services.AddHttpContextAccessor();
services.AddScoped<ICurrentUserAccessor, API.Utils.Helpers.HttpCurrentUserAccessor>();
return services;
}
private static void HelperRegisterScoped(this IServiceCollection services, Assembly implementationAssembly, Assembly interfaceAssembly, string suffix, string iNamespace, string implNamespace)
{
string interfacePrefix = "I";
IEnumerable<Type> interfaces = interfaceAssembly.GetTypes()
.Where(t => t.IsInterface && t.Namespace == iNamespace
&& t.Name.StartsWith(interfacePrefix) && t.Name.EndsWith(suffix));
foreach (Type iface in interfaces)
{
Type? implementation = Array.Find(implementationAssembly.GetTypes(),
t => t.IsClass && !t.IsAbstract
&& t.Namespace == implNamespace
&& t.Name == iface.Name[1..]);
if (implementation is not null)
{
services.AddScoped(iface, implementation);
}
}
}
/// <summary>
/// Registers FluentEmail with Mailgun and binds IEmailService to FluentEmailHelper.
/// </summary>
public static IServiceCollection AddEmailConfig(
this IServiceCollection services, IConfiguration configuration)
{
string smtpHost = configuration[ConfigurationKeys.Smtp.Host]
?? throw new ArgumentException(ErrorMessages.Configuration.SmtpKeyMissing(ConfigurationKeys.Smtp.Host));
int smtpPort = int.Parse(configuration[ConfigurationKeys.Smtp.Port]
?? throw new ArgumentException(ErrorMessages.Configuration.SmtpKeyMissing(ConfigurationKeys.Smtp.Port)));
string username = configuration[ConfigurationKeys.Smtp.Username]
?? throw new ArgumentException(ErrorMessages.Configuration.SmtpKeyMissing(ConfigurationKeys.Smtp.Username));
string password = configuration[ConfigurationKeys.Smtp.Password]
?? throw new ArgumentException(ErrorMessages.Configuration.SmtpKeyMissing(ConfigurationKeys.Smtp.Password));
bool useSsl = configuration.GetValue(ConfigurationKeys.Smtp.UseSsl, true);
string fromEmail = configuration[ConfigurationKeys.Smtp.FromEmail] ?? "noreply@club12.com";
string fromName = configuration[ConfigurationKeys.Smtp.FromName] ?? "Club12";
services
.AddFluentEmail(fromEmail, fromName)
.AddMailKitSender(new SmtpClientOptions
{
Server = smtpHost,
Port = smtpPort,
UseSsl = useSsl,
User = username,
Password = password,
RequiresAuthentication = true
});
services.AddScoped<IEmailService, FluentEmailHelper>();
return services;
}
/// <summary>
/// Registers the database backup feature by binding the Backup config section into a BackupOptions singleton and registering its ports and adapters.
/// </summary>
public static IServiceCollection AddBackupConfig(this IServiceCollection services, IConfiguration configuration)
{
BackupOptions options = configuration.GetSection(ConfigurationKeys.Backup.Section).Get<BackupOptions>() ?? new BackupOptions();
services.AddSingleton(options);
services.AddSingleton<IBackupRetentionPolicy, KeepLastNRetentionPolicy>();
services.AddSingleton<IProcessRunner, ProcessRunner>();
services.AddSingleton<IDatabaseBackupService, PgDumpBackupService>();
services.AddSingleton<IDatabaseRestoreService, PsqlDatabaseRestoreService>();
services.AddSingleton<IMaintenanceModeState, MaintenanceModeState>();
services.AddSingleton<BackupOperationLock>();
if (string.Equals(options.StorageTarget, BackupStorageTargets.Supabase, StringComparison.OrdinalIgnoreCase))
{
services.AddSingleton<ISupabaseRawStorage>(sp => sp.GetRequiredService<SupabaseHelper>());
services.AddSingleton<IBackupStorage, SupabaseBackupStorage>();
}
else
{
services.AddSingleton<IBackupStorage>(sp => new LocalDirectoryBackupStorage(
options.LocalStoragePath, sp.GetRequiredService<ILogger<LocalDirectoryBackupStorage>>()));
}
services.AddScoped<IBackupCatalog, EfBackupCatalog>();
services.AddScoped<IBackupOperationsService, BackupOperationsService>();
services.AddSingleton<DatabaseBackupHostedService>();
return services;
}
}
+51
View File
@@ -0,0 +1,51 @@
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Enrichers.Environment", "Serilog.Enrichers.Process", "Serilog.Enrichers.Thread" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning",
"Microsoft.AspNetCore": "Warning"
}
},
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithThreadId",
"WithProcessId"
],
"Filter": [
{
"Name": "ByExcluding",
"Args": {
"expression": "@mt = 'An unhandled exception has occurred while executing the request.'"
}
}
],
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}",
"theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console"
}
}
]
},
"Backup": {
"Enabled": false,
"IntervalHours": 24,
"RetentionCount": 7,
"StorageTarget": "Local",
"LocalStoragePath": "backups",
"PgDumpPath": "pg_dump"
},
"Seed": {
"Enabled": true,
"Reset": true,
"Seasons": 4,
"PlayersPerTeam": 12
}
}