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
@@ -0,0 +1,63 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.AuditLogs.Request;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Extensions;
using Domain.Entities.Models;
using Domain.Enums;
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
namespace Application.Services;
/// <inheritdoc cref="IAuditService"/>
public class AuditService(IAuditLogRepository auditLogRepository, ICurrentUserAccessor currentUserAccessor)
: IAuditService
{
public async Task LogAsync(
AuditAction action,
string? targetType = null,
string? targetId = null,
string? targetName = null,
string? detail = null,
CancellationToken ct = default)
{
string actor = currentUserAccessor.Actor;
await auditLogRepository.AddAsync(new AuditLog
{
Id = Guid.Empty,
Action = action,
Actor = actor,
TargetType = targetType,
TargetId = targetId,
TargetName = targetName,
Detail = detail,
DateCreated = DateTime.UtcNow,
// EntityBase.CreatedBy is required; mirror the actor so the base audit columns and the semantic Actor field agree.
CreatedBy = actor,
});
}
public async Task<PaginatedResponse<AuditLog>> GetAuditLogsAsync(AuditLogFilteredRequest filter)
{
Expression<Func<AuditLog, bool>> expression =
QueryableExtensions.ConstructFilterExpression<AuditLog, AuditLogFilteredRequest>(filter);
IEnumerable<AuditLog> entries = await auditLogRepository.FindAsync(expression, filter: filter);
int totalCount = await auditLogRepository.CountAsync(expression);
return new PaginatedResponse<AuditLog>
{
Page = filter.PageNumber,
PageSize = filter.PageSize,
TotalCount = totalCount,
Items = entries,
};
}
}
@@ -0,0 +1,61 @@
using Application.DTOs.Auth.Response;
using Application.Interfaces.Services;
using Application.Utils.Constants.Auth;
using Application.Utils.Constants.Configuration;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Generates JWT access tokens and refresh tokens.
/// </summary>
public class AuthService(IConfiguration configuration) : IAuthService
{
private readonly string _jwtSecret = configuration.GetSection(ConfigurationKeys.Jwt.Key).Value
?? throw new ArgumentNullException(nameof(configuration), "The JWT Key should be initialized");
private readonly string _issuer = configuration.GetSection(ConfigurationKeys.Jwt.Issuer).Value
?? throw new ArgumentNullException(nameof(configuration), "The JWT Issuer should be initialized");
private readonly string _audience = configuration.GetSection(ConfigurationKeys.Jwt.Audience).Value
?? throw new ArgumentNullException(nameof(configuration), "The JWT Audience should be initialized");
public Task<TokenResponse> GenerateJwtTokenAsync(IEnumerable<Claim> claims, CancellationToken ct = default)
{
JwtSecurityTokenHandler tokenHandler = new();
byte[] key = Encoding.UTF8.GetBytes(_jwtSecret);
SecurityTokenDescriptor tokenDescriptor = new()
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddHours(TokenLifetime.AccessTokenExpiryHours),
Issuer = _issuer,
Audience = _audience,
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
};
SecurityToken token = tokenHandler.CreateToken(tokenDescriptor);
string accessToken = tokenHandler.WriteToken(token);
string refreshToken = GenerateRefreshToken();
return Task.FromResult(new TokenResponse(
accessToken, TimeSpan.FromHours(TokenLifetime.AccessTokenExpiryHours), refreshToken));
}
private static string GenerateRefreshToken()
{
byte[] randomBytes = new byte[TokenLifetime.RefreshTokenByteLength];
using RandomNumberGenerator rng = RandomNumberGenerator.Create();
rng.GetBytes(randomBytes);
return Convert.ToBase64String(randomBytes);
}
}
@@ -0,0 +1,94 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.BlogPosts.Request;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Extensions;
using Application.Utils.Helper.Slug;
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Manages blog posts and keeps draft visibility opt-in so a public endpoint can never leak a draft.
/// </summary>
public class BlogPostService(IBlogPostRepository blogpostRepository) : IBlogPostService
{
public async Task<BlogPost> CreateBlogPostAsync(BlogPost blogPostEntity)
{
blogPostEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync(
blogPostEntity.Title,
candidate => blogpostRepository.ExistsAsync(post => post.Slug == candidate));
return await blogpostRepository.AddAsync(blogPostEntity);
}
public async Task DeleteBlogPostAsync(Guid id)
{
await blogpostRepository.RemoveAsync(bp => bp.Id == id);
}
public async Task UpdateBlogPostAsync(BlogPost blogPostEntity)
{
await blogpostRepository.UpdateAsync(blogPostEntity);
}
public async Task<BlogPost?> GetBlogPostByIdAsync(Guid blogPostId)
{
return await blogpostRepository.GetByIdAsync(blogPostId);
}
/// <summary>
/// Retrieves a blog post by id or slug, auto-detecting which one was passed via GUID parsing.
/// </summary>
/// <param name="idOrSlug">The blog post's GUID id or its slug.</param>
/// <param name="includeUnpublished">
/// Defaults to false, which treats a draft as not found so it never leaks through public
/// endpoints; true also resolves drafts.
/// </param>
/// <returns>The blog post entity if found; otherwise, null.</returns>
public async Task<BlogPost?> GetBlogPostByIdOrSlugAsync(string idOrSlug, bool includeUnpublished = false)
{
BlogPost? post = Guid.TryParse(idOrSlug, out Guid blogPostId)
? await GetBlogPostByIdAsync(blogPostId)
: (await blogpostRepository.FindAsync(candidate => candidate.Slug == idOrSlug)).FirstOrDefault();
// Public callers must not resolve drafts: a draft is treated as not found so it never leaks through the public detail endpoint.
if (post is not null && !includeUnpublished && !post.IsPublished)
{
return null;
}
return post;
}
public async Task<PaginatedResponse<BlogPost>> GetAllBlogPostsAsync(GetBlogPostsFilteredRequest filter, bool includeUnpublished = false)
{
Expression<Func<BlogPost, bool>> expression = QueryableExtensions.ConstructFilterExpression<BlogPost, GetBlogPostsFilteredRequest>(filter);
// Public listing only ever exposes published posts; Admin/Owner callers pass includeUnpublished: true to also see drafts.
if (!includeUnpublished)
{
Expression<Func<BlogPost, bool>> publishedOnly = post => post.IsPublished;
expression = expression.AndAlso(publishedOnly);
}
IEnumerable<BlogPost> filteredBlogPosts = await blogpostRepository.FindAsync(expression, filter: filter);
int totalCount = await blogpostRepository.CountAsync(expression);
return new PaginatedResponse<BlogPost>
{
Page = filter.PageNumber,
PageSize = filter.PageSize,
TotalCount = totalCount,
Items = filteredBlogPosts
};
}
}
@@ -0,0 +1,247 @@
using Application.DTOs.Champions.Response;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Helper.Champions;
using Domain.Entities.Models;
using Domain.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Computes the champion and podium of each competition and the champions history over finished tournaments.
/// </summary>
public class ChampionService(
IDivisionRepository divisionRepository,
IMatchRepository matchRepository,
IMatchSeriesRepository matchSeriesRepository,
ITournamentRepository tournamentRepository,
IDivisionService divisionService) : IChampionService
{
/// <summary>
/// Computes a division's podium; see IChampionService.GetDivisionPodiumAsync.
/// </summary>
public async Task<PodiumResponse?> GetDivisionPodiumAsync(Guid divisionId)
{
Division? division = await divisionRepository.GetByIdAsync(
divisionId,
includes: [d => d.Stages, d => d.PlayoffMappings]);
if (division is null)
{
return null;
}
return await BuildDivisionPodiumAsync(division);
}
/// <summary>
/// Computes the podium of every division of a tournament; see IChampionService.GetTournamentChampionsAsync.
/// </summary>
public async Task<List<PodiumResponse>> GetTournamentChampionsAsync(Guid tournamentId)
{
List<Division> divisions = [.. await divisionRepository.FindAsync(
division => division.TournamentId == tournamentId,
includes: [d => d.Stages, d => d.PlayoffMappings])];
List<PodiumResponse> podiums = [];
foreach (Division division in divisions.OrderBy(d => d.Name, StringComparer.Ordinal))
{
podiums.Add(await BuildDivisionPodiumAsync(division));
}
return podiums;
}
/// <summary>
/// Returns the champion of every division of every finished tournament; see IChampionService.GetChampionsHistoryAsync.
/// </summary>
public async Task<List<ChampionHistoryResponse>> GetChampionsHistoryAsync(Guid? seasonId)
{
IEnumerable<Tournament> finishedTournaments = seasonId.HasValue
? await tournamentRepository.FindAsync(
tournament => tournament.Status == TournamentStatus.Finished && tournament.SeasonId == seasonId,
includes: [t => t.Season!, t => t.Divisions])
: await tournamentRepository.FindAsync(
tournament => tournament.Status == TournamentStatus.Finished,
includes: [t => t.Season!, t => t.Divisions]);
List<ChampionHistoryResponse> history = [];
foreach (Tournament tournament in finishedTournaments)
{
foreach (Division division in tournament.Divisions)
{
// One row per sub-cup; a single-bracket or group-only division yields exactly one row with a null CupName.
List<(string? CupName, PodiumTeamResponse Champion)> champions =
await GetDivisionChampionsAsync(division.Id);
foreach ((string? cupName, PodiumTeamResponse champion) in champions)
{
history.Add(new ChampionHistoryResponse
{
TournamentId = tournament.Id,
TournamentName = tournament.Name,
SeasonName = tournament.Season?.Name,
SeasonYear = tournament.Season?.Year,
Category = division.Category.ToString(),
DivisionName = division.Name,
CupName = cupName,
ChampionTeam = champion,
});
}
}
}
return history;
}
/// <summary>
/// Resolves the champion of every sub-cup of a division, or the standings leader for a group-only division.
/// </summary>
private async Task<List<(string? CupName, PodiumTeamResponse Champion)>> GetDivisionChampionsAsync(Guid divisionId)
{
Division? division = await divisionRepository.GetByIdAsync(
divisionId,
includes: [d => d.Stages, d => d.PlayoffMappings]);
if (division is null)
{
return [];
}
List<Stage> eliminationStages = [.. division.Stages.Where(stage => stage.StageType != StageType.Group)];
if (eliminationStages.Count > 0)
{
(List<Match> matches, List<MatchSeries> series) = await LoadEliminationDataAsync(eliminationStages);
IReadOnlyList<ChampionResolver.CupChampion> cupChampions = ChampionResolver.ResolveCupChampions(
eliminationStages,
[.. division.PlayoffMappings],
matches,
series);
return [.. cupChampions.Select(cup => (cup.CupName, ToTeamResponse(cup.Champion)!))];
}
List<Position> standings = await divisionService.GetPositionsByDivisionIdAsync(division.Id);
PodiumTeamResponse? leader = ToTeamResponse(standings.ElementAtOrDefault(0));
return leader is null ? [] : [((string?)null, leader)];
}
/// <summary>
/// Builds one division's podium, routing to the bracket resolver or the standings resolver.
/// </summary>
private async Task<PodiumResponse> BuildDivisionPodiumAsync(Division division)
{
List<Stage> eliminationStages = [.. division.Stages.Where(stage => stage.StageType != StageType.Group)];
if (eliminationStages.Count > 0)
{
return await BuildPlayoffPodiumAsync(division, eliminationStages);
}
return await BuildStandingsPodiumAsync(division);
}
/// <summary>
/// Resolves a playoff division's podium from its top cup's Final and third-place match.
/// </summary>
private async Task<PodiumResponse> BuildPlayoffPodiumAsync(Division division, List<Stage> eliminationStages)
{
(List<Match> eliminationMatches, List<MatchSeries> series) = await LoadEliminationDataAsync(eliminationStages);
ChampionResolver.Podium podium = ChampionResolver.ResolvePlayoffPodium(
eliminationStages,
[.. division.PlayoffMappings],
eliminationMatches,
series);
// The top cup never had real bracket rounds to draw a third place from, so position 3 in the standings is the implicit bronze there, while a real bracket that opted out of a third-place match keeps podium.Third null instead.
PodiumTeamResponse? third = ToTeamResponse(podium.Third);
if (third is null && podium.ImplicitThirdFromStandings)
{
List<Position> standings = await divisionService.GetPositionsByDivisionIdAsync(division.Id);
third = ToTeamResponse(standings.ElementAtOrDefault(2));
}
return new PodiumResponse
{
DivisionId = division.Id,
DivisionName = division.Name,
HasPlayoff = true,
First = ToTeamResponse(podium.First),
Second = ToTeamResponse(podium.Second),
Third = third,
};
}
/// <summary>
/// Resolves a group-only division's podium from the top three of its group standings.
/// </summary>
private async Task<PodiumResponse> BuildStandingsPodiumAsync(Division division)
{
List<Position> standings = await divisionService.GetPositionsByDivisionIdAsync(division.Id);
return new PodiumResponse
{
DivisionId = division.Id,
DivisionName = division.Name,
HasPlayoff = false,
First = ToTeamResponse(standings.ElementAtOrDefault(0)),
Second = ToTeamResponse(standings.ElementAtOrDefault(1)),
Third = ToTeamResponse(standings.ElementAtOrDefault(2)),
};
}
/// <summary>
/// Loads the matches and best-of-N series of a set of elimination stages with their team navigations.
/// </summary>
private async Task<(List<Match> Matches, List<MatchSeries> Series)> LoadEliminationDataAsync(
List<Stage> eliminationStages)
{
List<Guid> eliminationStageIds = [.. eliminationStages.Select(stage => stage.Id)];
List<Match> eliminationMatches = [.. await matchRepository.FindAsync(
match => eliminationStageIds.Contains(match.StageId),
includes: [m => m.HomeTeam!, m => m.VisitorTeam!, m => m.WinningTeam!])];
List<MatchSeries> series = [.. await matchSeriesRepository.FindAsync(
matchSeries => eliminationStageIds.Contains(matchSeries.StageId),
includes: [s => s.HomeTeam!, s => s.VisitorTeam!, s => s.WinningTeam!])];
return (eliminationMatches, series);
}
private static PodiumTeamResponse? ToTeamResponse(ChampionResolver.TeamRef? team)
{
return team is null
? null
: new PodiumTeamResponse
{
TeamId = team.TeamId,
TeamName = team.TeamName,
LogoUrl = team.LogoUrl,
};
}
private static PodiumTeamResponse? ToTeamResponse(Position? position)
{
return position is null
? null
: new PodiumTeamResponse
{
TeamId = position.TeamId,
TeamName = position.TeamName,
LogoUrl = position.LogoUrl,
};
}
}
@@ -0,0 +1,302 @@
using Application.DTOs.Club.Response;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Application.Utils.Helper.Slug;
using Domain.Constants;
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Manages the stable cross-season club identity.
/// </summary>
public class ClubService(IUnitOfWork unitOfWork) : IClubService
{
private readonly IClubRepository _clubRepository = unitOfWork.ClubRepository;
private readonly ITeamRepository _teamRepository = unitOfWork.TeamRepository;
private readonly ITeamTournamentRegistrationRepository _tournamentRegistrationRepository = unitOfWork.TeamTournamentRegistrationRepository;
private readonly ITournamentRepository _tournamentRepository = unitOfWork.TournamentRepository;
/// <inheritdoc />
public async Task<ClubBackfillResult> BackfillClubsAsync()
{
List<Team> unlinkedTeams = [.. (await _teamRepository.GetAllAsync()).Where(team => team.ClubId is null)];
if (unlinkedTeams.Count == 0)
{
return new ClubBackfillResult { ClubsCreated = 0, TeamsLinked = 0 };
}
// Slug is the stable identity key: same-named teams generate the same slug and therefore collapse onto a single club. Existing clubs are reused so a partial or previous backfill is never duplicated.
Dictionary<string, Club> clubsBySlug = (await _clubRepository.GetAllAsync())
.GroupBy(club => club.Slug)
.ToDictionary(group => group.Key, group => group.First());
Dictionary<Guid, string> slugByTeam = [];
List<Club> clubsToCreate = [];
foreach (Team team in unlinkedTeams)
{
string slug = ClubSlugForTeam(team);
slugByTeam[team.Id] = slug;
if (!clubsBySlug.ContainsKey(slug))
{
Club club = new()
{
Id = Guid.Empty,
Name = !string.IsNullOrWhiteSpace(team.Name) ? team.Name.Trim() : team.ThreeLetterCode,
Slug = slug,
LogoUrl = string.IsNullOrWhiteSpace(team.LogoUrl) ? null : team.LogoUrl,
DateCreated = DateTime.UtcNow,
CreatedBy = AuditConstants.SystemUser,
};
clubsBySlug[slug] = club;
clubsToCreate.Add(club);
}
}
if (clubsToCreate.Count > 0)
{
// Persist first so EF assigns each new club an Id before it is referenced by a team's ClubId FK.
await _clubRepository.AddRangeAsync(clubsToCreate);
}
foreach (Team team in unlinkedTeams)
{
team.ClubId = clubsBySlug[slugByTeam[team.Id]].Id;
}
await _teamRepository.UpdateRangeAsync(unlinkedTeams);
return new ClubBackfillResult
{
ClubsCreated = clubsToCreate.Count,
TeamsLinked = unlinkedTeams.Count,
};
}
/// <inheritdoc />
public async Task EnsureTeamLinkedToClubAsync(Team team)
{
if (team.ClubId is not null)
{
return;
}
string slug = ClubSlugForTeam(team);
Club? club = (await _clubRepository.FindAsync(candidate => candidate.Slug == slug)).FirstOrDefault();
if (club is null)
{
club = new Club
{
Id = Guid.Empty,
Name = !string.IsNullOrWhiteSpace(team.Name) ? team.Name.Trim() : team.ThreeLetterCode,
Slug = slug,
LogoUrl = string.IsNullOrWhiteSpace(team.LogoUrl) ? null : team.LogoUrl,
DateCreated = DateTime.UtcNow,
CreatedBy = AuditConstants.SystemUser,
};
await _clubRepository.AddAsync(club);
}
team.ClubId = club.Id;
await _teamRepository.UpdateAsync(team);
}
/// <summary>
/// The stable identity key shared by BackfillClubsAsync and EnsureTeamLinkedToClubAsync.
/// </summary>
private static string ClubSlugForTeam(Team team)
{
string key = !string.IsNullOrWhiteSpace(team.Name) ? team.Name.Trim() : team.ThreeLetterCode;
string slug = SlugGenerator.GenerateSlug(key);
// No alphanumeric characters to slug — fall back to the team id so the club still gets a unique, non-null slug.
return string.IsNullOrEmpty(slug) ? team.Id.ToString() : slug;
}
/// <summary>
/// Resolves a club by id or slug and assembles its cross-season team history.
/// </summary>
public async Task<ClubHistoryResponse?> GetClubHistoryAsync(string idOrSlug)
{
Club? club = Guid.TryParse(idOrSlug, out Guid clubId)
? await _clubRepository.GetByIdAsync(clubId)
: (await _clubRepository.FindAsync(candidate => candidate.Slug == idOrSlug)).FirstOrDefault();
if (club is null)
{
return null;
}
List<Team> teams = [.. await _teamRepository.FindAsync(team => team.ClubId == club.Id)];
List<Guid> teamIds = [.. teams.Select(team => team.Id)];
// Season lookups are batched to avoid N+1 queries: all registrations for these teams, then the tournaments they point at, resolved to names in memory.
List<TeamTournamentRegistration> registrations = teamIds.Count == 0
? []
: [.. await _tournamentRegistrationRepository.FindAsync(
registration => teamIds.Contains(registration.TeamId))];
List<Guid> tournamentIds = [.. registrations.Select(registration => registration.TournamentId).Distinct()];
Dictionary<Guid, Tournament> tournamentsById = tournamentIds.Count == 0
? []
: (await _tournamentRepository.FindAsync(tournament => tournamentIds.Contains(tournament.Id)))
.ToDictionary(tournament => tournament.Id);
ILookup<Guid, TeamTournamentRegistration> registrationsByTeam = registrations.ToLookup(r => r.TeamId);
ClubSummaryResponse? parentClub = club.ParentClubId is null
? null
: ToSummary(await _clubRepository.GetByIdAsync(club.ParentClubId.Value));
List<ClubSummaryResponse> childClubs = [.. (await _clubRepository.FindAsync(candidate => candidate.ParentClubId == club.Id))
.Select(candidate => ToSummary(candidate)!)];
return new ClubHistoryResponse
{
Id = club.Id,
Name = club.Name,
Slug = club.Slug,
LogoUrl = club.LogoUrl,
ParentClub = parentClub,
ChildClubs = childClubs,
Teams = [.. teams.Select(team => new ClubTeamSeasonResponse
{
TeamId = team.Id,
Name = team.Name,
Slug = team.Slug,
ThreeLetterCode = team.ThreeLetterCode,
Seasons = [.. registrationsByTeam[team.Id]
.Select(registration =>
{
tournamentsById.TryGetValue(registration.TournamentId, out Tournament? tournament);
return new ClubSeasonResponse
{
TournamentId = registration.TournamentId,
TournamentName = tournament?.Name,
StartDate = tournament?.StartDate ?? DateTime.MinValue,
};
})
// Newest season first; the history page flattens these across all teams and re-sorts, but ordering here keeps any single-team consumer correct too.
.OrderByDescending(season => season.StartDate)],
})],
};
}
/// <inheritdoc />
public async Task<IEnumerable<ClubSummaryResponse>> GetAllClubsAsync()
{
IEnumerable<Club> clubs = await _clubRepository.GetAllAsync();
return [.. clubs
.OrderBy(club => club.Name)
.Select(club => ToSummary(club)!)];
}
/// <inheritdoc />
public async Task<ClubHistoryResponse> LinkClubToParentAsync(Guid childClubId, Guid parentClubId)
{
if (childClubId == parentClubId)
{
throw new InvalidOperationException(ErrorMessages.Club.CannotLinkToItself);
}
Club child = await _clubRepository.GetByIdAsync(childClubId)
?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(childClubId));
Club parent = await _clubRepository.GetByIdAsync(parentClubId)
?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(parentClubId));
// Flat, one level deep: the parent can't itself be someone else's squad,
// and the child can't already have squads of its own — either would
// create a chain longer than institution -> squads.
if (parent.ParentClubId is not null)
{
throw new InvalidOperationException(ErrorMessages.Club.ParentAlreadyHasParent);
}
bool childAlreadyHasSquads = await _clubRepository.ExistsAsync(candidate => candidate.ParentClubId == childClubId);
if (childAlreadyHasSquads)
{
throw new InvalidOperationException(ErrorMessages.Club.CannotBecomeChildWithExistingSquads);
}
child.ParentClubId = parentClubId;
await _clubRepository.UpdateAsync(child);
return await GetClubHistoryAsync(childClubId.ToString())
?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(childClubId));
}
/// <inheritdoc />
public async Task<ClubHistoryResponse> UnlinkClubParentAsync(Guid childClubId)
{
Club child = await _clubRepository.GetByIdAsync(childClubId)
?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(childClubId));
child.ParentClubId = null;
await _clubRepository.UpdateAsync(child);
return await GetClubHistoryAsync(childClubId.ToString())
?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(childClubId));
}
/// <inheritdoc />
public async Task<ClubHistoryResponse> RenameClubAsync(Guid clubId, string name)
{
string trimmedName = name?.Trim() ?? string.Empty;
if (trimmedName.Length == 0)
{
throw new InvalidOperationException(ErrorMessages.Club.NameRequired);
}
Club club = await _clubRepository.GetByIdAsync(clubId)
?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(clubId));
club.Name = trimmedName;
await _clubRepository.UpdateAsync(club);
return await GetClubHistoryAsync(clubId.ToString())
?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(clubId));
}
/// <inheritdoc />
public async Task DeleteClubAsync(Guid clubId)
{
_ = await _clubRepository.GetByIdAsync(clubId)
?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(clubId));
bool hasTeams = await _teamRepository.ExistsAsync(team => team.ClubId == clubId);
bool hasChildClubs = await _clubRepository.ExistsAsync(candidate => candidate.ParentClubId == clubId);
if (hasTeams || hasChildClubs)
{
throw new InvalidOperationException(ErrorMessages.Club.HasTeamsOrSquadsCannotDelete);
}
await _clubRepository.RemoveAsync(candidate => candidate.Id == clubId);
}
private static ClubSummaryResponse? ToSummary(Club? club) => club is null
? null
: new ClubSummaryResponse
{
Id = club.Id,
Name = club.Name,
Slug = club.Slug,
LogoUrl = club.LogoUrl,
};
}
@@ -0,0 +1,143 @@
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Domain.Constants;
using Domain.Entities.Models;
using Domain.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Manages a division's team roster, independent of any stage placement.
/// </summary>
public class DivisionRosterService(IUnitOfWork unitOfWork, IStageService stageService) : IDivisionRosterService
{
private readonly IDivisionTeamRegistrationRepository _registrationRepository = unitOfWork.DivisionTeamRegistrationRepository;
private readonly IStageTeamMatchRepository _stageTeamMatchRepository = unitOfWork.StageTeamMatchRepository;
private readonly IDivisionRepository _divisionRepository = unitOfWork.DivisionRepository;
/// <inheritdoc/>
public async Task<List<Team>> GetRosterAsync(Guid divisionId)
{
IEnumerable<DivisionTeamRegistration> registrations = await _registrationRepository.FindAsync(
r => r.DivisionId == divisionId,
includes: [r => r.Team!]);
return [.. registrations.Select(r => r.Team!)];
}
/// <inheritdoc/>
public async Task<List<DivisionTeamRegistration>> EnrollTeamsAsync(Guid divisionId, List<Guid> teamIds)
{
await EnsureDivisionStructureEditableAsync(divisionId);
Division targetDivision = await _divisionRepository.GetByIdAsync(divisionId)
?? throw new InvalidOperationException(ErrorMessages.Stage.DivisionNotFound);
List<Guid> distinctIds = [.. teamIds.Distinct()];
IEnumerable<DivisionTeamRegistration> existingForDivision = await _registrationRepository.FindAsync(
r => r.DivisionId == divisionId && distinctIds.Contains(r.TeamId));
HashSet<Guid> alreadyRegistered = [.. existingForDivision.Select(r => r.TeamId)];
List<Guid> newIds = [.. distinctIds.Where(id => !alreadyRegistered.Contains(id))];
if (newIds.Count == 0)
{
return [];
}
await EnsureNoConflictingRegistrationAsync(targetDivision, newIds);
List<DivisionTeamRegistration> newRegistrations = [.. newIds.Select(teamId => new DivisionTeamRegistration
{
TeamId = teamId,
DivisionId = divisionId,
CreatedBy = AuditConstants.SystemUser,
})];
await _registrationRepository.AddRangeAsync(newRegistrations);
return newRegistrations;
}
/// <inheritdoc/>
public async Task UnenrollTeamsAsync(Guid divisionId, List<Guid> teamIds)
{
if (teamIds == null || teamIds.Count == 0)
{
return;
}
await EnsureDivisionStructureEditableAsync(divisionId);
await _stageTeamMatchRepository.RemoveAsync(stm =>
teamIds.Contains(stm.TeamId) && stm.Stage!.DivisionId == divisionId);
await _registrationRepository.RemoveAsync(r =>
r.DivisionId == divisionId && teamIds.Contains(r.TeamId));
}
/// <inheritdoc/>
public Task<List<Stage>> RebuildSubGroupsAsync(Guid divisionId, int subGroupCount) =>
stageService.RebuildSubGroupsAsync(divisionId, subGroupCount);
/// <inheritdoc/>
public Task AutoDistributeRosterAsync(Guid divisionId) =>
stageService.AutoDistributeRosterAsync(divisionId);
/// <inheritdoc/>
public Task ReassignTeamToSubGroupAsync(Guid teamId, Guid fromStageId, Guid toStageId) =>
stageService.ReassignTeamToSubGroupAsync(teamId, fromStageId, toStageId);
/// <summary>
/// Guards roster edits against the state of the division's tournament, mirroring StageService's structure lock.
/// </summary>
private async Task EnsureDivisionStructureEditableAsync(Guid divisionId)
{
Division? division = await _divisionRepository.GetByIdAsync(
divisionId, includes: [d => d.Tournament]);
if (division?.Tournament is null)
{
return;
}
bool structureLocked = division.Tournament.Status
is TournamentStatus.Ongoing or TournamentStatus.Finished or TournamentStatus.Canceled;
if (structureLocked)
{
throw new InvalidOperationException(ErrorMessages.Stage.StructureLockedTournamentStarted);
}
}
/// <summary>
/// Throws when a team already holds a same-kind registration, regular or cross-cup, in a different division of the same tournament.
/// </summary>
private async Task EnsureNoConflictingRegistrationAsync(Division targetDivision, List<Guid> teamIds)
{
IEnumerable<DivisionTeamRegistration> candidates = await _registrationRepository.FindAsync(
r => teamIds.Contains(r.TeamId) && r.DivisionId != targetDivision.Id,
includes: [r => r.Division!]);
List<Guid> conflictingTeamIds = [.. candidates
.Where(r => r.Division!.TournamentId == targetDivision.TournamentId
&& r.Division!.IsCrossDivisionCup == targetDivision.IsCrossDivisionCup)
.Select(r => r.TeamId)
.Distinct()];
if (conflictingTeamIds.Count > 0)
{
throw new InvalidOperationException(
ErrorMessages.Division.ConflictingRosterEnrollment(string.Join(", ", conflictingTeamIds)));
}
}
}
@@ -0,0 +1,354 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.Divisions.Request;
using Application.DTOs.Match.Request;
using Application.DTOs.Stage.Request;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Application.Utils.Constants.Pagination;
using Application.Utils.Extensions;
using Application.Utils.Helper.Playoff;
using Application.Utils.Helper.Slug;
using Application.Utils.Helper.Standings;
using Domain.Entities.Models;
using Domain.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Manages divisions within a tournament, binding their lifecycle to their tournament's.
/// </summary>
public class DivisionService(
IDivisionRepository divisionRepository,
IStageService stageService,
IMatchService matchService,
ITeamRepository teamRepository,
IStageTeamMatchRepository stageTeamMatchRepository,
ITournamentRepository tournamentRepository,
ITeamPointDeductionRepository pointDeductionRepository,
IMatchRepository matchRepository) : IDivisionService
{
/// <summary>
/// Creates a division after checking its tournament allows structural edits and its category matches.
/// </summary>
/// <param name="divisionEntity">The division entity to create.</param>
/// <returns>The created Division entity.</returns>
public async Task<Division> CreateDivisionAsync(Division divisionEntity)
{
await EnsureTournamentAllowsDivisionAsync(divisionEntity);
PlayoffMappingValidator.Validate(divisionEntity.PlayoffMappings);
divisionEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync(
divisionEntity.Name,
candidate => divisionRepository.ExistsAsync(division => division.Slug == candidate));
await divisionRepository.AddAsync(divisionEntity);
return divisionEntity;
}
/// <summary>
/// Deletes a division, blocking the delete once it has competitive history or its tournament is locked.
/// </summary>
/// <param name="id">The unique identifier of the division to delete.</param>
/// <exception cref="InvalidOperationException">
/// Thrown as a 409 when the division has played matches or point deductions, or its tournament has
/// already started or was canceled.
/// </exception>
public async Task DeleteDivisionAsync(Guid id)
{
Division? division = await divisionRepository.GetByIdAsync(id, includes: [d => d.Tournament]);
bool structureLocked = division?.Tournament?.Status
is TournamentStatus.Ongoing or TournamentStatus.Finished or TournamentStatus.Canceled;
if (structureLocked)
{
throw new InvalidOperationException(ErrorMessages.Division.StructureLockedTournamentStarted);
}
bool hasPlayedMatches = await matchRepository.ExistsAsync(
match => match.IsFinished && match.Stage.DivisionId == id);
bool hasPointDeductions = await pointDeductionRepository.ExistsAsync(
deduction => deduction.DivisionId == id);
if (hasPlayedMatches || hasPointDeductions)
{
throw new InvalidOperationException(ErrorMessages.Division.HasHistoryCannotDelete);
}
await divisionRepository.RemoveAsync(division => division.Id == id);
}
/// <summary>
/// Updates a division after re-checking its tournament still allows structural edits and its category still matches.
/// </summary>
/// <param name="divisionEntity">The division entity with updated values.</param>
public async Task UpdateDivisionAsync(Division divisionEntity)
{
await EnsureTournamentAllowsDivisionAsync(divisionEntity);
PlayoffMappingValidator.Validate(divisionEntity.PlayoffMappings);
await divisionRepository.UpdateAsync(divisionEntity);
}
/// <summary>
/// Guards a division create or update against its tournament's status and category.
/// </summary>
private async Task EnsureTournamentAllowsDivisionAsync(Division division)
{
Tournament? tournament = await tournamentRepository.GetByIdAsync(division.TournamentId);
if (tournament is null)
{
return;
}
if (tournament.Status != TournamentStatus.OpenForRegistration)
{
throw new InvalidOperationException(
ErrorMessages.Tournament.StructuralEditNotAllowed(tournament.Status));
}
if (division.Category != tournament.Category)
{
throw new InvalidOperationException(
ErrorMessages.Tournament.CategoryMismatch(division.Category, tournament.Category));
}
}
/// <summary>
/// Loads a division with just its playoff mappings, the lightweight counterpart to GetFullDivisionByIdAsync.
/// </summary>
/// <param name="divisionId">The unique identifier of the division.</param>
/// <returns>The Division entity if found; otherwise, null.</returns>
public async Task<Division?> GetSimpleDivisionByIdAsync(Guid divisionId)
{
// Eager-load the playoff mappings so the public division detail can expose its qualification ranges without a second round-trip.
return await divisionRepository.GetByIdAsync(
divisionId,
includes: [division => division.PlayoffMappings]);
}
/// <summary>
/// Retrieves a division's basic data by id or slug, auto-detecting which one was passed via GUID parsing.
/// </summary>
/// <param name="idOrSlug">The division's GUID id or its slug.</param>
/// <returns>The matching Division, or null if not found.</returns>
public async Task<Division?> GetSimpleDivisionByIdOrSlugAsync(string idOrSlug)
{
if (Guid.TryParse(idOrSlug, out Guid divisionId))
{
return await GetSimpleDivisionByIdAsync(divisionId);
}
IEnumerable<Division> matches = await divisionRepository.FindAsync(
division => division.Slug == idOrSlug,
includes: [division => division.PlayoffMappings]);
return matches.FirstOrDefault();
}
/// <summary>
/// Loads a division with its tournament and stages, the heavier counterpart to GetSimpleDivisionByIdAsync.
/// </summary>
/// <param name="divisionId">The unique identifier of the division.</param>
/// <returns>The Division entity with related data if found; otherwise, null.</returns>
public async Task<Division?> GetFullDivisionByIdAsync(Guid divisionId)
{
return await divisionRepository.GetByIdAsync(divisionId, includes: [division => division.Tournament, division => division.Stages]);
}
/// <summary>
/// Retrieves a paginated and filtered list of division entities asynchronously.
/// </summary>
/// <param name="filter">The filter and pagination parameters.</param>
/// <returns>A PaginatedResponse{Division} containing the filtered divisions.</returns>
public async Task<PaginatedResponse<Division>> GetAllDivisionsAsync(GetDivisionsFilteredRequest filter)
{
Expression<Func<Division, bool>> expression = QueryableExtensions.ConstructFilterExpression<Division, GetDivisionsFilteredRequest>(filter);
IEnumerable<Division> filteredDivisions = await divisionRepository.FindAsync(expression, filter: filter);
int totalCount = await divisionRepository.CountAsync(expression);
return new PaginatedResponse<Division>
{
Page = filter.PageNumber,
PageSize = filter.PageSize,
TotalCount = totalCount,
Items = filteredDivisions
};
}
/// <summary>
/// Computes standings for a division from its Group stage's finished matches only.
/// </summary>
/// <param name="divisionId">The id of the division.</param>
/// <returns>One Position per team with at least one finished Group-stage match; empty if the division has no Group stage or no finished matches yet.</returns>
public async Task<List<Position>> GetPositionsByDivisionIdAsync(Guid divisionId)
{
PaginatedResponse<Stage> stages = await stageService.GetAllStagesAsync(new GetStagesFilteredRequest
{
DivisionId = divisionId,
StageType = StageType.Group,
PageSize = PaginationDefaults.MaxPageSize,
});
Stage? groupStage = stages.Items.FirstOrDefault();
if (groupStage is null)
{
return [];
}
Division? division = await divisionRepository.GetByIdAsync(divisionId);
int pointsForWin = division?.PointsForWin ?? PositionCalculator.DefaultPointsForWin;
int pointsForLoss = division?.PointsForLoss ?? PositionCalculator.DefaultPointsForLoss;
List<TeamPointDeduction> deductions = await GetDeductionsAsync(divisionId);
PaginatedResponse<Match> matches = await matchService.GetAllMatchesAsync(new GetMatchesFilteredRequest
{
StageId = groupStage.Id,
IsFinished = true,
PageSize = PaginationDefaults.MaxPageSize,
});
// Seed the table with every team assigned to the zone so the standings list all of them at 0-0 from the start, not only once they have a finished match.
List<Team> rosterTeams = await GetAssignedTeamsAsync(groupStage.Id);
return PositionCalculator.CalculatePositions(matches.Items, pointsForWin, pointsForLoss, deductions, rosterTeams);
}
/// <summary>
/// The teams currently assigned to a group stage, resolved through their StageTeamMatch membership.
/// </summary>
private async Task<List<Team>> GetAssignedTeamsAsync(Guid stageId)
{
IEnumerable<StageTeamMatch> assignments = await stageTeamMatchRepository.FindAsync(
stm => stm.StageId == stageId,
includes: [stm => stm.Team!]);
return [.. assignments
.Select(stm => stm.Team)
.Where(team => team is not null)
.Cast<Team>()];
}
/// <summary>
/// Loads every disciplinary point deduction applied in a division, threaded into the standings calculation.
/// </summary>
private async Task<List<TeamPointDeduction>> GetDeductionsAsync(Guid divisionId)
{
return [.. await pointDeductionRepository.FindAsync(
deduction => deduction.DivisionId == divisionId)];
}
/// <summary>
/// Computes standings for a division split by Group stage, one entry per internal group.
/// </summary>
/// <param name="divisionId">The id of the division.</param>
/// <returns>One GroupStandings per Group stage; empty when the division has no Group stage.</returns>
public async Task<List<GroupStandings>> GetGroupStandingsByDivisionIdAsync(Guid divisionId)
{
PaginatedResponse<Stage> stages = await stageService.GetAllStagesAsync(new GetStagesFilteredRequest
{
DivisionId = divisionId,
StageType = StageType.Group,
PageSize = PaginationDefaults.MaxPageSize,
});
List<Stage> groupStages = [.. stages.Items
.OrderBy(stage => stage.Order)
.ThenBy(stage => stage.Name, StringComparer.Ordinal)];
if (groupStages.Count == 0)
{
return [];
}
Division? division = await divisionRepository.GetByIdAsync(divisionId);
int pointsForWin = division?.PointsForWin ?? PositionCalculator.DefaultPointsForWin;
int pointsForLoss = division?.PointsForLoss ?? PositionCalculator.DefaultPointsForLoss;
List<TeamPointDeduction> deductions = await GetDeductionsAsync(divisionId);
List<GroupStandings> result = [];
foreach (Stage groupStage in groupStages)
{
PaginatedResponse<Match> matches = await matchService.GetAllMatchesAsync(new GetMatchesFilteredRequest
{
StageId = groupStage.Id,
IsFinished = true,
PageSize = PaginationDefaults.MaxPageSize,
});
List<Team> rosterTeams = await GetAssignedTeamsAsync(groupStage.Id);
result.Add(new GroupStandings
{
StageId = groupStage.Id,
StageName = groupStage.Name,
Positions = PositionCalculator.CalculatePositions(matches.Items, pointsForWin, pointsForLoss, deductions, rosterTeams),
});
}
return result;
}
/// <summary>
/// Returns every team registered to the tournament that does not yet belong to any division.
/// </summary>
/// <param name="tournamentId">The id of the tournament.</param>
public async Task<List<Team>> GetUnassignedTeamsAsync(Guid tournamentId)
{
List<Team> registeredTeams = [.. await teamRepository.FindAsync(team => team.TournamentId == tournamentId)];
if (registeredTeams.Count == 0)
{
return [];
}
List<Guid> registeredTeamIds = [.. registeredTeams.Select(t => t.Id)];
IEnumerable<StageTeamMatch> assignments = await stageTeamMatchRepository.FindAsync(stm =>
registeredTeamIds.Contains(stm.TeamId)
&& stm.Stage!.Division.TournamentId == tournamentId);
HashSet<Guid> assignedTeamIds = [.. assignments.Select(a => a.TeamId)];
return [.. registeredTeams.Where(t => !assignedTeamIds.Contains(t.Id))];
}
/// <summary>
/// Reassigns a division to a different tournament in memory, validating only the target's existence.
/// </summary>
/// <param name="division">The division to reassign. Its Tournament navigation and TournamentId are mutated in place.</param>
/// <param name="tournamentId">The id of the tournament the division should belong to.</param>
/// <returns>True if the target tournament exists and the division was reassigned in memory; false if no tournament with that id exists.</returns>
public async Task<bool> TryAssignTournamentAsync(Division division, Guid tournamentId)
{
if (division.TournamentId == tournamentId)
{
return true;
}
Tournament? targetTournament = await tournamentRepository.GetByIdAsync(tournamentId);
if (targetTournament is null)
{
return false;
}
division.Tournament = targetTournament;
division.TournamentId = tournamentId;
return true;
}
}
@@ -0,0 +1,171 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.MatchSeries.Request;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Application.Utils.Extensions;
using Application.Utils.Helper.Series;
using Application.Utils.Helper.Slug;
using Domain.Constants;
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Threading.Tasks;
using MatchType = Domain.Enums.MatchType;
namespace Application.Services;
/// <summary>
/// Service responsible for managing best-of-N playoff series between two teams at a single bracket round.
/// </summary>
public class MatchSeriesService(IUnitOfWork unitOfWork) : IMatchSeriesService
{
private readonly IMatchSeriesRepository _matchSeriesRepository = unitOfWork.MatchSeriesRepository;
private readonly IMatchRepository _matchRepository = unitOfWork.MatchRepository;
private readonly IStageRepository _stageRepository = unitOfWork.StageRepository;
private readonly IStageTeamMatchRepository _stageTeamMatchRepository = unitOfWork.StageTeamMatchRepository;
private readonly ITeamRepository _teamRepository = unitOfWork.TeamRepository;
public async Task<MatchSeries?> GetSeriesByIdAsync(Guid seriesId)
{
return await _matchSeriesRepository.GetByIdAsync(seriesId,
includes: [s => s.HomeTeam!, s => s.VisitorTeam!, s => s.WinningTeam!, s => s.Matches]);
}
public async Task<PaginatedResponse<MatchSeries>> GetAllSeriesAsync(GetMatchSeriesFilteredRequest filter)
{
Expression<Func<MatchSeries, bool>> expression = QueryableExtensions.ConstructFilterExpression<MatchSeries, GetMatchSeriesFilteredRequest>(filter);
IEnumerable<MatchSeries> filteredSeries = await _matchSeriesRepository.FindAsync(expression, filter: filter,
includes: [s => s.HomeTeam!, s => s.VisitorTeam!, s => s.WinningTeam!, s => s.Matches]);
int totalCount = await _matchSeriesRepository.CountAsync(expression);
return new PaginatedResponse<MatchSeries>
{
Page = filter.PageNumber,
PageSize = filter.PageSize,
TotalCount = totalCount,
Items = filteredSeries
};
}
/// <summary>
/// Opens a best-of-N series between two teams already assigned to the stage, at most one per pair.
/// </summary>
public async Task<MatchSeries> CreateSeriesAsync(Guid stageId, Guid homeTeamId, Guid visitorTeamId)
{
if (homeTeamId == visitorTeamId)
{
throw new InvalidOperationException(ErrorMessages.MatchSeries.RequiresTwoDifferentTeams);
}
Stage stage = await _stageRepository.GetByIdAsync(stageId)
?? throw new InvalidOperationException(ErrorMessages.Stage.NotFoundGeneric);
await EnsureTeamAssignedToStageAsync(stageId, homeTeamId);
await EnsureTeamAssignedToStageAsync(stageId, visitorTeamId);
bool alreadyExists = await _matchSeriesRepository.ExistsAsync(series =>
series.StageId == stageId
&& ((series.HomeTeamId == homeTeamId && series.VisitorTeamId == visitorTeamId)
|| (series.HomeTeamId == visitorTeamId && series.VisitorTeamId == homeTeamId)));
if (alreadyExists)
{
throw new InvalidOperationException(ErrorMessages.MatchSeries.AlreadyExistsForStage);
}
MatchSeries seriesEntity = new()
{
StageId = stageId,
HomeTeamId = homeTeamId,
VisitorTeamId = visitorTeamId,
BestOf = stage.BestOf,
CreatedBy = AuditConstants.SystemUser,
};
await _matchSeriesRepository.AddAsync(seriesEntity);
return seriesEntity;
}
/// <summary>
/// Adds the next game to a series, blocked once it already has a winner or has reached its BestOf count.
/// </summary>
public async Task<Match> AddGameToSeriesAsync(Guid seriesId, DateTime matchDate, Guid? venueId)
{
MatchSeries series = await _matchSeriesRepository.GetByIdAsync(seriesId, includes: [s => s.Matches])
?? throw new InvalidOperationException(ErrorMessages.MatchSeries.NotFound);
if (series.WinningTeamId.HasValue)
{
throw new InvalidOperationException(ErrorMessages.MatchSeries.AlreadyDecided);
}
if (series.Matches.Count >= series.BestOf)
{
throw new InvalidOperationException(ErrorMessages.MatchSeries.MaxGamesReached(series.BestOf));
}
Team? homeTeam = await _teamRepository.GetByIdAsync(series.HomeTeamId);
Team? visitorTeam = await _teamRepository.GetByIdAsync(series.VisitorTeamId);
Match game = new()
{
StageId = series.StageId,
HomeTeamId = series.HomeTeamId,
VisitorTeamId = series.VisitorTeamId,
SeriesId = series.Id,
GameNumber = series.Matches.Count + 1,
MatchDate = matchDate,
VenueId = venueId,
Type = MatchType.Playoff,
Slug = string.Empty,
IsFinished = false,
CreatedBy = AuditConstants.SystemUser,
};
game.Slug = await SlugGenerator.GenerateUniqueSlugAsync(
MatchSlugSourceBuilder.Build(homeTeam?.Name, visitorTeam?.Name, game.MatchDate),
candidate => _matchRepository.ExistsAsync(match => match.Slug == candidate));
await _matchRepository.AddAsync(game);
return game;
}
/// <summary>
/// Re-evaluates a series' winner from its games, a no-op once it already has a WinningTeamId.
/// </summary>
public async Task RecalculateSeriesWinnerAsync(Guid seriesId)
{
MatchSeries? series = await _matchSeriesRepository.GetByIdAsync(seriesId, includes: [s => s.Matches]);
if (series is null || series.WinningTeamId.HasValue)
{
return;
}
Guid? winningTeamId = SeriesDecisionCalculator.DetermineWinner(series);
if (winningTeamId.HasValue)
{
series.WinningTeamId = winningTeamId;
await _matchSeriesRepository.UpdateAsync(series);
}
}
private async Task EnsureTeamAssignedToStageAsync(Guid stageId, Guid teamId)
{
bool isAssigned = await _stageTeamMatchRepository.ExistsAsync(stm =>
stm.StageId == stageId && stm.TeamId == teamId);
if (!isAssigned)
{
throw new InvalidOperationException(ErrorMessages.MatchSeries.TeamNotAssignedToStage(teamId));
}
}
}
@@ -0,0 +1,570 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.Match.Request;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Application.Utils.Constants.Stage;
using Application.Utils.Extensions;
using Application.Utils.Helper.MatchResult;
using Application.Utils.Helper.Playoff;
using Application.Utils.Helper.RoundRobin;
using Application.Utils.Helper.Slug;
using Domain.Constants;
using Domain.Entities.Models;
using Domain.Enums;
using LinqKit;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using MatchType = Domain.Enums.MatchType;
namespace Application.Services;
/// <summary>
/// Manages individual matches: CRUD, result and walkover and suspension transitions, and automated fixture generation.
/// </summary>
public class MatchService(IUnitOfWork unitOfWork, IStageService stageService) : IMatchService
{
private readonly IMatchRepository _matchRepository = unitOfWork.MatchRepository;
private readonly IStageRepository _stageRepository = unitOfWork.StageRepository;
private readonly ITeamRepository _teamRepository = unitOfWork.TeamRepository;
private readonly IStageTeamMatchRepository _stageTeamMatchRepository = unitOfWork.StageTeamMatchRepository;
public async Task<Match> CreateMatchAsync(Match matchEntity)
{
string homeTeamName = await ResolveTeamNameAsync(matchEntity.HomeTeamId);
string visitorTeamName = await ResolveTeamNameAsync(matchEntity.VisitorTeamId);
matchEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync(
MatchSlugSourceBuilder.Build(homeTeamName, visitorTeamName, matchEntity.MatchDate),
candidate => _matchRepository.ExistsAsync(match => match.Slug == candidate));
await _matchRepository.AddAsync(matchEntity);
return matchEntity;
}
public async Task<Match?> GetMatchByIdAsync(Guid matchId)
{
return await _matchRepository.GetByIdAsync(matchId, includes: [m => m.HomeTeam!, m => m.VisitorTeam!]);
}
/// <summary>
/// Retrieves a match by id or slug with its home and visitor teams, auto-detecting which one was passed.
/// </summary>
/// <param name="idOrSlug">The match's GUID id or its slug.</param>
/// <returns>The match entity if found; otherwise, null.</returns>
public async Task<Match?> GetMatchByIdOrSlugAsync(string idOrSlug)
{
// Loads the full public-detail graph including both teams, venue, and scorers with their players so the match page can render the scoreboard and the per-team goleadores, since the generic include list cannot express the nested Scorers.Player path scorer-to-team attribution needs.
return await _matchRepository.GetDetailByIdOrSlugAsync(idOrSlug);
}
public async Task<Match?> GetMatchByIdWithScorersAsync(Guid matchId)
{
Match? match = await _matchRepository.GetByIdAsync(matchId,
includes: [m => m.HomeTeam!,
m => m.VisitorTeam!,
m => m.PlayerStatistics,
m => m.Venue!]
);
return match is null ? null : match;
}
public async Task DeleteMatchAsync(Guid id)
{
await _matchRepository.RemoveAsync(match => match.Id == id);
}
/// <summary>
/// True if another match is scheduled strictly within a 2-hour window of matchDate at the same venue.
/// </summary>
public async Task<bool> HasVenueScheduleConflictAsync(Guid venueId, DateTime matchDate, Guid excludeMatchId)
{
DateTime windowStart = matchDate.AddHours(-2);
DateTime windowEnd = matchDate.AddHours(2);
return await _matchRepository.ExistsAsync(match =>
match.Id != excludeMatchId
&& match.VenueId == venueId
&& match.MatchDate > windowStart
&& match.MatchDate < windowEnd);
}
public async Task UpdateMatchAsync(Match matchEntity)
{
await _matchRepository.UpdateAsync(matchEntity);
}
/// <summary>
/// Loads a decisive final result for a match, rejecting a tied score.
/// </summary>
/// <param name="matchId">The id of the match to load.</param>
/// <param name="homeScore">The home team's final score.</param>
/// <param name="visitorScore">The visitor team's final score.</param>
/// <returns>The updated match, or null if no match with that id exists.</returns>
/// <exception cref="InvalidOperationException">Thrown when the score is tied.</exception>
public async Task<Match?> LoadMatchResultAsync(Guid matchId, int homeScore, int visitorScore)
{
Match? match = await _matchRepository.GetByIdAsync(matchId,
includes: [m => m.HomeTeam!, m => m.VisitorTeam!, m => m.Stage]);
if (match is null)
{
return null;
}
MatchResultFinalizer.ApplyResult(match, homeScore, visitorScore);
await _matchRepository.UpdateAsync(match);
await stageService.TryAutoSeedPlayoffPhaseAsync(match.StageId);
return match;
}
/// <summary>
/// Marks a match as a walkover, awarding the present team the win and the absent team zero.
/// </summary>
/// <param name="matchId">The id of the match.</param>
/// <param name="presentTeamId">The team that showed up, the winner by walkover.</param>
/// <param name="presentTeamScore">Optional override for the present team's awarded score; defaults to the regulation value.</param>
/// <returns>The updated match, or null if no match with that id exists.</returns>
/// <exception cref="InvalidOperationException">Thrown when the present team is not part of the match.</exception>
public async Task<Match?> LoadWalkOverAsync(Guid matchId, Guid presentTeamId, int? presentTeamScore)
{
Match? match = await _matchRepository.GetByIdAsync(matchId,
includes: [m => m.HomeTeam!, m => m.VisitorTeam!, m => m.Stage]);
if (match is null)
{
return null;
}
bool presentIsHome = match.HomeTeamId == presentTeamId;
bool presentIsVisitor = match.VisitorTeamId == presentTeamId;
if (!presentIsHome && !presentIsVisitor)
{
throw new InvalidOperationException(ErrorMessages.Match.WalkOverTeamNotInMatch);
}
int winnerScore = presentTeamScore ?? MatchDefaults.WalkOverWinnerScore;
match.HomeScore = presentIsHome ? winnerScore : MatchDefaults.WalkOverLoserScore;
match.VisitorScore = presentIsHome ? MatchDefaults.WalkOverLoserScore : winnerScore;
match.WinningTeam = presentIsHome ? match.HomeTeam : match.VisitorTeam;
match.WinningTeamId = presentTeamId;
match.IsFinished = true;
match.Status = MatchStatus.WalkOver;
await _matchRepository.UpdateAsync(match);
await stageService.TryAutoSeedPlayoffPhaseAsync(match.StageId);
return match;
}
/// <summary>
/// Reprograms or suspends a match, moving it to a new date without touching its Round.
/// </summary>
/// <param name="matchId">The id of the match to suspend/reprogram.</param>
/// <param name="newMatchDate">Optional new calendar date; when null the existing date is kept.</param>
/// <returns>The updated match, or null if no match with that id exists.</returns>
public async Task<Match?> SuspendMatchAsync(Guid matchId, DateTime? newMatchDate)
{
Match? match = await _matchRepository.GetByIdAsync(matchId,
includes: [m => m.HomeTeam!, m => m.VisitorTeam!]);
if (match is null)
{
return null;
}
match.Status = MatchStatus.Suspended;
if (newMatchDate.HasValue)
{
match.MatchDate = newMatchDate.Value;
}
await _matchRepository.UpdateAsync(match);
return match;
}
public async Task<List<Match>> GetStageMatchesByRoundAsync(Guid stageId)
{
IEnumerable<Match> matches = await _matchRepository.FindAsync(
match => match.StageId == stageId,
includes: [match => match.HomeTeam!, match => match.VisitorTeam!, match => match.Venue!]);
// Round is the canonical grouping: order by matchday, then by date within the round for a stable "Partido 1, Partido 2, …" order; matches without a round sort after the numbered ones.
return [.. matches
.OrderBy(match => match.Round ?? int.MaxValue)
.ThenBy(match => match.MatchDate)];
}
public async Task<PaginatedResponse<Match>> GetAllMatchesAsync(GetMatchesFilteredRequest filter)
{
Expression<Func<Match, bool>> expression = QueryableExtensions.ConstructFilterExpression<Match, GetMatchesFilteredRequest>(filter);
if (filter.DivisionId.HasValue)
{
Expression<Func<Match, bool>> divisionExpression = match => match.Stage.DivisionId == filter.DivisionId.Value;
expression = expression.And(divisionExpression);
}
if (filter.TournamentId.HasValue)
{
Expression<Func<Match, bool>> tournamentExpression = match => match.Stage.Division.TournamentId == filter.TournamentId.Value;
expression = expression.And(tournamentExpression);
}
if (!string.IsNullOrWhiteSpace(filter.HomeTeamName))
{
Expression<Func<Match, bool>> homeTeamExpression = match => match.HomeTeam != null && match.HomeTeam.Name.ToLower().Contains(filter.HomeTeamName.ToLower());
expression = expression.And(homeTeamExpression);
}
if (!string.IsNullOrWhiteSpace(filter.VisitorTeamName))
{
Expression<Func<Match, bool>> visitorTeamExpression = match => match.VisitorTeam != null && match.VisitorTeam.Name.ToLower().Contains(filter.VisitorTeamName.ToLower());
expression = expression.And(visitorTeamExpression);
}
IEnumerable<Match> filteredMatches = await _matchRepository.FindAsync(expression, filter: filter, includes: [match => match.HomeTeam!,
match => match.VisitorTeam!,
match => match.Venue!]);
int totalCount = await _matchRepository.CountAsync(expression);
return new PaginatedResponse<Match>
{
Page = filter.PageNumber,
PageSize = filter.PageSize,
TotalCount = totalCount,
Items = filteredMatches
};
}
/// <summary>
/// Builds a stage's fixture from its type, blocked once the stage already has matches.
/// </summary>
public async Task<List<Match>> CreateAutomatedMatchesAsync(Guid stageId)
{
Stage stage = await _stageRepository.GetByIdAsync(stageId, includes: [s => s.Matches, s => s.Division])
?? throw new InvalidOperationException(ErrorMessages.Stage.NotFoundGeneric);
if (stage.Matches.Count > 0)
{
throw new InvalidOperationException(ErrorMessages.Match.StageAlreadyHasMatches);
}
List<Match> matches = stage.StageType switch
{
StageType.Group => await BuildGroupStageMatchesAsync(stage),
StageType.QuarterFinal => await CreateKnockoutStageMatchesAsync(stage),
StageType.SemiFinal => await CreateKnockoutStageMatchesAsync(stage),
StageType.ThirdPlace => await CreateFinalStageMatchesAsync(stage),
StageType.Final => await CreateFinalStageMatchesAsync(stage),
_ => throw new NotSupportedException(ErrorMessages.Match.StageTypeNotSupportedForAutomatedCreation)
};
await AssignMatchSlugsAsync(matches);
await _matchRepository.AddRangeAsync(matches);
return matches;
}
/// <summary>
/// Resolves a team's name for slug composition without requiring the caller to have loaded Team.
/// </summary>
/// <param name="teamId">The team's id, or null when no team is assigned.</param>
/// <returns>The team's name, or MatchSlugSourceBuilder.UnassignedTeamPlaceholder
/// when teamId is null or does not resolve to a team.</returns>
private async Task<string> ResolveTeamNameAsync(Guid? teamId)
{
if (!teamId.HasValue)
{
return MatchSlugSourceBuilder.UnassignedTeamPlaceholder;
}
Team? team = await _teamRepository.GetByIdAsync(teamId.Value);
return team?.Name ?? MatchSlugSourceBuilder.UnassignedTeamPlaceholder;
}
/// <summary>
/// Assigns a unique slug to every match in a freshly built batch before it is persisted.
/// </summary>
private async Task AssignMatchSlugsAsync(List<Match> matches)
{
List<Guid> teamIds = [.. matches
.SelectMany(match => new[] { match.HomeTeamId, match.VisitorTeamId })
.Where(teamId => teamId.HasValue)
.Select(teamId => teamId!.Value)
.Distinct()];
Dictionary<Guid, string> teamNamesById = teamIds.Count == 0
? []
: (await _teamRepository.FindAsync(team => teamIds.Contains(team.Id)))
.ToDictionary(team => team.Id, team => team.Name);
Dictionary<Match, string> baseSlugByMatch = matches.ToDictionary(
match => match,
match => SlugGenerator.GenerateSlug(MatchSlugSourceBuilder.Build(
ResolveTeamNameFromMap(match.HomeTeamId, teamNamesById),
ResolveTeamNameFromMap(match.VisitorTeamId, teamNamesById),
match.MatchDate)));
HashSet<string> baseSlugs = [.. baseSlugByMatch.Values.Distinct()];
HashSet<string> existingSlugs = [.. (await _matchRepository.FindAsync(
m => baseSlugs.Contains(m.Slug)))
.Select(m => m.Slug)];
HashSet<string> slugsAssignedInBatch = [];
foreach (Match match in matches)
{
string baseSlug = baseSlugByMatch[match];
// Fast, IO-free path: the prefetch already proved this exact base slug is free, and nothing earlier in this batch claimed it either.
if (!existingSlugs.Contains(baseSlug) && !slugsAssignedInBatch.Contains(baseSlug))
{
match.Slug = baseSlug;
}
else
{
string homeTeamName = ResolveTeamNameFromMap(match.HomeTeamId, teamNamesById);
string visitorTeamName = ResolveTeamNameFromMap(match.VisitorTeamId, teamNamesById);
match.Slug = await SlugGenerator.GenerateUniqueSlugAsync(
MatchSlugSourceBuilder.Build(homeTeamName, visitorTeamName, match.MatchDate),
async candidate => slugsAssignedInBatch.Contains(candidate)
|| existingSlugs.Contains(candidate)
|| await _matchRepository.ExistsAsync(m => m.Slug == candidate));
}
slugsAssignedInBatch.Add(match.Slug);
}
}
private static string ResolveTeamNameFromMap(Guid? teamId, IReadOnlyDictionary<Guid, string> teamNamesById)
{
return teamId.HasValue && teamNamesById.TryGetValue(teamId.Value, out string? name)
? name
: MatchSlugSourceBuilder.UnassignedTeamPlaceholder;
}
/// <summary>
/// Builds an unpersisted match for a stage's automated fixture with a placeholder slug.
/// </summary>
private static Match BuildMatch(Stage stage, DateTime matchDate, MatchType matchType = MatchType.Playoff)
{
return new()
{
StageId = stage.Id,
Type = matchType,
Slug = string.Empty,
IsFinished = false,
MatchDate = matchDate,
CreatedBy = AuditConstants.SystemUser
};
}
private async Task<int> ResolveGroupTeamCountAsync(Stage stage)
{
int totalGroups = await _stageRepository.CountAsync(s =>
s.DivisionId == stage.DivisionId && s.StageType == StageType.Group);
if (totalGroups <= 0)
{
throw new InvalidOperationException(ErrorMessages.Match.NoGroupStagesForDivision);
}
int registeredTeams = await _teamRepository.CountAsync(team => team.TournamentId == stage.Division.TournamentId);
if (registeredTeams <= 0)
{
throw new InvalidOperationException(ErrorMessages.Match.NoTeamsRegistered);
}
if (registeredTeams % totalGroups != 0)
{
throw new InvalidOperationException(
ErrorMessages.Match.TeamsNotDistributableAcrossGroups(registeredTeams, totalGroups));
}
int teamsPerGroup = registeredTeams / totalGroups;
return teamsPerGroup < 2
? throw new InvalidOperationException(ErrorMessages.Match.NotEnoughTeamsPerGroup)
: teamsPerGroup;
}
/// <summary>
/// Resolves team identities to pair for a group stage with no explicit StageTeamMatch assignments yet.
/// </summary>
private async Task<List<Guid>> ResolveGroupTeamIdsAsync(Stage stage, int expectedTeamCount)
{
int totalGroups = await _stageRepository.CountAsync(s =>
s.DivisionId == stage.DivisionId && s.StageType == StageType.Group);
if (totalGroups != 1)
{
return [];
}
List<Team> registeredTeams = [.. await _teamRepository.FindAsync(team => team.TournamentId == stage.Division.TournamentId)];
return registeredTeams.Count == expectedTeamCount
? [.. registeredTeams.Select(t => t.Id)]
: [];
}
/// <summary>
/// A stage with explicit StageTeamMatch assignments always uses exactly those teams for pairing.
/// </summary>
private async Task<List<Match>> BuildGroupStageMatchesAsync(Stage stage)
{
List<Guid> assignedTeamIds = [.. (await _stageTeamMatchRepository.FindAsync(stm => stm.StageId == stage.Id))
.Select(stm => stm.TeamId)];
if (assignedTeamIds.Count >= 2)
{
return await CreateGroupStageMatchesAsync(stage, assignedTeamIds.Count, assignedTeamIds);
}
int teamCount = await ResolveGroupTeamCountAsync(stage);
List<Guid> teamIds = await ResolveGroupTeamIdsAsync(stage, teamCount);
return await CreateGroupStageMatchesAsync(stage, teamCount, teamIds);
}
/// <summary>
/// Creates the group stage's matches from a round-robin fixture organised by matchday.
/// </summary>
private static Task<List<Match>> CreateGroupStageMatchesAsync(Stage stage, int totalTeams, List<Guid> teamIds)
{
List<Match> matches = [];
bool seeded = teamIds.Count == totalTeams;
// When the roster is unknown we still need the round structure, meaning how many matches and each one's matchday, which depends only on the team count and legs, so schedule with throwaway placeholder ids and keep just the round numbers, leaving the teams unseeded.
IReadOnlyList<Guid> rosterForSchedule = seeded
? teamIds
: [.. Enumerable.Range(0, totalTeams).Select(_ => Guid.NewGuid())];
List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> fixture =
RoundRobinScheduler.GenerateRounds(rosterForSchedule, stage.RoundRobinLegs);
// Lay the jornadas out weekly, division-aware: regular zones keep the Sunday baseline, and a cross-division-cup stage shifts to a different weekday so a team playing both its zone and the cup never has two jornadas on the same day; this is coordinated here, not at the tournament level, so both the manual single-stage generate endpoint and the tournament-start fixture trigger get the same anti-collision schedule.
bool isCrossDivisionCup = stage.Division?.IsCrossDivisionCup ?? false;
foreach ((Guid homeTeamId, Guid visitorTeamId, int round) in fixture)
{
Match match = BuildMatch(stage, RoundCalendar.DateForRound(stage.StartDate, round, isCrossDivisionCup), MatchType.Regular);
match.Round = round;
if (seeded)
{
match.HomeTeamId = homeTeamId;
match.VisitorTeamId = visitorTeamId;
}
matches.Add(match);
}
return Task.FromResult(matches);
}
private async Task<List<Match>> CreateKnockoutStageMatchesAsync(Stage stage)
{
List<Match> matches = [];
int matchCount = await ResolveKnockoutFirstRoundMatchCountAsync(stage);
List<DateTime> matchDates = DistributeMatchDates(stage.StartDate, stage.EndDate, matchCount);
for (int i = 0; i < matchCount; i++)
{
matches.Add(BuildMatch(stage, matchDates[i]));
}
return matches;
}
/// <summary>
/// Number of empty first-round matches to create for a bracket stage.
/// </summary>
private async Task<int> ResolveKnockoutFirstRoundMatchCountAsync(Stage stage)
{
if (stage.Division.IsCrossDivisionCup)
{
List<Stage> groupStages = [.. await _stageRepository.FindAsync(
s => s.DivisionId == stage.DivisionId && s.StageType == StageType.Group)];
if (groupStages.Count > 1)
{
int totalQualifiers = 0;
foreach (Stage groupStage in groupStages)
{
int groupSize = await _stageTeamMatchRepository.CountAsync(stm => stm.StageId == groupStage.Id);
totalQualifiers += Math.Min(stage.Division.QualifiersPerGroup, groupSize);
}
return PlayoffSeeder.NextPowerOfTwo(totalQualifiers) / 2;
}
}
return stage.StageType switch
{
StageType.QuarterFinal => KnockoutMatchCount.QuarterFinal,
StageType.SemiFinal => KnockoutMatchCount.SemiFinal,
_ => throw new InvalidOperationException(ErrorMessages.Match.InvalidKnockoutStageType)
};
}
private static Task<List<Match>> CreateFinalStageMatchesAsync(Stage stage)
{
List<Match> matches = [];
List<DateTime> matchDates = DistributeMatchDates(stage.StartDate, stage.EndDate, 1);
matches.Add(BuildMatch(stage, matchDates[0]));
return Task.FromResult(matches);
}
private static List<DateTime> DistributeMatchDates(DateTime startDate, DateTime endDate, int matchCount)
{
if (matchCount <= 0)
{
throw new ArgumentException(ErrorMessages.Match.MatchCountMustBePositive, nameof(matchCount));
}
if (endDate < startDate)
{
throw new ArgumentException(ErrorMessages.Match.EndDateBeforeStartDate);
}
List<DateTime> matchDates = [];
if (matchCount == 1)
{
matchDates.Add(startDate.AddDays((endDate - startDate).TotalDays / 2));
return matchDates;
}
double totalDays = (endDate - startDate).TotalDays;
double interval = totalDays / (matchCount - 1);
for (int i = 0; i < matchCount; i++)
{
DateTime matchDate = startDate.AddDays(interval * i);
matchDates.Add(matchDate);
}
return matchDates;
}
}
@@ -0,0 +1,101 @@
using Application.DTOs.MedicalRecord.Response;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Domain.Entities.Models;
using Domain.Enums;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Applies medical-record and eligibility operations to a player's season registration.
/// </summary>
public class MedicalRecordService(IUnitOfWork unitOfWork) : IMedicalRecordService
{
private readonly IPlayerTeamRegistrationRepository _registrationRepository = unitOfWork.PlayerTeamRegistrationRepository;
/// <summary>
/// Records a new ficha médica upload and resets it to MedicalRecordStatus.Pending for review.
/// </summary>
public async Task<MedicalRecordResponse> RecordUploadAsync(
Guid playerId, Guid teamId, Guid tournamentId, string fileReference, string fileName, string actor)
{
PlayerTeamRegistration registration = await GetRegistrationAsync(playerId, teamId, tournamentId);
// Once the ficha is Approved against a real stored file, the player is habilitado and the record is frozen so it can only be viewed or downloaded, never replaced by a new upload; an Approved row with a legacy or unresolvable reference from before the private-bucket relocation never actually habilitated the player and would otherwise have no path to fix it, so re-upload stays allowed for that shape.
if (registration.MedicalRecordStatus == MedicalRecordStatus.Approved
&& PlayerTeamRegistration.IsStoredReference(registration.MedicalRecordFileUrl))
{
throw new InvalidOperationException(ErrorMessages.MedicalRecord.AlreadyApproved);
}
registration.MedicalRecordFileUrl = fileReference;
registration.MedicalRecordFileName = fileName;
// Uploading a new file always requires a fresh review — it never habilitates on its own.
registration.MedicalRecordStatus = MedicalRecordStatus.Pending;
registration.MedicalRecordReviewReason = null;
registration.MedicalRecordReviewedAt = null;
Touch(registration, actor);
await _registrationRepository.UpdateAsync(registration);
return MedicalRecordResponse.FromRegistration(registration);
}
/// <summary>
/// Approves or rejects a ficha médica, blocking approval unless the ficha points at a stored file.
/// </summary>
public async Task<MedicalRecordResponse> ReviewAsync(
Guid playerId, Guid teamId, Guid tournamentId, bool approve, string? reason, string actor)
{
PlayerTeamRegistration registration = await GetRegistrationAsync(playerId, teamId, tournamentId);
// A ficha can only be approved against a file that is actually stored; refs under the legacy medical-records prefix point into the old public bucket and no longer resolve, so they do not count as stored, though rejecting with no file stays legal since only the approve transition is guarded.
if (approve && !PlayerTeamRegistration.IsStoredReference(registration.MedicalRecordFileUrl))
{
throw new InvalidOperationException(ErrorMessages.MedicalRecord.NoStoredFile);
}
registration.MedicalRecordStatus = approve ? MedicalRecordStatus.Approved : MedicalRecordStatus.Rejected;
registration.MedicalRecordReviewReason = approve ? null : reason;
registration.MedicalRecordReviewedAt = DateTime.UtcNow;
Touch(registration, actor);
await _registrationRepository.UpdateAsync(registration);
return MedicalRecordResponse.FromRegistration(registration);
}
public async Task<MedicalRecordResponse?> GetAsync(Guid playerId, Guid teamId, Guid tournamentId)
{
PlayerTeamRegistration? registration = await FindRegistrationAsync(playerId, teamId, tournamentId);
return registration is null ? null : MedicalRecordResponse.FromRegistration(registration);
}
private async Task<PlayerTeamRegistration> GetRegistrationAsync(Guid playerId, Guid teamId, Guid tournamentId)
{
return await FindRegistrationAsync(playerId, teamId, tournamentId)
?? throw new InvalidOperationException(
ErrorMessages.MedicalRecord.RegistrationNotFound(playerId, teamId, tournamentId));
}
private async Task<PlayerTeamRegistration?> FindRegistrationAsync(Guid playerId, Guid teamId, Guid tournamentId)
{
return (await _registrationRepository.FindAsync(
registration => registration.PlayerId == playerId
&& registration.TeamId == teamId
&& registration.TournamentId == tournamentId))
.FirstOrDefault();
}
private static void Touch(PlayerTeamRegistration registration, string actor)
{
registration.DateUpdated = DateTime.UtcNow;
registration.UpdatedBy = string.IsNullOrWhiteSpace(actor) ? registration.UpdatedBy : actor;
}
}
@@ -0,0 +1,298 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.PlayerSanction.Request;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Extensions;
using Application.Utils.Helper.Slug;
using Domain.Entities.Models;
using Domain.Enums;
using LinqKit;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Manages sanctions issued to a player, team, or staff member, identified by SanctionSubjectType.
/// </summary>
public class PlayerSanctionService(IUnitOfWork unitOfWork) : IPlayerSanctionService
{
private readonly IPlayerSanctionRepository _playerSanctionRepository = unitOfWork.PlayerSanctionRepository;
private readonly IPlayerRepository _playerRepository = unitOfWork.PlayerRepository;
private readonly IMatchRepository _matchRepository = unitOfWork.MatchRepository;
private readonly ITeamRepository _teamRepository = unitOfWork.TeamRepository;
/// <summary>
/// Creates a sanction and assigns it a unique slug derived from its subject's resolved name and issue date.
/// </summary>
public async Task<PlayerSanction> CreatePlayerSanctionAsync(PlayerSanction playerSanctionEntity)
{
string subjectName = await ResolveSubjectNameAsync(playerSanctionEntity);
playerSanctionEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync(
$"{subjectName} {playerSanctionEntity.IssuedDate:yyyy-MM-dd}",
candidate => _playerSanctionRepository.ExistsAsync(sanction => sanction.Slug == candidate));
await _playerSanctionRepository.AddAsync(playerSanctionEntity);
return playerSanctionEntity;
}
/// <summary>
/// Resolves the human-readable name of a sanction's subject, for a sensible slug.
/// </summary>
private async Task<string> ResolveSubjectNameAsync(PlayerSanction sanction)
{
switch (sanction.SubjectType)
{
case SanctionSubjectType.Team:
if (sanction.TeamId.HasValue)
{
Team? team = await _teamRepository.GetByIdAsync(sanction.TeamId.Value);
return team?.Name ?? "equipo";
}
return "equipo";
case SanctionSubjectType.Staff:
return string.IsNullOrWhiteSpace(sanction.StaffName) ? "staff" : sanction.StaffName;
// SanctionSubjectType.Player and any future/default value.
default:
if (sanction.PlayerId.HasValue)
{
Player? player = await _playerRepository.GetByIdAsync(sanction.PlayerId.Value);
return player?.FullName ?? "jugador";
}
return "jugador";
}
}
/// <summary>
/// Computes how many fechas of the sanction are still to be served.
/// </summary>
/// <returns>
/// The number of fechas remaining, or null when it cannot be computed by rounds: a staff
/// sanction, an unknown team, or a match with no round.
/// </returns>
public async Task<int?> GetFechasRemainingAsync(PlayerSanction sanction)
{
if (sanction.AppealStatus == SanctionAppealStatus.Accepted)
{
return 0;
}
Guid? teamId = await ResolveSubjectTeamIdAsync(sanction);
if (!teamId.HasValue)
{
return null;
}
Match? sanctionMatch = await _matchRepository.GetByIdAsync(sanction.MatchId);
if (sanctionMatch?.Round is not int sanctionRound)
{
return null;
}
Guid teamValue = teamId.Value;
Guid stageId = sanctionMatch.StageId;
int roundsServed = await _matchRepository.CountAsync(match =>
match.StageId == stageId
&& match.IsFinished
&& match.Round != null
&& match.Round > sanctionRound
&& (match.HomeTeamId == teamValue || match.VisitorTeamId == teamValue));
return Math.Max(0, sanction.Duration - roundsServed);
}
/// <summary>
/// Determines whether a player currently has any active sanction with fechas still to be served.
/// </summary>
public async Task<bool> HasActiveSanctionAsync(Guid playerId)
{
IEnumerable<PlayerSanction> sanctions = await _playerSanctionRepository.FindAsync(
sanction => sanction.PlayerId == playerId
&& sanction.SubjectType == SanctionSubjectType.Player,
includes: [sanction => sanction.Match]);
foreach (PlayerSanction sanction in sanctions)
{
int? remaining = await GetFechasRemainingAsync(sanction);
// A sanction whose fechas cannot be computed by rounds still counts as active while its duration is positive; the day-based sweep cleans it up later.
bool active = remaining.HasValue ? remaining.Value > 0 : sanction.Duration > 0;
if (active)
{
return true;
}
}
return false;
}
/// <summary>
/// Resolves the team whose rounds a sanction is served against, or none for a staff sanction.
/// </summary>
private async Task<Guid?> ResolveSubjectTeamIdAsync(PlayerSanction sanction)
{
switch (sanction.SubjectType)
{
case SanctionSubjectType.Team:
return sanction.TeamId;
case SanctionSubjectType.Player:
if (sanction.Player is not null)
{
return sanction.Player.TeamId;
}
if (sanction.PlayerId.HasValue)
{
Player? player = await _playerRepository.GetByIdAsync(sanction.PlayerId.Value);
return player?.TeamId;
}
return null;
case SanctionSubjectType.Staff:
default:
return null;
}
}
/// <inheritdoc />
public async Task<(string? PlayerFullName, string? TeamName, string? StaffName)> ResolveSubjectAsync(PlayerSanction sanction)
{
switch (sanction.SubjectType)
{
case SanctionSubjectType.Team:
string? teamName = sanction.Team?.Name;
if (teamName is null && sanction.TeamId.HasValue)
{
Team? team = await _teamRepository.GetByIdAsync(sanction.TeamId.Value);
teamName = team?.Name;
}
return (null, teamName, null);
case SanctionSubjectType.Staff:
return (null, null, sanction.StaffName);
// SanctionSubjectType.Player and any future/default value.
default:
string? playerFullName = sanction.Player?.FullName;
if (playerFullName is null && sanction.PlayerId.HasValue)
{
Player? player = await _playerRepository.GetByIdAsync(sanction.PlayerId.Value);
playerFullName = player?.FullName;
}
return (playerFullName, null, null);
}
}
public async Task<PlayerSanction?> GetPlayerSanctionByIdAsync(Guid playerSanctionId)
{
return await _playerSanctionRepository.GetByIdAsync(playerSanctionId);
}
/// <summary>
/// Retrieves a player sanction by id or slug, auto-detecting which one was passed via GUID parsing.
/// </summary>
/// <param name="idOrSlug">The sanction's GUID id or its slug.</param>
/// <returns>The matching player sanction, or null if not found.</returns>
public async Task<PlayerSanction?> GetPlayerSanctionByIdOrSlugAsync(string idOrSlug)
{
if (Guid.TryParse(idOrSlug, out Guid playerSanctionId))
{
return await GetPlayerSanctionByIdAsync(playerSanctionId);
}
IEnumerable<PlayerSanction> sanctions = await _playerSanctionRepository.FindAsync(
sanction => sanction.Slug == idOrSlug);
return sanctions.FirstOrDefault();
}
public async Task DeletePlayerSanctionAsync(Guid id)
{
await _playerSanctionRepository.RemoveAsync(playerSanction => playerSanction.Id == id);
}
public async Task UpdatePlayerSanctionAsync(PlayerSanction playerSanctionEntity)
{
await _playerSanctionRepository.UpdateAsync(playerSanctionEntity);
}
public async Task<IEnumerable<PlayerSanction>> GetExpiredSanctionsAsync(DateTime date)
{
return await _playerSanctionRepository.FindAsync(
playerSanction => playerSanction.IssuedDate.AddDays(playerSanction.Duration) <= date,
includes: [playerSanction => playerSanction.Player!]);
}
/// <summary>
/// Retrieves a filtered, paginated list of sanctions with free-text and team matching.
/// </summary>
public async Task<PaginatedResponse<PlayerSanction>> GetPlayerSanctionsAsync(GetPlayerSanctionsFilteredRequest filter)
{
// Description is suppressed from the auto-generated filter so free-text search can also match the sanctioned player's name; accent-insensitivity is intentionally skipped since the required Postgres extension is not enabled and the SQLite test provider could not translate it.
Expression<Func<PlayerSanction, bool>> expression = QueryableExtensions.ConstructFilterExpression<PlayerSanction, GetPlayerSanctionsFilteredRequest>(
filter, nameof(GetPlayerSanctionsFilteredRequest.Description));
if (!string.IsNullOrWhiteSpace(filter.Description))
{
string searchTerm = filter.Description.ToLower();
expression = expression.And(playerSanction =>
playerSanction.Description.ToLower().Contains(searchTerm)
|| (playerSanction.Player != null
&& (playerSanction.Player.FirstName.ToLower().Contains(searchTerm)
|| (playerSanction.Player.SecondName != null
&& playerSanction.Player.SecondName.ToLower().Contains(searchTerm))
|| playerSanction.Player.LastName.ToLower().Contains(searchTerm))));
}
if (filter.TournamentId.HasValue)
{
expression = expression.And(playerSanction => playerSanction.Match.Stage != null
&& playerSanction.Match.Stage.Division != null
&& playerSanction.Match.Stage.Division.TournamentId == filter.TournamentId.Value);
}
if (filter.DivisionId.HasValue)
{
expression = expression.And(playerSanction => playerSanction.Match.Stage != null
&& playerSanction.Match.Stage.DivisionId == filter.DivisionId.Value);
}
if (filter.StageId.HasValue)
{
expression = expression.And(playerSanction => playerSanction.Match.Stage != null
&& playerSanction.Match.Stage.Id == filter.StageId.Value);
}
if (filter.TeamId.HasValue)
{
// Matches both a player sanction whose player belongs to the team and a team-subject sanction targeting the team directly.
expression = expression.And(playerSanction =>
(playerSanction.Player != null && playerSanction.Player.TeamId == filter.TeamId.Value)
|| playerSanction.TeamId == filter.TeamId.Value);
}
IEnumerable<PlayerSanction> filteredSanctions = await _playerSanctionRepository.FindAsync(expression,
filter: filter,
includes: [playerSanction => playerSanction.Player!, playerSanction => playerSanction.Match]);
int totalCount = await _playerSanctionRepository.CountAsync(expression);
return new PaginatedResponse<PlayerSanction>
{
Page = filter.PageNumber,
PageSize = filter.PageSize,
TotalCount = totalCount,
Items = filteredSanctions
};
}
}
@@ -0,0 +1,267 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.Player.Request;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Application.Utils.Extensions;
using Application.Utils.Helper.Slug;
using Application.Utils.Options;
using Domain.Constants;
using Domain.Entities.Models;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Manages players and keeps Player.TeamId in sync with their season-scoped PlayerTeamRegistration.
/// </summary>
public class PlayerService(
IUnitOfWork unitOfWork,
IScorerRepository scorerRepository,
IOptions<RosterOptions> rosterOptions) : IPlayerService
{
private readonly IPlayerRepository _playerRepository = unitOfWork.PlayerRepository;
private readonly IPlayerTeamRegistrationRepository _registrationRepository = unitOfWork.PlayerTeamRegistrationRepository;
private readonly IPlayerStatisticRepository _statisticRepository = unitOfWork.PlayerStatisticRepository;
private readonly IPlayerSanctionRepository _sanctionRepository = unitOfWork.PlayerSanctionRepository;
private readonly IScorerRepository _scorerRepository = scorerRepository;
private readonly int _maxPlayersPerTeam = rosterOptions.Value.MaxPlayersPerTeam;
public async Task<Player> CreatePlayerAsync(Player playerEntity, Guid tournamentId)
{
await EnsureDocumentNumberIsUniqueAsync(playerEntity.DocumentNumber, excludingPlayerId: null);
playerEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync(
playerEntity.SlugSource,
candidate => _playerRepository.ExistsAsync(player => player.Slug == candidate));
await _playerRepository.AddAsync(playerEntity);
await EnsureRegistrationAsync(playerEntity, tournamentId);
return playerEntity;
}
/// <summary>
/// Guards the DB's IX_Players_DocumentNumber unique index with a friendly 409 instead of a raw 500.
/// </summary>
private async Task EnsureDocumentNumberIsUniqueAsync(string documentNumber, Guid? excludingPlayerId)
{
bool taken = await _playerRepository.ExistsAsync(player =>
player.DocumentNumber == documentNumber
&& (excludingPlayerId == null || player.Id != excludingPlayerId));
if (taken)
{
throw new InvalidOperationException(
ErrorMessages.Player.DuplicateDocumentNumber(documentNumber));
}
}
public async Task<Player?> GetPlayerByIdAsync(Guid playerId)
{
return await _playerRepository.GetByIdAsync(playerId);
}
/// <summary>
/// Retrieves a player by id or slug, auto-detecting which one was passed via GUID parsing.
/// </summary>
/// <param name="idOrSlug">The player's GUID id or its slug.</param>
/// <returns>The matching player, or null if not found.</returns>
public async Task<Player?> GetPlayerByIdOrSlugAsync(string idOrSlug)
{
if (Guid.TryParse(idOrSlug, out Guid playerId))
{
return await GetPlayerByIdAsync(playerId);
}
IEnumerable<Player> matches = await _playerRepository.FindAsync(player => player.Slug == idOrSlug);
return matches.FirstOrDefault();
}
public async Task DeletePlayerAsync(Guid id)
{
// A player who already has match statistics, scorer records, or sanctions is part of the tournament history, so deleting them would orphan those records and is blocked; otherwise their season registrations are cleaned up and the player is removed.
bool hasStatistics = await _statisticRepository.ExistsAsync(statistic => statistic.PlayerId == id);
bool hasScorers = await _scorerRepository.ExistsAsync(scorer => scorer.PlayerId == id);
bool hasSanctions = await _sanctionRepository.ExistsAsync(sanction => sanction.PlayerId == id);
if (hasStatistics || hasScorers || hasSanctions)
{
throw new InvalidOperationException(ErrorMessages.Player.HasHistoryCannotDelete);
}
await _registrationRepository.RemoveAsync(registration => registration.PlayerId == id);
await _playerRepository.RemoveAsync(player => player.Id == id);
}
/// <inheritdoc />
public async Task<PlayerTeamRegistration> RegisterPlayerToTeamAsync(
Guid playerId, Guid teamId, Guid tournamentId, int? jerseyNumber = null)
{
IEnumerable<PlayerTeamRegistration> existing = await _registrationRepository.FindAsync(
registration => registration.PlayerId == playerId && registration.TournamentId == tournamentId);
PlayerTeamRegistration? registration = existing.FirstOrDefault();
// A player cannot be registered to two teams in the same tournament.
if (registration is not null && registration.TeamId != teamId)
{
throw new InvalidOperationException(
ErrorMessages.Roster.PlayerAlreadyInAnotherTeam(playerId, tournamentId));
}
// Dorsal must be unique within the same team and tournament, ignoring this player's own current registration.
if (jerseyNumber is not null)
{
bool dorsalTaken = await _registrationRepository.ExistsAsync(candidate =>
candidate.TeamId == teamId
&& candidate.TournamentId == tournamentId
&& candidate.PlayerId != playerId
&& candidate.JerseyNumber == jerseyNumber);
if (dorsalTaken)
{
throw new InvalidOperationException(
ErrorMessages.Roster.DuplicateJerseyNumber(jerseyNumber.Value, teamId, tournamentId));
}
}
if (registration is null)
{
// Enforces the configurable roster-size cap only when adding a brand-new member, since re-registering an existing member does not grow the roster.
int currentRosterSize = await _registrationRepository.CountAsync(
candidate => candidate.TeamId == teamId && candidate.TournamentId == tournamentId);
if (currentRosterSize >= _maxPlayersPerTeam)
{
throw new InvalidOperationException(
ErrorMessages.Roster.RosterFull(teamId, _maxPlayersPerTeam));
}
PlayerTeamRegistration created = new()
{
Id = Guid.Empty,
PlayerId = playerId,
TeamId = teamId,
TournamentId = tournamentId,
JerseyNumber = jerseyNumber,
DateCreated = DateTime.UtcNow,
CreatedBy = AuditConstants.SystemUser,
};
await _registrationRepository.AddAsync(created);
return created;
}
// Same player, same team: keeps the dorsal in sync as an idempotent add or edit.
registration.JerseyNumber = jerseyNumber;
registration.DateUpdated = DateTime.UtcNow;
await _registrationRepository.UpdateAsync(registration);
return registration;
}
public async Task UpdatePlayerAsync(Player playerEntity, Guid tournamentId)
{
// Validates the roster move before persisting the Player row since this repository commits immediately with no shared transaction, so persisting first and then failing the move would leave Player.TeamId pointing at a team the player was never validly registered to for this season.
await EnsureDocumentNumberIsUniqueAsync(playerEntity.DocumentNumber, excludingPlayerId: playerEntity.Id);
await ValidateRegistrationMoveAsync(playerEntity, tournamentId);
await _playerRepository.UpdateAsync(playerEntity);
await EnsureRegistrationAsync(playerEntity, tournamentId);
}
/// <summary>
/// Mirrors RegisterPlayerToTeamAsync's roster-size cap for a mid-season team change.
/// </summary>
private async Task ValidateRegistrationMoveAsync(Player playerEntity, Guid tournamentId)
{
PlayerTeamRegistration? registration = (await _registrationRepository.FindAsync(
r => r.PlayerId == playerEntity.Id && r.TournamentId == tournamentId)).FirstOrDefault();
if (registration is null || registration.TeamId == playerEntity.TeamId)
{
return;
}
bool hasStatistics = await _statisticRepository.ExistsAsync(statistic => statistic.PlayerId == playerEntity.Id);
bool hasScorers = await _scorerRepository.ExistsAsync(scorer => scorer.PlayerId == playerEntity.Id);
bool hasSanctions = await _sanctionRepository.ExistsAsync(sanction => sanction.PlayerId == playerEntity.Id);
if (hasStatistics || hasScorers || hasSanctions)
{
throw new InvalidOperationException(ErrorMessages.Player.CannotMoveTeamWithHistory);
}
int destinationRosterSize = await _registrationRepository.CountAsync(
candidate => candidate.TeamId == playerEntity.TeamId
&& candidate.TournamentId == tournamentId
&& candidate.PlayerId != playerEntity.Id);
if (destinationRosterSize >= _maxPlayersPerTeam)
{
throw new InvalidOperationException(
ErrorMessages.Roster.RosterFull(playerEntity.TeamId, _maxPlayersPerTeam));
}
}
public async Task<PaginatedResponse<Player>> GetAllPlayersAsync(PlayerFilterRequestBase filter)
{
Expression<Func<Player, bool>> expression = QueryableExtensions.ConstructFilterExpression<Player, PlayerFilterRequestBase>(filter);
IEnumerable<Player> filteredPlayers = await _playerRepository.FindAsync(expression, filter: filter);
int totalCount = await _playerRepository.CountAsync(expression);
return new PaginatedResponse<Player>
{
Page = filter.PageNumber,
PageSize = filter.PageSize,
TotalCount = totalCount,
Items = filteredPlayers
};
}
/// <summary>
/// Ensures the player has exactly one PlayerTeamRegistration for tournamentId pointing at their current TeamId.
/// </summary>
private async Task EnsureRegistrationAsync(Player playerEntity, Guid tournamentId)
{
IEnumerable<PlayerTeamRegistration> existing = await _registrationRepository.FindAsync(
registration => registration.PlayerId == playerEntity.Id && registration.TournamentId == tournamentId);
PlayerTeamRegistration? registration = existing.FirstOrDefault();
if (registration is null)
{
await _registrationRepository.AddAsync(new PlayerTeamRegistration
{
Id = Guid.Empty,
PlayerId = playerEntity.Id,
TeamId = playerEntity.TeamId,
TournamentId = tournamentId,
DateCreated = DateTime.UtcNow,
CreatedBy = playerEntity.UpdatedBy ?? playerEntity.CreatedBy ?? AuditConstants.SystemUser,
});
return;
}
if (registration.TeamId != playerEntity.TeamId)
{
// Capacity was already validated by ValidateRegistrationMoveAsync before the Player row was persisted.
registration.TeamId = playerEntity.TeamId;
// The dorsal is unique per team, tournament, and jersey number, so carrying it over could collide with another player on the new team; it is reset instead, and the admin re-assigns one explicitly after the move.
registration.JerseyNumber = null;
registration.DateUpdated = DateTime.UtcNow;
registration.UpdatedBy = playerEntity.UpdatedBy ?? playerEntity.CreatedBy;
await _registrationRepository.UpdateAsync(registration);
}
}
}
@@ -0,0 +1,324 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.PlayerStatistic.Request;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Application.Utils.Extensions;
using Application.Utils.Helper.MatchResult;
using Application.Utils.Helper.Tournament;
using Domain.Constants;
using Domain.Entities.Models;
using Domain.Enums;
using LinqKit;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Records player statistics, most notably match scoring sheets that derive a team's final score.
/// </summary>
public class PlayerStatisticService(IUnitOfWork unitOfWork, IStageService stageService) : IPlayerStatisticService
{
private readonly IPlayerStatisticRepository _playerStatisticRepository = unitOfWork.PlayerStatisticRepository;
private readonly IMatchRepository _matchRepository = unitOfWork.MatchRepository;
private readonly IPlayerRepository _playerRepository = unitOfWork.PlayerRepository;
private readonly IPlayerTeamRegistrationRepository _playerTeamRegistrationRepository = unitOfWork.PlayerTeamRegistrationRepository;
public async Task<PlayerStatistic> CreatePlayerStatisticAsync(PlayerStatistic playerStatisticEntity)
{
await _playerStatisticRepository.AddAsync(playerStatisticEntity);
return playerStatisticEntity;
}
public async Task<PlayerStatistic?> GetPlayerStatisticByIdAsync(Guid playerStatisticId)
{
return await _playerStatisticRepository.GetByIdAsync(playerStatisticId);
}
public async Task DeletePlayerStatisticAsync(Guid id)
{
await _playerStatisticRepository.RemoveAsync(playerStatistic => playerStatistic.Id == id);
}
public async Task UpdatePlayerStatisticAsync(PlayerStatistic playerStatisticEntity)
{
await _playerStatisticRepository.UpdateAsync(playerStatisticEntity);
}
public async Task<PaginatedResponse<PlayerStatistic>> GetPlayerStatisticsAsync(GetPlayerStatisticsFilteredRequest filter)
{
Expression<Func<PlayerStatistic, bool>> expression =
QueryableExtensions.ConstructFilterExpression<PlayerStatistic, GetPlayerStatisticsFilteredRequest>(filter);
if (filter.TeamId.HasValue)
{
Expression<Func<PlayerStatistic, bool>> teamExpression =
playerStatistic => playerStatistic.Player != null && playerStatistic.Player.TeamId == filter.TeamId.Value;
expression = expression.And(teamExpression);
}
IEnumerable<PlayerStatistic> filteredStatistics = await _playerStatisticRepository.FindAsync(
expression,
includes: [playerStatistic => playerStatistic.Match!, playerStatistic => playerStatistic.Player!],
filter: filter);
int totalCount = await _playerStatisticRepository.CountAsync(expression);
return new PaginatedResponse<PlayerStatistic>
{
Page = filter.PageNumber,
PageSize = filter.PageSize,
TotalCount = totalCount,
Items = filteredStatistics
};
}
/// <summary>
/// Loads a whole team's scoring sheet for a match in one coherent operation.
/// </summary>
/// <param name="request">The match, team, and per-player points.</param>
/// <returns>The persisted Points statistics for the team in this match.</returns>
/// <exception cref="InvalidOperationException">Thrown when any coherence or eligibility check fails.</exception>
public async Task<List<PlayerStatistic>> LoadTeamMatchSheetAsync(LoadMatchSheetRequest request)
{
Match? match = await _matchRepository.GetByIdAsync(request.MatchId,
includes: [m => m.HomeTeam!, m => m.VisitorTeam!]);
if (match is null)
{
throw new InvalidOperationException(ErrorMessages.MatchSheet.MatchNotFinished(request.MatchId));
}
bool teamIsHome = match.HomeTeamId == request.TeamId;
bool teamIsVisitor = match.VisitorTeamId == request.TeamId;
if (!teamIsHome && !teamIsVisitor)
{
throw new InvalidOperationException(ErrorMessages.MatchSheet.TeamNotInMatch(request.TeamId));
}
int? teamScore = teamIsHome ? match.HomeScore : match.VisitorScore;
if (!match.IsFinished || teamScore is null)
{
throw new InvalidOperationException(ErrorMessages.MatchSheet.MatchNotFinished(request.MatchId));
}
int playersSum = request.Scores.Sum(entry => entry.Points);
if (playersSum != teamScore.Value)
{
throw new InvalidOperationException(ErrorMessages.MatchSheet.ScoreMismatch(teamScore.Value, playersSum));
}
Team teamEntity = (teamIsHome ? match.HomeTeam : match.VisitorTeam)
?? throw new InvalidOperationException(ErrorMessages.MatchSheet.TeamNotInMatch(request.TeamId));
await EnsureTeamMeetsHabilitadoMinimumAsync(teamEntity);
List<string> issues = await FindRosterEligibilityIssuesAsync(request.Scores, request.TeamId, teamEntity);
if (issues.Count > 0)
{
throw new InvalidOperationException(
ErrorMessages.MatchSheet.PlayersNotEligible([(teamEntity.Name, issues)]));
}
await ReplaceTeamPointsForMatchAsync(request.MatchId, request.TeamId);
List<PlayerStatistic> created = [.. request.Scores.Select(entry => new PlayerStatistic
{
MatchId = request.MatchId,
PlayerId = entry.PlayerId,
Value = entry.Points,
Type = StatisticType.Points,
CreatedBy = teamEntity.UpdatedBy ?? teamEntity.CreatedBy ?? AuditConstants.SystemUser,
})];
if (created.Count > 0)
{
await _playerStatisticRepository.AddRangeAsync(created);
}
return created;
}
/// <summary>
/// Finishes a match by loading both teams' scoring sheets in one coherent operation.
/// </summary>
/// <param name="request">The match and both teams' per-player points.</param>
/// <returns>The finalized match with score, winner, and IsFinished all set, or null if no match with that id exists.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when a player is ineligible/off the roster or the resulting
/// score is tied.
/// </exception>
public async Task<Match?> LoadMatchResultFromSheetsAsync(LoadMatchResultFromSheetsRequest request)
{
Match? match = await _matchRepository.GetByIdAsync(request.MatchId,
includes: [m => m.HomeTeam!, m => m.VisitorTeam!, m => m.Stage]);
if (match is null)
{
return null;
}
Team homeTeam = match.HomeTeam
?? throw new InvalidOperationException(ErrorMessages.MatchSheet.MatchMissingTeams(request.MatchId));
Team visitorTeam = match.VisitorTeam
?? throw new InvalidOperationException(ErrorMessages.MatchSheet.MatchMissingTeams(request.MatchId));
await EnsureTeamMeetsHabilitadoMinimumAsync(homeTeam);
await EnsureTeamMeetsHabilitadoMinimumAsync(visitorTeam);
List<string> homeIssues = await FindRosterEligibilityIssuesAsync(request.HomeScores, homeTeam.Id, homeTeam);
List<string> visitorIssues = await FindRosterEligibilityIssuesAsync(request.VisitorScores, visitorTeam.Id, visitorTeam);
if (homeIssues.Count > 0 || visitorIssues.Count > 0)
{
List<(string TeamName, List<string> Issues)> issuesByTeam = [];
if (homeIssues.Count > 0) issuesByTeam.Add((homeTeam.Name, homeIssues));
if (visitorIssues.Count > 0) issuesByTeam.Add((visitorTeam.Name, visitorIssues));
throw new InvalidOperationException(ErrorMessages.MatchSheet.PlayersNotEligible(issuesByTeam));
}
int homeScore = request.HomeScores.Sum(entry => entry.Points);
int visitorScore = request.VisitorScores.Sum(entry => entry.Points);
// Validated above and the tie-check below both throw before this line touches the database, so nothing is written on a rejected sheet.
MatchResultFinalizer.ApplyResult(match, homeScore, visitorScore);
match.WentToOvertime = request.WentToOvertime;
await _matchRepository.UpdateAsync(match);
await stageService.TryAutoSeedPlayoffPhaseAsync(match.StageId);
await ReplaceTeamPointsForMatchAsync(match.Id, homeTeam.Id);
await ReplaceTeamPointsForMatchAsync(match.Id, visitorTeam.Id);
List<PlayerStatistic> created = [
.. BuildPointsStatistics(match.Id, request.HomeScores, homeTeam),
.. BuildPointsStatistics(match.Id, request.VisitorScores, visitorTeam),
];
if (created.Count > 0)
{
await _playerStatisticRepository.AddRangeAsync(created);
}
return match;
}
private static List<PlayerStatistic> BuildPointsStatistics(Guid matchId, List<PlayerScoreEntry> scores, Team teamEntity) =>
[.. scores.Select(entry => new PlayerStatistic
{
MatchId = matchId,
PlayerId = entry.PlayerId,
Value = entry.Points,
Type = StatisticType.Points,
CreatedBy = teamEntity.UpdatedBy ?? teamEntity.CreatedBy ?? AuditConstants.SystemUser,
})];
/// <summary>
/// Enforces the walkover threshold: a team below MinPlayersPerTeam habilitado players must not record a normal result.
/// </summary>
private async Task EnsureTeamMeetsHabilitadoMinimumAsync(Team teamEntity)
{
if (teamEntity.TournamentId is null)
{
return;
}
Guid tournamentId = teamEntity.TournamentId.Value;
int habilitadoCount = (await _playerTeamRegistrationRepository.FindAsync(
registration => registration.TeamId == teamEntity.Id && registration.TournamentId == tournamentId))
.Count(registration => registration.IsHabilitado);
if (habilitadoCount < TournamentCompletabilityValidator.MinPlayersPerTeam)
{
throw new InvalidOperationException(
ErrorMessages.MatchSheet.TeamRequiresWalkOver(teamEntity.Name, habilitadoCount));
}
}
/// <summary>
/// Checks every listed player is registered and eligible, returning one reason per violation.
/// </summary>
private async Task<List<string>> FindRosterEligibilityIssuesAsync(
List<PlayerScoreEntry> scores, Guid teamId, Team teamEntity)
{
List<string> issues = [];
List<Guid> playerIds = [.. scores.Select(entry => entry.PlayerId).Distinct()];
if (playerIds.Count == 0)
{
return issues;
}
Dictionary<Guid, Player> playersById = (await _playerRepository.FindAsync(player => playerIds.Contains(player.Id)))
.ToDictionary(player => player.Id);
if (teamEntity.TournamentId is null)
{
foreach (Guid playerId in playerIds)
{
issues.Add(ErrorMessages.MatchSheet.PlayerNotOnRosterReason(PlayerLabel(playersById, playerId)));
}
return issues;
}
Guid tournamentId = teamEntity.TournamentId.Value;
Dictionary<Guid, PlayerTeamRegistration> registrationsByPlayer = (await _playerTeamRegistrationRepository.FindAsync(
registration => registration.TeamId == teamId
&& registration.TournamentId == tournamentId
&& playerIds.Contains(registration.PlayerId)))
.ToDictionary(registration => registration.PlayerId);
foreach (Guid playerId in playerIds)
{
string label = PlayerLabel(playersById, playerId);
if (!registrationsByPlayer.TryGetValue(playerId, out PlayerTeamRegistration? registration))
{
issues.Add(ErrorMessages.MatchSheet.PlayerNotOnRosterReason(label));
continue;
}
// Eligible only when not sanctioned and habilitado for this team and tournament: the medical record must be Approved and carry a real stored file, and a Pending or Rejected registration, including a brand-new season's which never inherits a prior approval, is not habilitado either.
bool sanctioned = !playersById.TryGetValue(playerId, out Player? player) || player.IsSanctioned;
if (sanctioned || !registration.IsHabilitado)
{
issues.Add(ErrorMessages.MatchSheet.PlayerNotEligibleReason(label));
}
}
return issues;
}
/// <summary>
/// A human-readable label for a player in an error message, falling back to the id if no player is found.
/// </summary>
private static string PlayerLabel(Dictionary<Guid, Player> playersById, Guid playerId) =>
playersById.TryGetValue(playerId, out Player? player) ? player.FullName : playerId.ToString();
/// <summary>
/// Removes the team's existing Points statistics for a match so a corrected sheet fully replaces the previous one.
/// </summary>
private async Task ReplaceTeamPointsForMatchAsync(Guid matchId, Guid teamId)
{
List<Guid> existingIds = [.. (await _playerStatisticRepository.FindAsync(
statistic => statistic.MatchId == matchId && statistic.Type == StatisticType.Points,
includes: [statistic => statistic.Player!]))
.Where(statistic => statistic.Player != null && statistic.Player.TeamId == teamId)
.Select(statistic => statistic.Id)];
if (existingIds.Count > 0)
{
await _playerStatisticRepository.RemoveAsync(statistic => existingIds.Contains(statistic.Id));
}
}
}
@@ -0,0 +1,64 @@
using Application.DTOs.Roster.Response;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Domain.Constants;
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Copies a roster from a previous season into a new season.
/// </summary>
public class RosterCopyService(IUnitOfWork unitOfWork) : IRosterCopyService
{
private readonly IPlayerTeamRegistrationRepository _registrationRepository = unitOfWork.PlayerTeamRegistrationRepository;
/// <inheritdoc />
public async Task<RosterCopyResult> CopyRosterAsync(
Guid sourceTeamId, Guid sourceTournamentId, Guid targetTeamId, Guid targetTournamentId)
{
List<PlayerTeamRegistration> sourceRegistrations = [.. await _registrationRepository.FindAsync(
registration => registration.TeamId == sourceTeamId
&& registration.TournamentId == sourceTournamentId)];
if (sourceRegistrations.Count == 0)
{
return new RosterCopyResult { CopiedCount = 0, SkippedCount = 0 };
}
// A player may hold at most one registration per tournament, enforced by a unique PlayerId and TournamentId index, so any source player already registered to the target season is skipped, which keeps the copy idempotent and prevents a two-teams-in-one-season violation.
HashSet<Guid> alreadyInTargetSeason = [.. (await _registrationRepository.FindAsync(
registration => registration.TournamentId == targetTournamentId))
.Select(registration => registration.PlayerId)];
List<PlayerTeamRegistration> newRegistrations = [.. sourceRegistrations
.Where(source => !alreadyInTargetSeason.Contains(source.PlayerId))
.Select(source => new PlayerTeamRegistration
{
Id = Guid.Empty,
PlayerId = source.PlayerId,
TeamId = targetTeamId,
TournamentId = targetTournamentId,
// JerseyNumber, MedicalRecordStatus, and all medical fields are intentionally not copied, so each season starts with a fresh, un-habilitado registration.
DateCreated = DateTime.UtcNow,
CreatedBy = AuditConstants.SystemUser,
})];
if (newRegistrations.Count > 0)
{
await _registrationRepository.AddRangeAsync(newRegistrations);
}
return new RosterCopyResult
{
CopiedCount = newRegistrations.Count,
SkippedCount = sourceRegistrations.Count - newRegistrations.Count,
};
}
}
@@ -0,0 +1,36 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.Match.Request;
using Application.DTOs.Scorer.Request;
using Application.DTOs.Scorer.Response;
using Application.Interfaces.Mappers;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Domain.Entities.Models;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Application.Services;
public class ScorerService(IScorerRepository scorerRepository, IMatchService matchService, IScorerMapper mapper) : IScorerService
{
public async Task<PaginatedResponse<ScorerByPlayerResponse>> GetAllScorersByPlayerAsync(GetScorerFilteredRequest filter)
{
(IEnumerable<ScorerByPlayerResponse> items, int totalCount) = await scorerRepository.GetPlayerScoresAsync(filter);
return new()
{
Page = filter.PageNumber,
PageSize = filter.PageSize,
TotalCount = totalCount,
Items = items
};
}
public async Task<PaginatedResponse<ScorerByTeamResponse>> GetAllScorersByTeamAsync(GetMatchesFilteredRequest filter)
{
PaginatedResponse<Match> matches = await matchService.GetAllMatchesAsync(filter);
return mapper.FromPaginatedMatchToPaginatedScorerByTeamResponse(matches);
}
}
@@ -0,0 +1,89 @@
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Helper.Slug;
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Manages seasons, the grouping above tournaments, deleting each one through its own tournaments.
/// </summary>
public class SeasonService(ISeasonRepository seasonRepository, ITournamentService tournamentService) : ISeasonService
{
public async Task<Season> CreateSeasonAsync(Season seasonEntity)
{
seasonEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync(
seasonEntity.Name,
candidate => seasonRepository.ExistsAsync(season => season.Slug == candidate));
await seasonRepository.AddAsync(seasonEntity);
return seasonEntity;
}
public async Task<Season?> GetSeasonByIdAsync(Guid seasonId)
{
return await seasonRepository.GetByIdAsync(seasonId, includes: [season => season.Tournaments]);
}
/// <summary>
/// Retrieves a season by id or slug, auto-detecting which one was passed via GUID parsing.
/// </summary>
/// <param name="idOrSlug">The season's GUID id or its slug.</param>
/// <returns>The matching season, or null if not found.</returns>
public async Task<Season?> GetSeasonByIdOrSlugAsync(string idOrSlug)
{
if (Guid.TryParse(idOrSlug, out Guid seasonId))
{
return await GetSeasonByIdAsync(seasonId);
}
IEnumerable<Season> matches = await seasonRepository.FindAsync(
season => season.Slug == idOrSlug,
includes: [season => season.Tournaments]);
return matches.FirstOrDefault();
}
/// <summary>
/// Deletes a season and every tournament it groups, one by one through the tournament service.
/// </summary>
public async Task DeleteSeasonAsync(Guid id)
{
Season? season = await seasonRepository.GetByIdAsync(id, includes: [s => s.Tournaments]);
if (season is null)
{
return;
}
foreach (Tournament tournament in season.Tournaments)
{
await tournamentService.DeleteTournamentAsync(tournament.Id);
}
await seasonRepository.RemoveAsync(s => s.Id == id);
}
public async Task UpdateSeasonAsync(Season seasonEntity)
{
await seasonRepository.UpdateAsync(seasonEntity);
}
public async Task<IEnumerable<Season>> GetAllSeasonsAsync()
{
IEnumerable<Season> seasons = await seasonRepository.FindAsync(
season => true,
includes: [season => season.Tournaments]);
// Ordered in memory, newest season first with null years last and Name as a deterministic tiebreak, since the list is small and unpaginated and passing a SortBy through FindAsync would also truncate it to the default page size.
return seasons
.OrderByDescending(season => season.Year.HasValue)
.ThenByDescending(season => season.Year)
.ThenBy(season => season.Name, StringComparer.OrdinalIgnoreCase);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Threading.Tasks;
namespace Application.Services;
public class StageTeamMatchService(IStageTeamMatchRepository stageTeamMatchRepository) : IStageTeamMatchService
{
public async Task<List<StageTeamMatch>> GetAllStageTeamMatchByStageId(Guid stageId)
{
return [.. await stageTeamMatchRepository.FindAsync(stm => stm.StageId == stageId)];
}
public async Task InsertRangeAsync(List<StageTeamMatch> stageTeamMatches)
{
await stageTeamMatchRepository.AddRangeAsync(stageTeamMatches);
}
public async Task RemoveWhereAsync(Expression<Func<StageTeamMatch, bool>> predicate)
{
await stageTeamMatchRepository.RemoveAsync(predicate);
}
/// <summary>
/// True only when every id in TeamIds has a matching assignment row for the stage.
/// </summary>
public async Task<bool> AllTeamsAssignedToStage(Guid stageId, List<Guid> TeamIds)
{
return await stageTeamMatchRepository.CountAsync(stm => stm.StageId == stageId
&& TeamIds.Contains(stm.TeamId)) == TeamIds.Count;
}
}
@@ -0,0 +1,20 @@
using Application.DTOs.Statistics.Response;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using System;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Thin application service over IStatisticsRepository for the historical-statistics reads.
/// </summary>
public class StatisticsService(IStatisticsRepository statisticsRepository) : IStatisticsService
{
public Task<PlayerStatisticCardResponse?> GetPlayerCardAsync(Guid playerId) =>
statisticsRepository.GetPlayerCardAsync(playerId);
public Task<PlayerHistoryResponse?> GetPlayerHistoryAsync(Guid playerId) =>
statisticsRepository.GetPlayerHistoryAsync(playerId);
}
@@ -0,0 +1,45 @@
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Service managing disciplinary point deductions for teams within a division.
/// </summary>
public class TeamPointDeductionService(
ITeamPointDeductionRepository deductionRepository) : ITeamPointDeductionService
{
/// <inheritdoc/>
public async Task<TeamPointDeduction> CreateAsync(TeamPointDeduction deduction)
{
await deductionRepository.AddAsync(deduction);
// Reload with the team navigation so the response can name the team.
return await deductionRepository.GetByIdAsync(
deduction.Id,
includes: [entity => entity.Team!]) ?? deduction;
}
/// <inheritdoc/>
public async Task<List<TeamPointDeduction>> GetByDivisionIdAsync(Guid divisionId)
{
IEnumerable<TeamPointDeduction> deductions = await deductionRepository.FindAsync(
deduction => deduction.DivisionId == divisionId,
includes: [deduction => deduction.Team!]);
return [.. deductions.OrderByDescending(deduction => deduction.DateCreated)];
}
/// <inheritdoc/>
public async Task DeleteAsync(Guid id)
{
await deductionRepository.RemoveAsync(deduction => deduction.Id == id);
}
}
@@ -0,0 +1,591 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.Team.Request;
using Application.DTOs.Team.Response;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Application.Utils.Extensions;
using Application.Utils.Helper.Slug;
using Application.Utils.Helper.Standings;
using Application.Utils.Helper.TeamProfile;
using Domain.Constants;
using Domain.Entities.Models;
using Domain.Enums;
using LinqKit;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Coordinates team lifecycle rules that don't belong to a single CRUD verb.
/// </summary>
public class TeamService(
IUnitOfWork unitOfWork,
IRosterCopyService rosterCopyService,
IDivisionService divisionService,
IClubService clubService,
ITeamPointDeductionRepository pointDeductionRepository,
IScorerRepository scorerRepository) : ITeamService
{
private readonly IUnitOfWork _unitOfWork = unitOfWork;
private readonly IRosterCopyService _rosterCopyService = rosterCopyService;
private readonly IDivisionService _divisionService = divisionService;
private readonly IClubService _clubService = clubService;
private readonly ITeamRepository _teamRepository = unitOfWork.TeamRepository;
private readonly IPlayerTeamRegistrationRepository _registrationRepository = unitOfWork.PlayerTeamRegistrationRepository;
private readonly ITeamTournamentRegistrationRepository _tournamentRegistrationRepository = unitOfWork.TeamTournamentRegistrationRepository;
private readonly IDivisionRepository _divisionRepository = unitOfWork.DivisionRepository;
private readonly IMatchRepository _matchRepository = unitOfWork.MatchRepository;
private readonly ITournamentRepository _tournamentRepository = unitOfWork.TournamentRepository;
private readonly IPlayerSanctionRepository _sanctionRepository = unitOfWork.PlayerSanctionRepository;
private readonly IPlayerStatisticRepository _statisticRepository = unitOfWork.PlayerStatisticRepository;
private readonly IScorerRepository _scorerRepository = scorerRepository;
private readonly ITeamPointDeductionRepository _pointDeductionRepository = pointDeductionRepository;
/// <summary>
/// Creates a team, generating a unique slug from its name, and eagerly links it into its club's history.
/// </summary>
/// <param name="teamEntity">The team entity to create.</param>
/// <returns>The created team entity.</returns>
public async Task<Team> CreateTeamAsync(Team teamEntity)
{
teamEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync(
teamEntity.Name,
candidate => _teamRepository.ExistsAsync(team => team.Slug == candidate));
await _teamRepository.AddAsync(teamEntity);
// Ensures the "Importar plantel de una temporada anterior" flow has a club history to search from day one, instead of only working after someone remembers to run the bulk backfill.
await _clubService.EnsureTeamLinkedToClubAsync(teamEntity);
return teamEntity;
}
/// <summary>
/// Retrieves a team by its unique identifier, with its Players roster scoped to one season.
/// </summary>
/// <param name="teamId">The unique identifier of the team.</param>
/// <param name="tournamentId">
/// The season whose roster to attach; defaults to the team's own current
/// TournamentId when omitted.
/// </param>
/// <returns>The team entity if found; otherwise, null.</returns>
public async Task<Team?> GetTeamByIdAsync(Guid teamId, Guid? tournamentId = null)
{
Team? team = await _teamRepository.GetByIdAsync(teamId);
if (team is null)
{
return null;
}
await AttachSeasonRostersAsync([team], tournamentId);
return team;
}
/// <summary>
/// Retrieves a team by id or slug with its Players roster scoped to one season.
/// </summary>
/// <param name="idOrSlug">The team's GUID id or its slug.</param>
/// <param name="tournamentId">
/// The season whose roster to attach; defaults to the team's own current
/// TournamentId when omitted.
/// </param>
/// <returns>The team entity if found; otherwise, null.</returns>
public async Task<Team?> GetTeamByIdOrSlugAsync(string idOrSlug, Guid? tournamentId = null)
{
if (Guid.TryParse(idOrSlug, out Guid teamId))
{
return await GetTeamByIdAsync(teamId, tournamentId);
}
IEnumerable<Team> matches = await _teamRepository.FindAsync(team => team.Slug == idOrSlug);
Team? team = matches.FirstOrDefault();
if (team is null)
{
return null;
}
await AttachSeasonRostersAsync([team], tournamentId);
return team;
}
/// <summary>
/// Deletes a team, blocking the delete whenever it carries history that would otherwise be orphaned.
/// </summary>
/// <param name="id">The unique identifier of the team to delete.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown as a 409 when the team has match history, sanctions, point deductions, or tournament
/// registrations.
/// </exception>
public async Task DeleteTeamAsync(Guid id)
{
bool hasMatchHistory = await _matchRepository.ExistsAsync(
match => match.HomeTeamId == id || match.VisitorTeamId == id || match.WinningTeamId == id);
bool hasSanctions = await _sanctionRepository.ExistsAsync(sanction => sanction.TeamId == id);
bool hasPointDeductions = await _pointDeductionRepository.ExistsAsync(deduction => deduction.TeamId == id);
bool hasTournamentRegistrations = await _tournamentRegistrationRepository.ExistsAsync(
registration => registration.TeamId == id);
// Team.Players cascades on delete, so hasSanctions alone would let a player's own statistics, scorer records, or sanctions be silently wiped out without triggering PlayerService.DeletePlayerAsync's history guard.
bool hasPlayersWithHistory =
await _statisticRepository.ExistsAsync(statistic => statistic.Player!.TeamId == id)
|| await _scorerRepository.ExistsAsync(scorer => scorer.Player!.TeamId == id)
|| await _sanctionRepository.ExistsAsync(sanction => sanction.Player!.TeamId == id);
if (hasMatchHistory || hasSanctions || hasPointDeductions || hasTournamentRegistrations || hasPlayersWithHistory)
{
throw new InvalidOperationException(ErrorMessages.Team.HasHistoryCannotDelete);
}
await _teamRepository.RemoveAsync(team => team.Id == id);
}
public async Task UpdateTeamAsync(Team teamEntity)
{
await _teamRepository.UpdateAsync(teamEntity);
}
/// <inheritdoc />
public async Task EnsureTeamIdentityEditableAsync(Team existingTeam, string? requestedName, string? requestedThreeLetterCode)
{
// Only a supplied request field can change identity, and the three-letter code is normalized to upper-case on the way in, so it is compared case-insensitively.
bool nameChanged = requestedName is not null
&& !string.Equals(requestedName, existingTeam.Name, StringComparison.Ordinal);
bool codeChanged = requestedThreeLetterCode is not null
&& !string.Equals(requestedThreeLetterCode, existingTeam.ThreeLetterCode, StringComparison.OrdinalIgnoreCase);
if (!nameChanged && !codeChanged)
{
return;
}
// Identity is only frozen while the team is actually in an Ongoing tournament, via its denormalized current-season pointer.
if (existingTeam.TournamentId is not Guid tournamentId)
{
return;
}
Tournament? tournament = await _tournamentRepository.GetByIdAsync(tournamentId);
if (tournament?.Status == TournamentStatus.Ongoing)
{
throw new InvalidOperationException(ErrorMessages.Team.IdentityFrozenWhileOngoing);
}
}
public async Task UpdateTeamsAsync(IEnumerable<Team> teams)
{
await _teamRepository.UpdateRangeAsync(teams);
}
/// <summary>
/// Retrieves a paginated list of teams filtered by stage and tournament, with players and stage team matches.
/// </summary>
/// <param name="filter">The filter criteria for retrieving teams.</param>
/// <returns>A paginated response containing the filtered teams.</returns>
public async Task<PaginatedResponse<Team>> GetAllTeamsAsync(GetTeamsFilteredRequest filter)
{
// TournamentId is suppressed from the auto-generated filter because a team's participation in a tournament is the TeamTournamentRegistration join, not the denormalized current-season pointer, so a team appears for every season it is registered in.
Expression<Func<Team, bool>> expression = QueryableExtensions.ConstructFilterExpression<Team, GetTeamsFilteredRequest>(
filter, nameof(GetTeamsFilteredRequest.TournamentId));
if (filter.StageId.HasValue)
{
Expression<Func<Team, bool>> stageExpression =
team => team.StageTeamMatches.Any(stm => stm.StageId == filter.StageId.Value);
expression = expression.And(stageExpression);
}
if (filter.TournamentId.HasValue)
{
Expression<Func<Team, bool>> tournamentExpression =
team => team.TeamTournamentRegistrations.Any(r => r.TournamentId == filter.TournamentId.Value);
expression = expression.And(tournamentExpression);
}
List<Team> filteredTeams = [.. await _teamRepository.FindAsync(
expression,
includes: [team => team.StageTeamMatches, team => team.Tournament!],
filter: filter,
asSplitQuery: true)];
await AttachSeasonRostersAsync(filteredTeams, filter.TournamentId);
int totalCount = await _teamRepository.CountAsync(expression);
return new PaginatedResponse<Team>
{
Page = filter.PageNumber,
PageSize = filter.PageSize,
TotalCount = totalCount,
Items = filteredTeams
};
}
/// <summary>
/// Populates each team's Players collection with the roster registered for one season.
/// </summary>
/// <param name="teams">The teams to attach a roster to, mutated in place.</param>
/// <param name="tournamentIdOverride">
/// When set, every team's roster is scoped to this season instead of its own current
/// TournamentId, used when the caller already filtered teams by a specific tournament, or
/// explicitly asked to view a past season.
/// </param>
private async Task AttachSeasonRostersAsync(List<Team> teams, Guid? tournamentIdOverride)
{
List<Guid> teamIds = [.. teams.Select(t => t.Id)];
if (teamIds.Count == 0)
{
return;
}
List<PlayerTeamRegistration> registrations = [.. await _registrationRepository.FindAsync(
registration => teamIds.Contains(registration.TeamId),
includes: [registration => registration.Player!])];
ILookup<Guid, PlayerTeamRegistration> registrationsByTeam = registrations.ToLookup(r => r.TeamId);
foreach (Team team in teams)
{
Guid? season = tournamentIdOverride ?? team.TournamentId;
team.Players = season is null
? []
: [.. registrationsByTeam[team.Id]
.Where(r => r.TournamentId == season.Value)
.Select(r =>
{
// Surface the season-scoped eligibility onto the roster player so responses expose habilitado or not-habilitado per player, without persisting it back.
r.Player!.MedicalRecordStatus = r.MedicalRecordStatus;
// The medical-record eligibility flag is a bool only, never the storage path; see Player.HasMedicalRecordFile.
r.Player!.HasMedicalRecordFile = PlayerTeamRegistration.IsStoredReference(r.MedicalRecordFileUrl);
// Surface the season-scoped dorsal onto the roster player without persisting it back.
r.Player!.JerseyNumber = r.JerseyNumber;
return r.Player!;
})];
}
}
/// <summary>
/// Reconciles a tournament's team roster by upserting TeamTournamentRegistration rows for this tournament only.
/// </summary>
/// <param name="tournament">The tournament entity to register teams to.</param>
/// <param name="teamIds">A list of team IDs to be registered in the tournament.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public async Task RegisterTeamsToTournamentAsync(Tournament tournament, List<Guid> teamIds)
{
// Structural guard: team registrations may only be added or removed while the tournament is OpenForRegistration, since once registration closes the roster is frozen.
if (tournament.Status != TournamentStatus.OpenForRegistration)
{
throw new InvalidOperationException(
ErrorMessages.Tournament.StructuralEditNotAllowed(tournament.Status));
}
HashSet<Guid> targetTeamIds = [.. teamIds];
List<TeamTournamentRegistration> existingRegistrations =
[.. await _tournamentRegistrationRepository.FindAsync(
registration => registration.TournamentId == tournament.Id)];
// Remove only this tournament's registrations for teams no longer in the list, scoped by registration Id so no other season is affected.
List<Guid> registrationIdsToRemove = [.. existingRegistrations
.Where(registration => !targetTeamIds.Contains(registration.TeamId))
.Select(registration => registration.Id)];
if (registrationIdsToRemove.Count > 0)
{
await _tournamentRegistrationRepository.RemoveAsync(
registration => registrationIdsToRemove.Contains(registration.Id));
}
// Add a registration for every listed team that does not already have one for this tournament, an upsert that leaves existing rows untouched.
HashSet<Guid> alreadyRegisteredTeamIds = [.. existingRegistrations.Select(registration => registration.TeamId)];
List<TeamTournamentRegistration> registrationsToAdd = [.. targetTeamIds
.Where(teamId => !alreadyRegisteredTeamIds.Contains(teamId))
.Select(teamId => new TeamTournamentRegistration
{
Id = Guid.Empty,
TeamId = teamId,
TournamentId = tournament.Id,
DateCreated = DateTime.UtcNow,
CreatedBy = tournament.UpdatedBy ?? tournament.CreatedBy ?? AuditConstants.SystemUser,
})];
if (registrationsToAdd.Count > 0)
{
await _tournamentRegistrationRepository.AddRangeAsync(registrationsToAdd);
}
// Keep the denormalized current-season pointer in sync: listed teams point at this tournament, dropped teams pointing here are cleared, and teams pointing elsewhere are left alone.
List<Team> affectedTeams = [.. await _teamRepository.FindAsync(team => teamIds.Contains(team.Id)
|| team.TournamentId == tournament.Id)];
foreach (Team team in affectedTeams)
{
team.TournamentId = targetTeamIds.Contains(team.Id) ? tournament.Id : null;
}
if (affectedTeams.Count > 0)
{
await _teamRepository.UpdateRangeAsync(affectedTeams);
}
}
/// <inheritdoc />
public async Task<Team> EnrollTeamAsync(
Tournament tournament,
Guid? existingTeamId,
string? newTeamName,
Guid? copyRosterFromTournamentId)
{
// Fail fast with the same rejection RegisterTeamsToTournamentAsync uses, mapped to 409, before doing any work.
if (tournament.Status != TournamentStatus.OpenForRegistration)
{
throw new InvalidOperationException(
ErrorMessages.Tournament.StructuralEditNotAllowed(tournament.Status));
}
Guid enrolledTeamId = Guid.Empty;
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
if (existingTeamId is Guid existing)
{
_ = await _teamRepository.GetByIdAsync(existing)
?? throw new KeyNotFoundException(ErrorMessages.Team.NotFound(existing));
// A team can be enrolled only once per tournament, and since RegisterTeamsToTournamentAsync is an idempotent upsert that would silently no-op on a duplicate, surface a clean conflict here instead, mapped to 409.
bool alreadyEnrolled = await _tournamentRegistrationRepository.ExistsAsync(
registration => registration.TeamId == existing
&& registration.TournamentId == tournament.Id);
if (alreadyEnrolled)
{
throw new InvalidOperationException(
ErrorMessages.Team.AlreadyEnrolled(existing, tournament.Id));
}
enrolledTeamId = existing;
}
else
{
Team created = await CreateTeamAsync(BuildNewTeam(newTeamName!));
enrolledTeamId = created.Id;
}
// Additive enroll: keep every team already registered to this tournament and add the new one, since passing only the new id would make RegisterTeamsToTournamentAsync unregister the rest of the roster.
List<Guid> targetTeamIds =
[
.. (await _tournamentRegistrationRepository.FindAsync(
registration => registration.TournamentId == tournament.Id))
.Select(registration => registration.TeamId),
enrolledTeamId,
];
await RegisterTeamsToTournamentAsync(tournament, targetTeamIds);
// Copy roster is only meaningful for an existing team; the caller guarantees copyRosterFromTournamentId is only set together with existingTeamId.
if (existingTeamId is Guid teamToCopyInto && copyRosterFromTournamentId is Guid sourceTournamentId)
{
await _rosterCopyService.CopyRosterAsync(
teamToCopyInto, sourceTournamentId, teamToCopyInto, tournament.Id);
}
});
// Reload with the roster scoped to THIS tournament for the response.
return (await GetTeamByIdAsync(enrolledTeamId, tournament.Id))!;
}
/// <inheritdoc />
public async Task UnenrollTeamAsync(Tournament tournament, Guid teamId)
{
// Roster-editing window guard: teams may only be removed before the tournament starts, while OpenForRegistration or RegistrationClosed, mapped to 409 by the global handler.
if (tournament.Status is not (TournamentStatus.OpenForRegistration or TournamentStatus.RegistrationClosed))
{
throw new InvalidOperationException(
ErrorMessages.Tournament.UnenrollNotAllowed(tournament.Status));
}
bool isEnrolled = await _tournamentRegistrationRepository.ExistsAsync(
registration => registration.TeamId == teamId && registration.TournamentId == tournament.Id);
if (!isEnrolled)
{
throw new KeyNotFoundException(ErrorMessages.Team.NotEnrolled(teamId, tournament.Id));
}
// Stage ids belonging to this tournament via its divisions, so only this tournament's assignments for the team are removed.
List<Guid> divisionIds = [.. (await _unitOfWork.DivisionRepository.FindAsync(
division => division.TournamentId == tournament.Id)).Select(division => division.Id)];
List<Guid> stageIds = divisionIds.Count == 0
? []
: [.. (await _unitOfWork.StageRepository.FindAsync(
stage => divisionIds.Contains(stage.DivisionId))).Select(stage => stage.Id)];
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
await _tournamentRegistrationRepository.RemoveAsync(
registration => registration.TeamId == teamId && registration.TournamentId == tournament.Id);
await _registrationRepository.RemoveAsync(
registration => registration.TeamId == teamId && registration.TournamentId == tournament.Id);
if (stageIds.Count > 0)
{
await _unitOfWork.StageTeamMatchRepository.RemoveAsync(
match => match.TeamId == teamId && stageIds.Contains(match.StageId));
}
// Clear the denormalized current-season pointer only when it points at this tournament; a team pointing at another season is untouched.
Team? team = await _teamRepository.GetByIdAsync(teamId);
if (team is not null && team.TournamentId == tournament.Id)
{
team.TournamentId = null;
await _teamRepository.UpdateAsync(team);
}
});
}
/// <summary>
/// Builds a minimal brand-new team from just a name, for the new-team enrollment path.
/// </summary>
private static Team BuildNewTeam(string name)
{
string alphanumeric = new([.. name.Where(char.IsLetterOrDigit)]);
string threeLetterCode = (alphanumeric.Length >= 3
? alphanumeric[..3]
: alphanumeric.PadRight(3, 'X')).ToUpperInvariant();
return new Team
{
Name = name,
Slug = string.Empty,
ThreeLetterCode = threeLetterCode,
LogoUrl = string.Empty,
ShirtColor = string.Empty,
TournamentId = null,
Players = [],
CreatedBy = AuditConstants.SystemUser,
};
}
/// <inheritdoc />
public async Task<TeamSummaryResponse?> GetTeamSummaryAsync(Guid teamId, Guid? tournamentId)
{
// No season context means there is no standing to report, since the team has no current tournament and none was requested.
if (tournamentId is null)
{
return null;
}
// Preferring the team's real division over a cross-division cup, and ordering cups last, ensures the first group table containing the team is its own zone whenever it has one.
List<Division> divisions = [.. (await _divisionRepository.FindAsync(
division => division.TournamentId == tournamentId.Value))
.OrderBy(division => division.IsCrossDivisionCup)];
foreach (Division division in divisions)
{
// Reuse the canonical per-group standings computation instead of recomputing it here, since a regular zone yields one group and a cross-division cup yields one per internal group.
List<GroupStandings> groups = await _divisionService.GetGroupStandingsByDivisionIdAsync(division.Id);
var located = groups
.Select(group => new
{
Group = group,
Index = group.Positions.FindIndex(position => position.TeamId == teamId),
})
.FirstOrDefault(candidate => candidate.Index >= 0);
if (located is not null)
{
Position row = located.Group.Positions[located.Index];
return new TeamSummaryResponse
{
DivisionId = division.Id,
DivisionName = division.Name,
Position = located.Index + 1,
TotalTeams = located.Group.Positions.Count,
Played = row.MatchesPlayed,
Wins = row.Wins,
Losses = row.Losses,
PointsFor = row.PointsFor,
PointsAgainst = row.PointsAgainst,
PointsDifference = row.PointsDifference,
Points = row.Points,
};
}
}
// The team plays in no group-stage table for this tournament, whether playoff-only, unassigned, or with no finished matches yet.
return null;
}
/// <inheritdoc />
public async Task<List<TeamMatchResponse>> GetTeamMatchesAsync(Guid teamId, Guid? tournamentId)
{
if (tournamentId is null)
{
return [];
}
// Queried directly rather than through the paginated match filter, which has no by-team predicate and would cap a tournament-wide read at one page.
List<Match> matches = [.. await _matchRepository.FindAsync(
match => (match.HomeTeamId == teamId || match.VisitorTeamId == teamId)
&& match.Stage.Division.TournamentId == tournamentId.Value,
includes: [match => match.HomeTeam!, match => match.VisitorTeam!, match => match.Venue!])];
return [.. matches
.OrderBy(match => match.MatchDate)
.Select(match => TeamMatchMapper.Map(match, teamId))];
}
/// <inheritdoc />
public async Task<List<TeamParticipationResponse>> GetTeamParticipationsAsync(Guid teamId, Guid? currentTournamentId)
{
List<TeamTournamentRegistration> registrations = [.. await _tournamentRegistrationRepository.FindAsync(
registration => registration.TeamId == teamId)];
if (registrations.Count == 0)
{
return [];
}
List<Guid> tournamentIds = [.. registrations.Select(registration => registration.TournamentId).Distinct()];
List<Tournament> tournaments = [.. await _tournamentRepository.FindAsync(
tournament => tournamentIds.Contains(tournament.Id),
includes: [tournament => tournament.Season!])];
return [.. tournaments
.Select(tournament => new TeamParticipationResponse
{
TournamentId = tournament.Id,
TournamentName = tournament.Name,
TournamentSlug = tournament.Slug,
Category = tournament.Category.ToString(),
SeasonId = tournament.SeasonId,
SeasonName = tournament.Season?.Name,
Year = tournament.Season?.Year,
IsCurrent = currentTournamentId.HasValue && tournament.Id == currentTournamentId.Value,
})
// Newest first: known years descending, participations without a season year last, ties broken by tournament name.
.OrderByDescending(participation => participation.Year.HasValue)
.ThenByDescending(participation => participation.Year)
.ThenBy(participation => participation.TournamentName, StringComparer.OrdinalIgnoreCase)];
}
}
@@ -0,0 +1,45 @@
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Service managing a team's technical staff for a given tournament and season.
/// </summary>
public class TeamStaffService(
ITeamStaffRepository staffRepository) : ITeamStaffService
{
/// <inheritdoc/>
public async Task<TeamStaff> CreateAsync(TeamStaff staff)
{
await staffRepository.AddAsync(staff);
// Reload with the team navigation so the response can name the team.
return await staffRepository.GetByIdAsync(
staff.Id,
includes: [entity => entity.Team!]) ?? staff;
}
/// <inheritdoc/>
public async Task<List<TeamStaff>> GetByTeamAndTournamentAsync(Guid teamId, Guid tournamentId)
{
IEnumerable<TeamStaff> staff = await staffRepository.FindAsync(
member => member.TeamId == teamId && member.TournamentId == tournamentId,
includes: [member => member.Team!]);
return [.. staff.OrderBy(member => member.DateCreated)];
}
/// <inheritdoc/>
public async Task DeleteAsync(Guid id)
{
await staffRepository.RemoveAsync(member => member.Id == id);
}
}
@@ -0,0 +1,517 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.Match.Request;
using Application.DTOs.Stage.Request;
using Application.DTOs.Tournament.Request;
using Application.DTOs.Tournament.Response;
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Application.Utils.Constants.Pagination;
using Application.Utils.Extensions;
using Application.Utils.Helper.Slug;
using Application.Utils.Helper.Tournament;
using Domain.Constants;
using Domain.Entities.Models;
using Domain.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Owns the tournament lifecycle: structural creation, status transitions, and their cascades.
/// </summary>
public class TournamentService(
ITournamentRepository tournamentRepository,
IStageService stageService,
IMatchService matchService,
IAuditService auditService,
IDivisionService divisionService,
IUnitOfWork unitOfWork) : ITournamentService
{
public async Task<Tournament> CreateTournamentAsync(Tournament tournamentEntity)
{
tournamentEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync(
tournamentEntity.Name,
candidate => tournamentRepository.ExistsAsync(tournament => tournament.Slug == candidate));
await tournamentRepository.AddAsync(tournamentEntity);
return tournamentEntity;
}
/// <inheritdoc/>
public async Task<Tournament> CreateFullTournamentAsync(CreateFullTournamentRequest request)
{
Tournament tournament = new()
{
Name = request.Name,
Description = request.Description,
Slug = null!,
TeamRegistrationDeadline = request.TeamRegistrationDeadline,
StartDate = request.StartDate,
Category = request.Category,
SeasonId = request.SeasonId,
Status = TournamentStatus.OpenForRegistration,
Divisions = [],
Teams = [],
CreatedBy = AuditConstants.SystemUser,
};
await unitOfWork.ExecuteInTransactionAsync(async () =>
{
await CreateTournamentAsync(tournament);
foreach (CreateFullDivisionRequest divisionRequest in request.Divisions)
{
await CreateDivisionWithStagesAsync(tournament, divisionRequest);
}
});
return await GetTournamentByIdAsync(tournament.Id) ?? tournament;
}
/// <inheritdoc/>
public async Task<Division> AddFullDivisionAsync(Tournament tournament, CreateFullDivisionRequest divisionRequest)
{
Division division = null!;
await unitOfWork.ExecuteInTransactionAsync(async () =>
{
division = await CreateDivisionWithStagesAsync(tournament, divisionRequest);
});
return division;
}
/// <summary>
/// Builds and persists one division and its stages from a CreateFullDivisionRequest.
/// </summary>
private async Task<Division> CreateDivisionWithStagesAsync(
Tournament tournament, CreateFullDivisionRequest divisionRequest)
{
Division division = new()
{
Name = divisionRequest.Name,
Slug = null!,
Tournament = tournament,
TournamentId = tournament.Id,
Category = divisionRequest.Category,
IsCrossDivisionCup = divisionRequest.IsCrossDivisionCup,
PointsForWin = divisionRequest.PointsForWin,
PointsForLoss = divisionRequest.PointsForLoss,
QualifiersPerGroup = divisionRequest.QualifiersPerGroup,
Stages = [],
CreatedBy = AuditConstants.SystemUser,
PlayoffMappings = (divisionRequest.PlayoffMappings ?? [])
.Select(mapping => new DivisionPlayoffMapping
{
FromPosition = mapping.FromPosition,
ToPosition = mapping.ToPosition,
Destination = mapping.Destination,
CreatedBy = AuditConstants.SystemUser,
})
.ToList(),
};
await divisionService.CreateDivisionAsync(division);
for (int i = 0; i < divisionRequest.Stages.Count; i++)
{
CreateFullStageRequest stageRequest = divisionRequest.Stages[i];
Stage stage = new()
{
Name = stageRequest.Name,
Slug = null!,
Description = stageRequest.Description,
StageType = stageRequest.StageType,
IsActive = stageRequest.IsActive ?? true,
IsElimination = stageRequest.IsElimination ?? (stageRequest.StageType != StageType.Group),
StartDate = stageRequest.StartDate,
EndDate = stageRequest.EndDate,
DivisionId = division.Id,
Division = division,
BracketName = stageRequest.BracketName,
BestOf = stageRequest.BestOf,
RoundRobinLegs = stageRequest.RoundRobinLegs,
Order = i,
Matches = [],
CreatedBy = AuditConstants.SystemUser,
};
await stageService.CreateStageAsync(stage);
}
return division;
}
public async Task<Tournament?> GetTournamentByIdAsync(Guid tournamentId)
{
return await tournamentRepository.GetByIdAsync(
tournamentId,
includes: [tournament => tournament.Divisions, tournament => tournament.Season!]);
}
/// <summary>
/// Retrieves a tournament by id or slug, auto-detecting which one was passed via GUID parsing.
/// </summary>
/// <param name="idOrSlug">The tournament's GUID id or its slug.</param>
/// <returns>The matching tournament, or null if not found.</returns>
public async Task<Tournament?> GetTournamentByIdOrSlugAsync(string idOrSlug)
{
if (Guid.TryParse(idOrSlug, out Guid tournamentId))
{
return await GetTournamentByIdAsync(tournamentId);
}
IEnumerable<Tournament> matches = await tournamentRepository.FindAsync(
tournament => tournament.Slug == idOrSlug,
includes: [tournament => tournament.Divisions, tournament => tournament.Season!]);
return matches.FirstOrDefault();
}
/// <inheritdoc/>
public async Task<Tournament?> GetTournamentStructureAsync(Guid tournamentId)
{
Tournament? tournament = await tournamentRepository.GetByIdAsync(tournamentId);
if (tournament is null)
{
return null;
}
// IGenericRepository's includes only support a single level of
// .Include(), so Tournament.Divisions.Stages cannot be expressed
// directly — reuses the same two-step load EvaluateCompletabilityAsync
// already relies on for the same reason.
List<Division> divisions = [.. await unitOfWork.DivisionRepository.FindAsync(
division => division.TournamentId == tournamentId,
includes: [division => division.Stages, division => division.PlayoffMappings],
asSplitQuery: true)];
tournament.Divisions = divisions;
return tournament;
}
/// <summary>
/// Deletes a tournament, blocking the delete once it has real history.
/// </summary>
/// <param name="id">The id of the tournament to delete.</param>
/// <exception cref="InvalidOperationException">
/// Thrown as a 409 when the tournament has already started or has played matches.
/// </exception>
public async Task DeleteTournamentAsync(Guid id)
{
Tournament? tournament = await tournamentRepository.GetByIdAsync(id);
// Nothing to delete, so keep the historical idempotent no-op behavior.
if (tournament is null)
{
return;
}
bool hasStarted = tournament.Status is TournamentStatus.Ongoing or TournamentStatus.Finished;
bool hasPlayedMatches = await unitOfWork.MatchRepository.ExistsAsync(
match => match.IsFinished && match.Stage.Division.TournamentId == id);
if (hasStarted || hasPlayedMatches)
{
throw new InvalidOperationException(ErrorMessages.Tournament.HasHistoryCannotDelete);
}
await unitOfWork.ExecuteInTransactionAsync(async () =>
{
// Clear the denormalized current-season pointer of any team pointing at this tournament before the cascade delete, since Team to Tournament is NoAction and a still-set pointer would abort the delete with an opaque FK error.
List<Team> pointingTeams = [.. await unitOfWork.TeamRepository.FindAsync(
team => team.TournamentId == id)];
foreach (Team team in pointingTeams)
{
team.TournamentId = null;
}
if (pointingTeams.Count > 0)
{
await unitOfWork.TeamRepository.UpdateRangeAsync(pointingTeams);
}
await tournamentRepository.RemoveAsync(tournament => tournament.Id == id);
});
}
public async Task UpdateTournamentAsync(Tournament tournamentEntity)
{
await tournamentRepository.UpdateAsync(tournamentEntity);
}
/// <summary>
/// Drives the tournament status state machine, applying whichever cascade the new status implies.
/// </summary>
/// <param name="tournamentId">The tournament to transition.</param>
/// <param name="newStatus">The status to transition to.</param>
/// <exception cref="KeyNotFoundException">Thrown when the tournament does not exist.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown as a 409 when the transition is invalid, or when moving to Ongoing while the
/// tournament fails the completability check.
/// </exception>
public async Task ChangeStatusAsync(Guid tournamentId, TournamentStatus newStatus)
{
Tournament tournament = await tournamentRepository.GetByIdAsync(
tournamentId, includes: [tournament => tournament.Divisions])
?? throw new KeyNotFoundException(ErrorMessages.Tournament.NotFound(tournamentId));
if (tournament.Status == newStatus)
{
return;
}
if (!TournamentStatusTransitions.IsValidTransition(tournament.Status, newStatus))
{
throw new InvalidOperationException(
ErrorMessages.Tournament.InvalidStatusTransition(tournament.Status, newStatus));
}
// Fixture generation runs before the status is committed, so a generation failure leaves the tournament in its prior editable status instead of stranding it in Ongoing with a half-built fixture.
if (newStatus == TournamentStatus.Ongoing)
{
// The completability guard runs on the loaded graph before generating any fixture, so a violation aborts the transition, mapped to 409, before any half-built fixture is left.
IReadOnlyList<CompletabilityIssue> issues = await EvaluateCompletabilityAsync(tournamentId);
if (issues.Count > 0)
{
throw new InvalidOperationException(
ErrorMessages.Tournament.NotCompletable(SummarizeIssues(issues)));
}
// Wrapped in a transaction so a failure partway through one division's stages rolls back every match already generated in this attempt too, leaving a clean slate to retry from.
await unitOfWork.ExecuteInTransactionAsync(() => GenerateFixtureAsync(tournament));
}
// Moving Ongoing to RegistrationClosed reopens the assignment phase, so the fixture generated at start is torn down while team-to-zone assignments are kept, letting the organizer re-start to rebuild the fixture.
if (tournament.Status == TournamentStatus.Ongoing
&& newStatus == TournamentStatus.RegistrationClosed)
{
await TeardownFixtureAsync(tournament);
}
// Canceling a tournament, or force-closing it as Finished while matches are still pending, must not leave still-to-be-played fixtures dangling under a dead tournament, so the leftover matches need their own terminal state too.
if (newStatus is TournamentStatus.Canceled or TournamentStatus.Finished)
{
await CancelPendingMatchesAsync(tournamentId);
}
TournamentStatus previousStatus = tournament.Status;
tournament.Status = newStatus;
await tournamentRepository.UpdateAsync(tournament);
// Record the sensitive status change for traceability.
await auditService.LogAsync(
AuditAction.TournamentStatusChange,
targetType: nameof(Tournament),
targetId: tournamentId.ToString(),
targetName: tournament.Name,
detail: $"{previousStatus.ToSpanishLabel()} → {newStatus.ToSpanishLabel()}");
}
/// <summary>
/// Generates the automated fixture for every stage of every division in the tournament.
/// </summary>
private async Task GenerateFixtureAsync(Tournament tournament)
{
foreach (Division division in tournament.Divisions)
{
PaginatedResponse<Stage> stages = await stageService.GetAllStagesAsync(new GetStagesFilteredRequest
{
DivisionId = division.Id,
PageSize = PaginationDefaults.MaxPageSize,
});
foreach (Guid stageId in stages.Items.Select(stage => stage.Id))
{
PaginatedResponse<Match> existingMatches = await matchService.GetAllMatchesAsync(new GetMatchesFilteredRequest
{
StageId = stageId,
PageSize = 1,
});
if (existingMatches.TotalCount > 0)
{
continue;
}
await matchService.CreateAutomatedMatchesAsync(stageId);
}
}
}
/// <summary>
/// Deletes every generated match and best-of-N series across the tournament, back to a fixture-less state.
/// </summary>
private async Task TeardownFixtureAsync(Tournament tournament)
{
List<Guid> stageIds = [];
foreach (Division division in tournament.Divisions)
{
PaginatedResponse<Stage> stages = await stageService.GetAllStagesAsync(new GetStagesFilteredRequest
{
DivisionId = division.Id,
PageSize = PaginationDefaults.MaxPageSize,
});
stageIds.AddRange(stages.Items.Select(stage => stage.Id));
}
if (stageIds.Count == 0)
{
return;
}
// Reverting is only safe while nothing has been played, since a finished match carries results and scorers that tearing down the fixture would lose.
int playedMatches = await unitOfWork.MatchRepository.CountAsync(
match => stageIds.Contains(match.StageId) && match.IsFinished);
if (playedMatches > 0)
{
throw new InvalidOperationException(ErrorMessages.Tournament.CannotRevertWithPlayedMatches);
}
// Matches are removed first, since they may reference a series, then the series.
await unitOfWork.MatchRepository.RemoveAsync(match => stageIds.Contains(match.StageId));
await unitOfWork.MatchSeriesRepository.RemoveAsync(series => stageIds.Contains(series.StageId));
}
/// <summary>
/// Marks every not-yet-finished match of a tournament as MatchStatus.Canceled.
/// </summary>
private async Task CancelPendingMatchesAsync(Guid tournamentId)
{
List<Match> pendingMatches = [.. await unitOfWork.MatchRepository.FindAsync(
match => !match.IsFinished
&& match.Status != MatchStatus.Canceled
&& match.Stage.Division.TournamentId == tournamentId)];
if (pendingMatches.Count == 0)
{
return;
}
foreach (Match match in pendingMatches)
{
match.Status = MatchStatus.Canceled;
}
await unitOfWork.MatchRepository.UpdateRangeAsync(pendingMatches);
}
/// <inheritdoc/>
public async Task<TournamentCompletabilityResponse> GetCompletabilityAsync(Guid tournamentId)
{
IReadOnlyList<CompletabilityIssue> issues = await EvaluateCompletabilityAsync(tournamentId);
return new TournamentCompletabilityResponse
{
CanStart = issues.Count == 0,
Issues = [.. issues],
};
}
/// <summary>
/// Loads the graph the completability validator needs and runs it against a throwaway tournament.
/// </summary>
private async Task<IReadOnlyList<CompletabilityIssue>> EvaluateCompletabilityAsync(Guid tournamentId)
{
bool exists = await tournamentRepository.ExistsAsync(tournament => tournament.Id == tournamentId);
if (!exists)
{
throw new KeyNotFoundException(ErrorMessages.Tournament.NotFound(tournamentId));
}
List<Division> divisions = [.. await unitOfWork.DivisionRepository.FindAsync(
division => division.TournamentId == tournamentId,
includes: [division => division.Stages, division => division.PlayoffMappings],
asSplitQuery: true)];
List<Guid> stageIds = [.. divisions.SelectMany(division => division.Stages).Select(stage => stage.Id)];
List<StageTeamMatch> stageTeamMatches = stageIds.Count == 0
? []
: [.. await unitOfWork.StageTeamMatchRepository.FindAsync(
match => stageIds.Contains(match.StageId),
includes: [match => match.Team!])];
ILookup<Guid, StageTeamMatch> matchesByStage = stageTeamMatches.ToLookup(match => match.StageId);
foreach (Stage stage in divisions.SelectMany(division => division.Stages))
{
stage.StageTeamMatches = [.. matchesByStage[stage.Id]];
}
List<TeamTournamentRegistration> registrations =
[.. await unitOfWork.TeamTournamentRegistrationRepository.FindAsync(
registration => registration.TournamentId == tournamentId,
includes: [registration => registration.Team!])];
// Only HABILITADO players count toward the minimum, per the owner's "no se puede arrancar torneos sin al menos 4 habilitados" rule, since a registration that is merely on the roster but Pending, Rejected, or Approved with no real stored file could never legally play.
Dictionary<Guid, int> habilitadoPlayerCountsByTeam = (await unitOfWork.PlayerTeamRegistrationRepository.FindAsync(
registration => registration.TournamentId == tournamentId))
.Where(registration => registration.IsHabilitado)
.GroupBy(registration => registration.TeamId)
.ToDictionary(group => group.Key, group => group.Count());
Tournament graph = new()
{
Name = string.Empty,
Description = string.Empty,
Slug = string.Empty,
TeamRegistrationDeadline = default,
StartDate = default,
Divisions = divisions,
Teams = [],
CreatedBy = AuditConstants.SystemUser,
};
return TournamentCompletabilityValidator.Validate(graph, registrations, habilitadoPlayerCountsByTeam);
}
/// <summary>
/// Renders the structured completability issues into a compact summary for the blocking exception.
/// </summary>
private static string SummarizeIssues(IReadOnlyList<CompletabilityIssue> issues)
{
return string.Join("; ", issues.Select(issue => issue.Code switch
{
CompletabilityIssueCodes.ZoneTooFewTeams =>
$"zone '{issue.DivisionName}' has {issue.AssignedTeams} team(s), needs at least {TournamentCompletabilityValidator.MinTeamsPerZone}",
CompletabilityIssueCodes.TeamNotAssigned =>
$"team '{issue.TeamName}' is not assigned to any zone",
CompletabilityIssueCodes.TeamInMultipleZones =>
$"team '{issue.TeamName}' is assigned to more than one zone",
CompletabilityIssueCodes.PlayoffRangeExceedsTeams =>
$"zone '{issue.DivisionName}' has a playoff range starting at position {issue.FromPosition} but only {issue.AssignedTeams} team(s) assigned",
CompletabilityIssueCodes.CrossCupGroupTooFewTeams =>
$"cross-cup '{issue.DivisionName}' group has {issue.AssignedTeams} team(s), needs at least {TournamentCompletabilityValidator.MinTeamsPerZone}",
CompletabilityIssueCodes.TeamTooFewPlayers =>
$"team '{issue.TeamName}' has {issue.PlayerCount} habilitado player(s), needs at least {TournamentCompletabilityValidator.MinPlayersPerTeam}",
_ => issue.Code,
}));
}
public async Task<PaginatedResponse<Tournament>> GetAllTournamentsAsync(GetTournamentsFilteredRequest filter)
{
Expression<Func<Tournament, bool>> expression = QueryableExtensions.ConstructFilterExpression<Tournament, GetTournamentsFilteredRequest>(filter);
IEnumerable<Tournament> filteredTournaments = await tournamentRepository.FindAsync(expression, filter: filter);
int totalCount = await tournamentRepository.CountAsync(expression);
return new PaginatedResponse<Tournament>
{
Page = filter.PageNumber,
PageSize = filter.PageSize,
TotalCount = totalCount,
Items = filteredTournaments
};
}
}
@@ -0,0 +1,80 @@
using Application.Interfaces.Repositories;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Application.Utils.Helper.Slug;
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Application.Services;
/// <summary>
/// Manages venues, blocking deletion while any match still references the venue.
/// </summary>
public class VenueService(IVenueRepository venueRepository, IMatchRepository matchRepository) : IVenueService
{
public async Task<Venue> CreateVenueAsync(Venue venueEntity)
{
venueEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync(
venueEntity.Name,
candidate => venueRepository.ExistsAsync(venue => venue.Slug == candidate));
await venueRepository.AddAsync(venueEntity);
return venueEntity;
}
public async Task<Venue?> GetVenueByIdAsync(Guid venueId)
{
return await venueRepository.GetByIdAsync(venueId);
}
/// <summary>
/// Retrieves a venue by id or slug, auto-detecting which one was passed via GUID parsing.
/// </summary>
/// <param name="idOrSlug">The venue's GUID id or its slug.</param>
/// <returns>The matching venue, or null if not found.</returns>
public async Task<Venue?> GetVenueByIdOrSlugAsync(string idOrSlug)
{
if (Guid.TryParse(idOrSlug, out Guid venueId))
{
return await GetVenueByIdAsync(venueId);
}
IEnumerable<Venue> matches = await venueRepository.FindAsync(venue => venue.Slug == idOrSlug);
return matches.FirstOrDefault();
}
/// <summary>
/// Deletes a venue. Blocked while any match still references it.
/// </summary>
/// <param name="id">The unique identifier of the venue to delete.</param>
/// <exception cref="InvalidOperationException">
/// Thrown as a 409 when the venue is referenced by one or more matches.
/// </exception>
public async Task DeleteVenueAsync(Guid id)
{
// Integrity: a venue referenced by one or more matches cannot be deleted, since that would leave those matches without their venue.
bool referencedByMatches = await matchRepository.ExistsAsync(match => match.VenueId == id);
if (referencedByMatches)
{
throw new InvalidOperationException(ErrorMessages.Venue.ReferencedByMatches);
}
await venueRepository.RemoveAsync(venue => venue.Id == id);
}
public async Task UpdateVenueAsync(Venue venueEntity)
{
await venueRepository.UpdateAsync(venueEntity);
}
public async Task<IEnumerable<Venue>> GetAllVenuesAsync()
{
return await venueRepository.FindAsync(venue => true);
}
}