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,12 @@
namespace Application.Utils.Constants.Auth;
/// <summary>
/// Application-specific JWT claim types that are not part of the standard ClaimTypes set.
/// </summary>
public static class CustomClaimTypes
{
/// <summary>
/// Boolean claim, stored as the string true or false, indicating the user must change their password before most endpoints.
/// </summary>
public const string MustChangePassword = "must_change_password";
}
@@ -0,0 +1,14 @@
namespace Application.Utils.Constants.Auth;
/// <summary>
/// Durations and sizes applied when issuing JWT access/refresh tokens.
/// </summary>
public static class TokenLifetime
{
public const int AccessTokenExpiryHours = 24;
/// <summary>
/// Size, in bytes, of the cryptographically random refresh token.
/// </summary>
public const int RefreshTokenByteLength = 64;
}
@@ -0,0 +1,54 @@
namespace Application.Utils.Constants.Configuration;
/// <summary>
/// Centralized configuration key paths read directly by Application and Infrastructure code.
/// </summary>
public static class ConfigurationKeys
{
/// <summary>
/// Name of the connection string entry under ConnectionStrings, used for both the application database and the ASP.NET Core Identity database.
/// </summary>
public const string DbConnection = "DbConnection";
public static class Jwt
{
public const string Key = "JWT:Key";
public const string Issuer = "JWT:Issuer";
public const string Audience = "JWT:Audience";
}
public static class Frontend
{
public const string PasswordResetUrl = "Frontend:PasswordResetUrl";
/// <summary>
/// Base URL of the account-activation page the invited user lands on from the invitation email.
/// </summary>
public const string ActivationUrl = "Frontend:ActivationUrl";
}
public static class AdminUser
{
public const string Email = "AdminUser:Email";
public const string Password = "AdminUser:Password";
}
public static class Backup
{
public const string PgDumpPath = "Backup:PgDumpPath";
public const string PsqlPath = "Backup:PsqlPath";
}
public static class Supabase
{
public const string Section = "SupaBase";
public const string ProjectUrl = "ProjectUrl";
public const string ServiceRole = "ServiceRole";
public const string BucketName = "BucketName";
/// <summary>
/// Name of the private Supabase bucket medical-record PDFs are stored in, separate from BucketName.
/// </summary>
public const string MedicalRecordsBucketName = "MedicalRecordsBucketName";
}
}
@@ -0,0 +1,608 @@
namespace Application.Utils.Constants;
/// <summary>
/// Centralized user-facing and exception message text, so a message never needs to be typed twice and a wording change happens in one place.
/// </summary>
public static class ErrorMessages
{
public static class Media
{
public const string InvalidImageFile = "El archivo de la foto debe ser una imagen JPEG/PNG válida.";
}
public static class Auth
{
public const string EmailAlreadyExists = "Ya existe un usuario con ese email.";
public const string InvalidCredentials = "Credenciales inválidas.";
public const string AccountDeactivated = "Esta cuenta está desactivada.";
public const string NoAccountForEmail = "No se encontró ninguna cuenta para ese email.";
public const string InvalidMagicLink = "El magic-link no es válido.";
public const string MagicLinkAlreadyUsed = "El magic-link no es válido o ya fue usado.";
public const string InvalidPasswordResetRequest = "Solicitud de restablecimiento de contraseña inválida.";
public const string InvalidRefreshToken = "El refresh token no es válido.";
public const string RefreshTokenExpired = "El refresh token venció. Iniciá sesión de nuevo.";
public const string RoleClaimMissing = "Falta el claim de rol en el token.";
public const string IdClaimMissing = "Falta el claim de id en el token.";
public const string AccessDenied = "Acceso denegado.";
public static string UserCreationFailed(string errors)
{
return $"No se pudo crear el usuario: {errors}";
}
public static string RoleNotAllowedToCreate(string callerRole, string targetRole)
{
return $"El rol '{callerRole}' no puede crear usuarios con rol '{targetRole}'.";
}
}
public static class User
{
public const string InsufficientPermissionsToListUsers = "No tenés permisos suficientes para listar usuarios.";
public const string CurrentPasswordRequired = "Debés indicar la contraseña actual para cambiar tu propia contraseña.";
public const string CannotChangeOwnActiveState = "No podés cambiar el estado activo de tu propia cuenta.";
public const string InsufficientPermissionsToDelete = "No tenés permisos suficientes para eliminar este usuario.";
public const string PasswordResetRestricted = "Solo Admins y Owners (para sus subordinados) pueden blanquear contraseñas.";
public const string CannotChangeOwnRole = "No podés cambiar tu propio rol.";
public const string InsufficientPermissionsToChangeRole = "No tenés permisos suficientes para cambiar el rol de este usuario.";
public static string NotFound(string userId)
{
return $"No se encontró el usuario '{userId}'.";
}
public static string InvalidRole(object role)
{
return $"'{role}' no es un rol válido.";
}
public static string RoleNotAllowedToAssign(string callerRole, string targetRole)
{
return $"El rol '{callerRole}' no puede asignar el rol '{targetRole}'.";
}
}
public static class Tournament
{
/// <summary>
/// Blocks reverting an Ongoing tournament that already has results.
/// </summary>
public const string CannotRevertWithPlayedMatches =
"No se puede revertir a borrador: el torneo ya tiene partidos jugados. " +
"Sólo se puede revertir un torneo en curso sin resultados cargados.";
public static string NotFound(System.Guid tournamentId)
{
return $"No existe un torneo con id: {tournamentId}.";
}
public static string InvalidStatusTransition(
Domain.Enums.TournamentStatus from, Domain.Enums.TournamentStatus to)
{
return $"No se puede cambiar el estado del torneo de '{from}' a '{to}'. " +
"El flujo permitido es Scheduled -> OpenForRegistration -> RegistrationClosed -> " +
"Ongoing -> Finished; un torneo puede pasar a Canceled desde cualquier estado no terminal, " +
"y Finished/Canceled son terminales.";
}
public static string StructuralEditNotAllowed(Domain.Enums.TournamentStatus status)
{
return "Los cambios estructurales (divisiones e inscripción de equipos) solo se permiten " +
$"mientras el torneo está OpenForRegistration; este torneo está '{status}'.";
}
public static string CategoryMismatch(
Domain.Enums.TournamentCategory divisionCategory,
Domain.Enums.TournamentCategory tournamentCategory)
{
return $"Una división '{divisionCategory}' no puede pertenecer a un torneo '{tournamentCategory}' " +
"(HU-48): la competencia femenina es un torneo aparte y no puede mezclarse con la masculina. " +
"Creá la división dentro de un torneo de la misma categoría.";
}
public static string NotCompletable(string issueSummaries)
{
return "No se puede iniciar el torneo porque no está completo en su estado actual (HU-109): " +
$"{issueSummaries}. Resolvé todos los problemas antes de iniciar.";
}
public static string UnenrollNotAllowed(Domain.Enums.TournamentStatus status)
{
return "Los equipos solo se pueden dar de baja mientras el torneo está OpenForRegistration o " +
$"RegistrationClosed; este torneo está '{status}'.";
}
/// <summary>
/// User-facing Spanish message blocking deletion of a tournament that already started or has played matches, to avoid erasing competitive history.
/// </summary>
public const string HasHistoryCannotDelete =
"No se puede eliminar el torneo: tiene partidos jugados o ya arrancó.";
}
public static class Division
{
/// <summary>
/// User-facing Spanish message blocking deletion of a division that already has played matches or point deductions, to avoid cascading away that competitive history.
/// </summary>
public const string HasHistoryCannotDelete =
"No se puede eliminar la división: tiene partidos jugados o deducciones de puntos registradas.";
/// <summary>
/// User-facing Spanish message blocking deletion of a division whose tournament fixture is already generated.
/// </summary>
public const string StructureLockedTournamentStarted =
"No se puede eliminar la división: el torneo ya arrancó o fue cancelado.";
public static string ConflictingRosterEnrollment(string teamIds)
{
return $"No se puede inscribir el/los equipo(s) {teamIds} en esta división: ya está(n) inscripto(s) en otra división del mismo torneo.";
}
}
public static class Team
{
public static string NotFound(System.Guid teamId)
{
return $"No existe un equipo con id: {teamId}.";
}
/// <summary>
/// User-facing Spanish message blocking deletion of a team that carries competitive history including match participations, sanctions, deductions or registrations.
/// </summary>
public const string HasHistoryCannotDelete =
"No se puede eliminar el equipo: tiene historial de partidos, sanciones, deducciones o inscripciones a torneos.";
/// <summary>
/// User-facing Spanish message blocking edits to Team.Name or Team.ThreeLetterCode while the team plays in an Ongoing tournament.
/// </summary>
public const string IdentityFrozenWhileOngoing =
"No se puede cambiar el nombre ni la sigla del equipo mientras participa en un torneo en curso. " +
"Podés editar los colores y el escudo.";
public static string NotInTournament(System.Guid teamId)
{
return $"El equipo '{teamId}' no está inscripto en ningún torneo actualmente, así que no se pueden registrar jugadores en él.";
}
public static string AlreadyEnrolled(System.Guid teamId, System.Guid tournamentId)
{
return $"El equipo '{teamId}' ya está inscripto en el torneo '{tournamentId}'. Un equipo solo puede inscribirse una vez por torneo.";
}
public static string NotEnrolled(System.Guid teamId, System.Guid tournamentId)
{
return $"El equipo '{teamId}' no está inscripto en el torneo '{tournamentId}'.";
}
}
public static class Club
{
public static string NotFound(System.Guid clubId)
{
return $"No existe un club con id: {clubId}.";
}
public const string CannotLinkToItself =
"Un club no puede vincularse a sí mismo como club matriz.";
public const string ParentAlreadyHasParent =
"El club matriz elegido ya es una escuadra de otro club; solo se admite un nivel de vinculación.";
public const string CannotBecomeChildWithExistingSquads =
"Este club ya tiene escuadras vinculadas, así que no puede pasar a ser una escuadra de otro club.";
public const string NameRequired =
"El nombre del club es obligatorio.";
public const string HasTeamsOrSquadsCannotDelete =
"No se puede eliminar un club con equipos o escuadras vinculadas.";
}
public static class Roster
{
public static string PlayerAlreadyInAnotherTeam(System.Guid playerId, System.Guid tournamentId)
{
return $"El jugador '{playerId}' ya está registrado en otro equipo del torneo '{tournamentId}'. Un jugador no puede estar en dos equipos del mismo torneo.";
}
public static string RosterFull(System.Guid teamId, int maxPlayers)
{
return $"El equipo '{teamId}' ya tiene el máximo de {maxPlayers} jugadores para este torneo.";
}
public static string DuplicateJerseyNumber(int jerseyNumber, System.Guid teamId, System.Guid tournamentId)
{
return $"El dorsal {jerseyNumber} ya lo usa otro jugador del equipo '{teamId}' en el torneo '{tournamentId}'.";
}
}
public static class Player
{
/// <summary>
/// User-facing Spanish message blocking deletion of a player who has match statistics, scorer records or sanctions, to avoid orphaning historical records.
/// </summary>
public const string HasHistoryCannotDelete =
"No se puede eliminar: el jugador tiene estadísticas o sanciones registradas.";
/// <summary>
/// User-facing Spanish message blocking a mid-season team move for a player who already has match statistics, scorer records or sanctions that season.
/// </summary>
public const string CannotMoveTeamWithHistory =
"No se puede cambiar de equipo a un jugador que ya tiene estadísticas o sanciones registradas en esta temporada.";
/// <summary>
/// User-facing Spanish message returned when a player is created or updated with a DocumentNumber another player already has.
/// </summary>
public static string DuplicateDocumentNumber(string documentNumber)
{
return $"Ya existe un jugador registrado con el documento {documentNumber}.";
}
}
public static class Venue
{
/// <summary>
/// User-facing Spanish message blocking deletion of a venue that is referenced by one or more matches.
/// </summary>
public const string ReferencedByMatches =
"No se puede eliminar: la cancha tiene partidos asociados.";
}
public static class Stage
{
public const string NotFoundGeneric = "No se encontró la fase.";
public const string DivisionNotFound = "No se encontró la división.";
public const string DivisionAlreadyHasStages = "No se puede procesar la solicitud porque la división ya tiene alguna fase.";
public const string GenerateMatchesBeforeSeeding = "Generá los partidos de esta fase antes de sembrarla.";
public const string AlreadySeeded = "Esta fase ya fue sembrada.";
public const string InvalidStageType = "Tipo de fase inválido.";
public const string SeedMissingStandings = "No se puede sembrar: todavía no todos los equipos asignados a esta fase tienen una posición de la fase de grupos finalizada.";
/// <summary>
/// User-facing Spanish message blocking adding or removing a stage once the division's tournament has already started.
/// </summary>
public const string StructureLockedTournamentStarted =
"No se pueden agregar o quitar fases: el torneo ya arrancó o fue cancelado.";
public static string NotFoundById(System.Guid id)
{
return $"No se encontró la fase con id {id}.";
}
public static string NotFoundById(string idOrSlug)
{
return $"No se encontró la fase con id o slug {idOrSlug}.";
}
public static string AlreadyExistsInDivision(string stageName)
{
return $"Ya existe una fase con el nombre '{stageName}' en la división actual.";
}
public static string MaxTeamsReached(int maxTeams)
{
return $"Esta fase ya tiene el máximo de {maxTeams} equipos.";
}
public static string NotEnoughSlots(int requested, int available)
{
return $"No se pueden agregar {requested} equipos. Solo hay {available} lugares disponibles.";
}
public static string InvalidTournamentSize(int registeredTeams, string validSizes)
{
return $"Cantidad inválida de equipos inscriptos: {registeredTeams}. Los tamaños válidos son {validSizes} equipos.";
}
public static string TeamsNotDivisibleForGroups(int registeredTeams, int groupSize)
{
return $"La cantidad de equipos inscriptos ({registeredTeams}) debe ser divisible por {groupSize} para generar las fases de grupos.";
}
public static string ConflictingTeamAssignment(string teamIds)
{
return $"No se puede asignar el/los equipo(s) {teamIds} a esta división: ya está(n) asignado(s) a otra división del mismo torneo.";
}
public static string TeamNotEnrolledInDivision(string teamIds)
{
return $"No se puede asignar el/los equipo(s) {teamIds} a esta fase: no está(n) inscripto(s) en el roster de la división.";
}
public static string SeedTeamCountOutOfRange(int assignedCount, int slotCapacity)
{
return $"No se puede sembrar: hay {assignedCount} equipo(s) asignado(s) a esta fase, se esperaba entre 2 y {slotCapacity}. " +
"Una cantidad de equipos menor al cuadro completo está bien (los mejores seeds pasan con bye), pero no puede superar los lugares generados.";
}
/// <summary>
/// User-facing Spanish message blocking a playoffs-only draw preview or commit on a division that still has a group phase.
/// </summary>
public const string DrawRequiresGrouplessDivision =
"El sorteo de llave es solo para divisiones sin fase de grupos; esta división ya tiene una fase de grupos.";
/// <summary>
/// User-facing Spanish message rejecting a draw commit whose token is missing, tampered, expired, or does not match the stage or roster.
/// </summary>
public const string InvalidDrawToken =
"El token del sorteo no es válido o no corresponde a esta fase. Volvé a previsualizar el sorteo.";
/// <summary>
/// User-facing Spanish message rejecting a manual seeding order that is not exactly a permutation of the division's enrolled roster.
/// </summary>
public const string ManualOrderNotRosterPermutation =
"El orden manual debe incluir exactamente a los equipos inscriptos en el roster de la división, sin repetir ni omitir ninguno.";
/// <summary>
/// User-facing Spanish message blocking a bracket draw or re-draw once a real match of that bracket has already been played.
/// </summary>
public const string BracketAlreadyPlayed =
"No se puede sortear ni resortear esta llave: ya hay al menos un partido jugado en este cuadro.";
/// <summary>
/// User-facing Spanish message rejecting a sub-group count below 1.
/// </summary>
public const string SubGroupCountMustBePositive =
"La cantidad de sub-grupos debe ser mayor o igual a 1.";
/// <summary>
/// User-facing Spanish message rejecting a manual reassignment for a team not currently placed in the source sub-group.
/// </summary>
public const string TeamNotPlacedInSubGroup =
"El equipo no está ubicado en el sub-grupo de origen indicado.";
/// <summary>
/// User-facing Spanish message rejecting a manual reassignment across two sub-groups of different divisions.
/// </summary>
public const string ReassignmentAcrossDivisionsNotAllowed =
"No se puede reasignar un equipo entre sub-grupos de distintas divisiones.";
public static string SubGroupTooFewTeams(int teamCount, int subGroupCount)
{
return $"No se pueden crear {subGroupCount} sub-grupos con {teamCount} equipo(s) inscripto(s): cada sub-grupo necesita como mínimo 4 equipos. Elegí una cantidad de sub-grupos menor.";
}
public static string SubGroupReassignmentBelowMinimum(int remainingTeams)
{
return $"No se puede mover el equipo: el sub-grupo de origen quedaría con {remainingTeams} equipo(s), por debajo del mínimo de 4.";
}
/// <summary>
/// User-facing Spanish message rejecting a division split into 2+ groups that has no cup to determine an overall champion between them.
/// </summary>
public const string SubGroupsRequireCupToDetermineChampion =
"Una división con 2 o más grupos necesita al menos una copa: sin ella no hay forma de determinar un campeón entre los grupos.";
}
public static class Match
{
public const string CannotUpdateStartedOrFinished = "No se puede editar un partido que ya arrancó o finalizó.";
public const string TeamsNotAssignedToStage = "No se puede actualizar la fecha del partido porque uno o ambos equipos no están asignados a la fase.";
public const string VenueScheduleConflict =
"Esa cancha ya tiene otro partido a menos de 2 horas de esa hora. " +
"Elegí otro horario (mínimo 2 horas de diferencia) u otra cancha.";
public const string StageAlreadyHasMatches = "No se puede procesar la solicitud porque la fase actual ya tiene partidos.";
public const string NoGroupStagesForDivision = "No se encontraron fases de grupos para la división.";
public const string NoTeamsRegistered = "No hay equipos inscriptos en el torneo.";
public const string NotEnoughTeamsPerGroup = "Se necesitan al menos 2 equipos por grupo para generar los partidos.";
public const string InvalidKnockoutStageType = "Tipo de fase eliminatoria inválido.";
public const string MatchCountMustBePositive = "La cantidad de partidos debe ser mayor a cero.";
public const string EndDateBeforeStartDate = "La fecha de fin debe ser posterior a la fecha de inicio.";
public const string StageTypeNotSupportedForAutomatedCreation = "Este tipo de fase no admite la generación automática de partidos.";
// Basketball has no draws — a played match must have a winner.
public const string GroupStageTieNotAllowed =
"Un partido de fase de grupos no puede terminar empatado; el básquet no tiene empates. Cargá un resultado con un ganador.";
public const string PlayoffTieNotAllowed =
"Un partido de playoff no puede terminar empatado; debe resolverse en un tiempo suplementario. Cargá el resultado final con un ganador.";
public const string WalkOverTeamNotInMatch =
"El equipo presente tiene que ser el local o el visitante de este partido.";
public static string TeamsNotDistributableAcrossGroups(int registeredTeams, int totalGroups)
{
return $"Los equipos inscriptos ({registeredTeams}) no se pueden distribuir de forma pareja entre {totalGroups} grupos.";
}
}
public static class MatchSheet
{
// The sum of a team's players' points must equal the team's final score.
public static string ScoreMismatch(int teamScore, int playersSum)
{
int difference = teamScore - playersSum;
return $"Los puntos de los jugadores no coinciden con el resultado del equipo: el equipo anotó {teamScore} " +
$"pero la suma de los jugadores cargados da {playersSum} (diferencia de {difference}). Corregí la planilla antes de guardar.";
}
public static string MatchNotFinished(System.Guid matchId)
{
return $"No se puede cargar la planilla del partido {matchId} porque todavía no tiene un resultado final cargado.";
}
public static string MatchMissingTeams(System.Guid matchId)
{
return $"El partido {matchId} todavía no tiene los dos equipos asignados, así que no se le puede cargar un resultado.";
}
public static string TeamNotInMatch(System.Guid teamId)
{
return $"El equipo {teamId} no jugó este partido, así que no se pueden cargar sus puntos acá.";
}
public static string PlayerNotOnRosterReason(string playerLabel)
{
return $"{playerLabel} (no está en el plantel del equipo para esta temporada)";
}
public static string PlayerNotEligibleReason(string playerLabel)
{
return $"{playerLabel} (no está habilitado: le falta la inscripción aprobada o tiene una sanción activa)";
}
/// <summary>
/// One combined error naming every ineligible or off-roster player across both teams' sheets, grouped by team.
/// </summary>
public static string PlayersNotEligible(
System.Collections.Generic.IEnumerable<(string TeamName, System.Collections.Generic.List<string> Reasons)> issuesByTeam)
{
System.Collections.Generic.List<string> groups = [];
foreach ((string teamName, System.Collections.Generic.List<string> reasons) in issuesByTeam)
{
groups.Add($"{teamName}: {string.Join(", ", reasons)}.");
}
return $"No se puede cargar el resultado porque hay jugadores no elegibles. {string.Join(" ", groups)}";
}
public static string TeamRequiresWalkOver(string teamName, int habilitadoCount)
{
return $"{teamName} tiene {habilitadoCount} jugador(es) habilitado(s) (mínimo 4), así que este partido no se puede " +
"cargar con un resultado normal. Cargalo como walkover.";
}
}
public static class MedicalRecord
{
public const string InvalidPdfFile = "El archivo de la ficha médica debe ser un PDF válido.";
/// <summary>
/// User-facing Spanish message returned when a new ficha upload is attempted on a registration whose medical record is already Approved.
/// </summary>
public const string AlreadyApproved =
"La ficha médica ya está aprobada; no se puede subir una nueva. Solo puede consultarse o descargarse.";
/// <summary>
/// User-facing Spanish message returned when an approve is attempted against a registration with no real stored file.
/// </summary>
public const string NoStoredFile =
"No se puede aprobar la ficha médica: no hay un archivo cargado. Subí la ficha antes de aprobarla.";
public static string RegistrationNotFound(
System.Guid playerId, System.Guid teamId, System.Guid tournamentId)
{
return $"El jugador {playerId} no tiene una inscripción al equipo {teamId} para el torneo " +
$"{tournamentId}, así que no se puede adjuntar ni revisar una ficha médica.";
}
}
public static class MatchSeries
{
public const string RequiresTwoDifferentTeams = "Una serie requiere dos equipos distintos.";
public const string AlreadyExistsForStage = "Ya existe una serie entre estos dos equipos para esta fase.";
public const string NotFound = "No se encontró la serie.";
public const string AlreadyDecided = "No se puede agregar un partido a una serie que ya se definió.";
public static string NotFoundById(System.Guid id)
{
return $"No se encontró la serie con id {id}.";
}
public static string MaxGamesReached(int bestOf)
{
return $"Esta serie ya tiene el máximo de {bestOf} partidos.";
}
public static string TeamNotAssignedToStage(System.Guid teamId)
{
return $"El equipo {teamId} no está asignado a esta fase.";
}
}
public static class PlayerSanction
{
public const string AppealAlreadyPending = "Esta sanción ya tiene una apelación pendiente.";
public const string NoPendingAppealToResolve = "No hay ninguna apelación pendiente para resolver en esta sanción.";
}
public static class Playoff
{
public const string NotEnoughRankedTeams = "Para sembrar se necesitan al menos dos equipos rankeados.";
public const string EmptyDestination = "Un rango de posiciones de playoff debe tener un destino no vacío.";
public const string NoMappingsConfigured = "Esta división no tiene ningún mapeo de rango de posiciones a playoff configurado.";
public static string InvalidRange(int from, int to)
{
return $"Rango de posiciones de playoff inválido {from}-{to}: las posiciones empiezan en 1 y 'desde' debe ser menor o igual a 'hasta'.";
}
public static string OverlappingRanges(int firstFrom, int firstTo, int secondFrom, int secondTo)
{
return $"Los rangos de posiciones de playoff {firstFrom}-{firstTo} y {secondFrom}-{secondTo} se superponen; cada posición debe mapear a un solo destino.";
}
public static string CupStageNotFound(string destination)
{
return $"No se encontró una fase eliminatoria de primera ronda sin sembrar para el destino de playoff '{destination}'.";
}
public static string InvalidQualifiersPerGroup(int qualifiersPerGroup)
{
return $"QualifiersPerGroup debe ser al menos 1, pero era {qualifiersPerGroup}.";
}
}
public static class Backup
{
public const string RetentionCountNegative = "Retention count cannot be negative.";
public const string OperationInProgress = "A backup or restore operation is already in progress.";
}
public static class Configuration
{
public const string ConnectionStringMissing = "The connection string should be initialized already.";
public const string JwtMissing = "The JWT is missing or empty in configuration.";
public static string SmtpKeyMissing(string key)
{
return $"{key} is missing from configuration.";
}
public static string KeyNotConfigured(string key)
{
return $"{key} is not configured.";
}
}
public static class Storage
{
public static string UploadFailed(string reason)
{
return $"Error uploading file: {reason}";
}
public static string DeleteFailed(string reason)
{
return $"Error deleting file: {reason}";
}
public static string ListFailed(string reason)
{
return $"Error listing files: {reason}";
}
public static string RemoveFailed(string reason)
{
return $"Error removing file: {reason}";
}
public static string DownloadFailed(string reason)
{
return $"Error downloading file: {reason}";
}
}
public static class Query
{
public const string ContainsMethodNotFound = "The 'Contains' method could not be found on the string class.";
public const string ToLowerMethodNotFound = "The 'ToLower' method could not be found on the string class.";
}
public static class Serialization
{
public static string InvalidDate(string? rawValue)
{
return $"Invalid date: '{rawValue}'.";
}
}
}
@@ -0,0 +1,14 @@
namespace Application.Utils.Constants.Pagination;
/// <summary>
/// Default and boundary values applied to paginated list requests when the caller does not specify a page size, or supplies an out-of-range one.
/// </summary>
public static class PaginationDefaults
{
public const int DefaultPageSize = 100;
/// <summary>
/// Maximum allowed page size; requests above this are clamped down to it.
/// </summary>
public const int MaxPageSize = 100;
}
@@ -0,0 +1,11 @@
namespace Application.Utils.Constants.Scorer;
/// <summary>
/// Points awarded per match outcome when computing the team scoreboard.
/// </summary>
public static class ScoreConstants
{
public const int PointsForWin = 2;
public const int PointsForLoss = 1;
public const int PointsForDraw = 1;
}
@@ -0,0 +1,11 @@
namespace Application.Utils.Constants.Stage;
/// <summary>
/// Provides constant values representing the number of matches generated for each knockout-stage type during automated match creation.
/// </summary>
public static class KnockoutMatchCount
{
public const int QuarterFinal = 4;
public const int SemiFinal = 2;
}
@@ -0,0 +1,22 @@
namespace Application.Utils.Constants.Stage;
public static class MaxTeams
{
/// <summary>
/// Standard number of teams per group used to partition a division's registered teams into same-sized Group stages.
/// </summary>
public const int Group = 4;
/// <summary>
/// Upper bound on how many teams a single manually-built Group stage, one zone's whole round-robin phase, may hold.
/// </summary>
public const int GroupStageCap = 32;
public const int QuarterFinal = 8;
public const int SemiFinal = 4;
public const int ThirdPlace = 2;
public const int Final = 2;
}
@@ -0,0 +1,17 @@
namespace Application.Utils.Constants.Stage;
/// <summary>
/// Bounds for how many times each pair of teams plays within a single group stage, RoundRobinLegs.
/// </summary>
public static class RoundRobinFormat
{
/// <summary>
/// Single round-robin: every pair plays once.
/// </summary>
public const int MinLegs = 1;
/// <summary>
/// Upper bound to keep fixture size sane, a triple round-robin.
/// </summary>
public const int MaxLegs = 3;
}
@@ -0,0 +1,28 @@
namespace Application.Utils.Constants.Stage;
public static class StageTemplate
{
public static readonly Template Group = new("Fase de Grupos", "Fase de grupos todos contra todos.");
public static readonly Template RoundOf16 = new("Octavos de Final", "Etapa eliminatoria con 16 equipos.");
public static readonly Template QuarterFinal = new("Cuartos de Final", "Etapa eliminatoria con 8 equipos.");
public static readonly Template SemiFinal = new("Semifinales", "Etapa eliminatoria con 4 equipos.");
public static readonly Template Final = new("Final", "Partido final del campeonato.");
public static readonly Template ThirdPlace = new("Tercer Puesto", "Partido para determinar el tercer lugar.");
public static readonly int DurationDays = 7;
/// <summary>
/// Standard rest gap, in days, inserted between two consecutive automated stages, from the group stage to the quarter-final.
/// </summary>
public static readonly int StandardGapDays = 2;
/// <summary>
/// Shorter rest gap, in days, inserted between the semi-final and the third place match.
/// </summary>
public static readonly int ThirdPlaceGapDays = 1;
}
@@ -0,0 +1,3 @@
namespace Application.Utils.Constants.Stage;
public record Template(string Name, string Description);
@@ -0,0 +1,30 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
namespace Application.Utils.Constants.Validation;
/// <summary>
/// Validates that a string is a plausible Argentine phone number.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public sealed class ArgentinePhoneNumberAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if (value is not string phone || string.IsNullOrWhiteSpace(phone))
{
return ValidationResult.Success;
}
string trimmed = phone.Trim();
bool hasOnlyAllowedChars = trimmed.All(c => char.IsDigit(c) || c is '+' or ' ' or '(' or ')' or '-');
int digitCount = trimmed.Count(char.IsDigit);
return hasOnlyAllowedChars && digitCount == 10
? ValidationResult.Success
: new ValidationResult(
ErrorMessage ?? ValidationPatterns.PhoneNumberError,
[validationContext.MemberName ?? string.Empty]);
}
}
@@ -0,0 +1,9 @@
namespace Application.Utils.Constants.Validation;
/// <summary>
/// Field length limits shared by every division-name-accepting request DTO and entity configuration.
/// </summary>
public static class DivisionFieldLengths
{
public const int NameMaxLength = 100;
}
@@ -0,0 +1,29 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.Utils.Constants.Validation;
/// <summary>
/// Validates that a DateTime birth date is at least minimumYears years in the past, meaning the person is at least that many years old today.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public sealed class MinimumAgeAttribute(int minimumYears) : ValidationAttribute
{
private readonly int _minimumYears = minimumYears;
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if (value is not DateTime birthDate)
{
return ValidationResult.Success;
}
DateTime cutoff = DateTime.UtcNow.Date.AddYears(-_minimumYears);
return birthDate.Date <= cutoff
? ValidationResult.Success
: new ValidationResult(
ErrorMessage ?? $"El jugador debe tener al menos {_minimumYears} años.",
[validationContext.MemberName ?? string.Empty]);
}
}
@@ -0,0 +1,12 @@
namespace Application.Utils.Constants.Validation;
/// <summary>
/// Field length limits shared by CreatePlayerRequest and UpdatePlayerRequest, so the two stay in sync.
/// </summary>
public static class PlayerFieldLengths
{
public const int NameMaxLength = 70;
public const int DocumentNumberMaxLength = 15;
public const int PhoneNumberMaxLength = 15;
public const int SocialSecurityMaxLength = 100;
}
@@ -0,0 +1,22 @@
namespace Application.Utils.Constants.Validation;
/// <summary>
/// Field length limits shared across the player sanction request DTOs, so they stay in sync.
/// </summary>
public static class SanctionFieldLengths
{
/// <summary>
/// Max length of the short sanction description on Create and Update requests.
/// </summary>
public const int DescriptionMaxLength = 255;
/// <summary>
/// Min length of the free-text appeal reason / resolution notes.
/// </summary>
public const int LongTextMinLength = 1;
/// <summary>
/// Max length of the free-text appeal reason / resolution notes.
/// </summary>
public const int LongTextMaxLength = 1000;
}
@@ -0,0 +1,9 @@
namespace Application.Utils.Constants.Validation;
/// <summary>
/// Field length limits shared by CreateSeasonRequest and UpdateSeasonRequest, so the two stay in sync.
/// </summary>
public static class SeasonFieldLengths
{
public const int NameMaxLength = 100;
}
@@ -0,0 +1,12 @@
namespace Application.Utils.Constants.Validation;
/// <summary>
/// Field length limits shared across the account and user request DTOs, so they stay in sync.
/// </summary>
public static class UserFieldLengths
{
public const int UsernameMinLength = 3;
public const int UsernameMaxLength = 50;
public const int PasswordMinLength = 8;
public const int PhoneMaxLength = 15;
}
@@ -0,0 +1,27 @@
namespace Application.Utils.Constants.Validation;
/// <summary>
/// Shared validation regex patterns kept in one place so every request DTO and the frontend validators enforce the exact same plausible contact-field rules.
/// </summary>
public static class ValidationPatterns
{
/// <summary>
/// Plausible phone number: only digits, spaces, plus, hyphen and parentheses, containing between 8 and 15 digits.
/// </summary>
public const string PhoneNumber = @"^(?=(?:\D*\d){8,15}\D*$)[+\d\s()-]+$";
/// <summary>
/// Human-facing message for a phone number that fails the pattern.
/// </summary>
public const string PhoneNumberError = "Invalid phone number format.";
/// <summary>
/// A player's DNI or document number: digits only, 6 to 15 of them.
/// </summary>
public const string DocumentNumber = @"^\d{6,15}$";
/// <summary>
/// Human-facing message for a document number that fails the pattern.
/// </summary>
public const string DocumentNumberError = "El documento debe contener solo números.";
}
@@ -0,0 +1,10 @@
namespace Application.Utils.Constants.Validation;
/// <summary>
/// Field length limits shared by CreateVenueRequest and UpdateVenueRequest, so the two stay in sync.
/// </summary>
public static class VenueFieldLengths
{
public const int NameMaxLength = 50;
public const int AddressMaxLength = 200;
}
@@ -0,0 +1,40 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.Utils.Extensions;
/// <summary>
/// Validates a property that carries an image reference: either an absolute http or https URL or a self-contained data:image URI.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
public sealed class ImageReferenceAttribute : ValidationAttribute
{
private const string DataImagePrefix = "data:image/";
/// <inheritdoc/>
public override bool IsValid(object? value)
{
// Null or empty means no image, which is the property's own business, decided by a Required attribute alongside this one.
if (value is null)
{
return true;
}
if (value is not string reference || string.IsNullOrWhiteSpace(reference))
{
return value is not string;
}
if (reference.StartsWith(DataImagePrefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return Uri.TryCreate(reference, UriKind.Absolute, out Uri? uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
/// <inheritdoc/>
public override string FormatErrorMessage(string name) =>
$"The {name} field must be an http(s) URL or a data:image URI.";
}
@@ -0,0 +1,151 @@
using Application.DTOs.Abstract.Request;
using Application.Utils.Constants;
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
namespace Application.Utils.Extensions;
public static class QueryableExtensions
{
/// <summary>
/// Builds a combined-AND filter expression by reflecting over T's properties that also exist on TEntity.
/// </summary>
/// <param name="filter">The filter DTO whose non-empty, non-skipped properties become predicates.</param>
/// <param name="ignoredProperties">
/// Names of filter properties whose auto-generated predicate must be
/// suppressed, letting the caller special-case them by resolving a
/// filter through a join instead of the entity's own FK-equality. Purely
/// additive: callers that pass nothing get the original behavior.
/// </param>
/// <exception cref="InvalidOperationException">Thrown when the 'Contains' method is not found.</exception>
public static Expression<Func<TEntity, bool>> ConstructFilterExpression<TEntity, T>(T filter, params string[] ignoredProperties) where T : PaginatedFilterRequest
{
PropertyInfo[] filterProperties = [.. typeof(T)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => !ShouldSkipProperty(p.Name)
&& !ignoredProperties.Contains(p.Name)
&& typeof(TEntity).GetProperty(p.Name) != null)];
bool allNullOrEmpty = Array.TrueForAll(filterProperties, property =>
{
object? value = property.GetValue(filter);
return value == null || (property.PropertyType == typeof(string) && string.IsNullOrWhiteSpace((string) value));
});
if (allNullOrEmpty)
{
ParameterExpression parameter = Expression.Parameter(typeof(TEntity), "x");
Expression trueExpr = Expression.Constant(true);
return Expression.Lambda<Func<TEntity, bool>>(trueExpr, parameter);
}
ParameterExpression parameterExpr = Expression.Parameter(typeof(TEntity), "x");
Expression? finalExpression = null;
MethodInfo containsMethod = typeof(string).GetMethod("Contains", [typeof(string)])
?? throw new InvalidOperationException(ErrorMessages.Query.ContainsMethodNotFound);
MethodInfo toLowerMethod = typeof(string).GetMethod("ToLower", Type.EmptyTypes)
?? throw new InvalidCastException(ErrorMessages.Query.ToLowerMethodNotFound);
foreach (PropertyInfo property in filterProperties)
{
object? filterValue = property.GetValue(filter);
if (filterValue == null)
{
continue;
}
if (property.PropertyType == typeof(string) && string.IsNullOrWhiteSpace((string) filterValue))
{
continue;
}
MemberExpression propertyAccess = Expression.Property(parameterExpr, property.Name);
Expression currentExpr;
if (property.PropertyType == typeof(string) && filterValue is string stringValue)
{
Expression toLowerProperty = Expression.Call(propertyAccess, toLowerMethod);
Expression toLowerFilterValue = Expression.Constant(stringValue.ToLower());
currentExpr = Expression.Call(toLowerProperty, containsMethod, toLowerFilterValue);
}
else
{
Expression constantExpr = Expression.Constant(filterValue, filterValue.GetType());
if (constantExpr.Type != propertyAccess.Type)
{
constantExpr = Expression.Convert(constantExpr, propertyAccess.Type);
}
currentExpr = Expression.Equal(propertyAccess, constantExpr);
}
finalExpression = finalExpression == null ? currentExpr : Expression.AndAlso(finalExpression, currentExpr);
}
finalExpression ??= Expression.Constant(true);
return Expression.Lambda<Func<TEntity, bool>>(finalExpression, parameterExpr);
}
/// <summary>
/// Combines two entity predicates with a logical AND, rebinding the second predicate's parameter onto the first so the result is a single lambda EF Core can translate to SQL.
/// </summary>
public static Expression<Func<TEntity, bool>> AndAlso<TEntity>(
this Expression<Func<TEntity, bool>> left,
Expression<Func<TEntity, bool>> right)
{
ParameterExpression parameter = left.Parameters[0];
Expression reboundRight = new ReplaceParameterVisitor(right.Parameters[0], parameter).Visit(right.Body);
Expression combined = Expression.AndAlso(left.Body, reboundRight);
return Expression.Lambda<Func<TEntity, bool>>(combined, parameter);
}
private sealed class ReplaceParameterVisitor(ParameterExpression from, ParameterExpression to) : ExpressionVisitor
{
protected override Expression VisitParameter(ParameterExpression node)
{
return node == from ? to : base.VisitParameter(node);
}
}
public static IQueryable<T> Paginate<T>(this IQueryable<T> source, int pageNumber, int pageSize)
{
return source.Skip((pageNumber - 1) * pageSize).Take(pageSize);
}
public static IQueryable<T> SortBy<T>(this IQueryable<T> source, IOrderRequest orderRequest)
{
if (string.IsNullOrEmpty(orderRequest.OrderBy))
{
return source;
}
ParameterExpression parameter = Expression.Parameter(typeof(T), "x");
MemberExpression property = Expression.Property(parameter, orderRequest.OrderBy);
LambdaExpression lambda = Expression.Lambda(property, parameter);
MethodInfo method = orderRequest.Order == SortOrder.Ascending
? typeof(Queryable).GetMethods().First(m => m.Name == "OrderBy" && m.GetParameters().Length == 2)
: typeof(Queryable).GetMethods().First(m => m.Name == "OrderByDescending" && m.GetParameters().Length == 2);
MethodInfo genericMethod = method.MakeGenericMethod(typeof(T), property.Type);
return (IQueryable<T>) genericMethod.Invoke(null, [source, lambda])!;
}
private static bool ShouldSkipProperty(string propertyName)
{
return propertyName is
nameof(PaginatedFilterRequest.PageSize) or
nameof(PaginatedFilterRequest.PageNumber) or
nameof(PaginatedFilterRequest.OrderBy) or
nameof(PaginatedFilterRequest.Order);
}
}
@@ -0,0 +1,24 @@
using Domain.Enums;
using System;
namespace Application.Utils.Extensions;
/// <summary>
/// Presentation helpers for TeamStaffRole, mapping each role to its Spanish label for user-facing text.
/// </summary>
public static class TeamStaffRoleExtensions
{
/// <summary>
/// Returns the Spanish, user-facing label for a team-staff role.
/// </summary>
public static string ToSpanishLabel(this TeamStaffRole role)
{
return role switch
{
TeamStaffRole.Coach => "DT",
TeamStaffRole.AssistantCoach => "Asistente",
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null),
};
}
}
@@ -0,0 +1,28 @@
using Domain.Enums;
using System;
namespace Application.Utils.Extensions;
/// <summary>
/// Presentation helpers for TournamentStatus, mapping each status to its Spanish label for user-facing text.
/// </summary>
public static class TournamentStatusExtensions
{
/// <summary>
/// Returns the Spanish, user-facing label for a tournament status.
/// </summary>
public static string ToSpanishLabel(this TournamentStatus status)
{
return status switch
{
TournamentStatus.Scheduled => "Programado",
TournamentStatus.OpenForRegistration => "Inscripción abierta",
TournamentStatus.RegistrationClosed => "Inscripción cerrada",
TournamentStatus.Ongoing => "En curso",
TournamentStatus.Finished => "Finalizado",
TournamentStatus.Canceled => "Cancelado",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
}
}
@@ -0,0 +1,261 @@
using Domain.Entities.Models;
using Domain.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Application.Utils.Helper.Champions;
/// <summary>
/// Pure logic that resolves a division's podium from its elimination bracket, with no data access.
/// </summary>
public static class ChampionResolver
{
/// <summary>
/// A team occupying a podium place.
/// </summary>
public sealed record TeamRef(Guid TeamId, string TeamName, string LogoUrl);
/// <summary>
/// The three podium places; any place may be null when undecided.
/// </summary>
public sealed class Podium
{
public TeamRef? First { get; init; }
public TeamRef? Second { get; init; }
public TeamRef? Third { get; init; }
/// <summary>
/// True when Third is null only because the top cup never had a real bracket round to draw a third place from.
/// </summary>
public bool ImplicitThirdFromStandings { get; init; }
}
private static readonly HashSet<StageType> BracketDepthStageTypes =
[
StageType.RoundOf16,
StageType.QuarterFinal,
StageType.SemiFinal,
];
/// <summary>
/// The champion of a single sub-cup, a playoff bracket, of a division.
/// </summary>
public sealed record CupChampion(string? CupName, int SeedOrder, TeamRef Champion);
/// <summary>
/// Resolves the podium of a division that has a playoff.
/// </summary>
/// <param name="eliminationStages">Every non-group stage of the division.</param>
/// <param name="mappings">The division's position-range to cup mappings, which may be empty.</param>
/// <param name="eliminationMatches">The finished/seeded matches of the elimination stages, with their team navigations loaded.</param>
/// <param name="series">The division's best-of-N series, with their team navigations loaded.</param>
public static Podium ResolvePlayoffPodium(
IReadOnlyList<Stage> eliminationStages,
IReadOnlyList<DivisionPlayoffMapping> mappings,
IReadOnlyList<Match> eliminationMatches,
IReadOnlyList<MatchSeries> series)
{
string? topBracket = ResolveTopBracketName(eliminationStages, mappings);
Stage? finalStage = eliminationStages
.Where(stage => stage.BracketName == topBracket && stage.StageType == StageType.Final)
.OrderBy(stage => stage.Order)
.FirstOrDefault();
Stage? thirdPlaceStage = eliminationStages
.Where(stage => stage.BracketName == topBracket && stage.StageType == StageType.ThirdPlace)
.OrderBy(stage => stage.Order)
.FirstOrDefault();
Dictionary<Guid, TeamRef> teamsById = BuildTeamLookup(eliminationMatches, series);
(Guid Winner, Guid Loser)? finalOutcome = finalStage is null
? null
: ResolveStageOutcome(finalStage, eliminationMatches, series);
(Guid Winner, Guid Loser)? thirdOutcome = thirdPlaceStage is null
? null
: ResolveStageOutcome(thirdPlaceStage, eliminationMatches, series);
TeamRef? third = thirdOutcome is null ? null : Lookup(teamsById, thirdOutcome.Value.Winner);
bool hasBracketDepth = eliminationStages
.Any(stage => stage.BracketName == topBracket && BracketDepthStageTypes.Contains(stage.StageType));
return new Podium
{
First = finalOutcome is null ? null : Lookup(teamsById, finalOutcome.Value.Winner),
Second = finalOutcome is null ? null : Lookup(teamsById, finalOutcome.Value.Loser),
Third = third,
ImplicitThirdFromStandings = third is null && !hasBracketDepth,
};
}
/// <summary>
/// Resolves the champion of every sub-cup, a playoff bracket, of a division, not just the top one.
/// </summary>
public static IReadOnlyList<CupChampion> ResolveCupChampions(
IReadOnlyList<Stage> eliminationStages,
IReadOnlyList<DivisionPlayoffMapping> mappings,
IReadOnlyList<Match> eliminationMatches,
IReadOnlyList<MatchSeries> series)
{
List<string?> bracketNames = [.. eliminationStages
.Select(stage => stage.BracketName)
.Distinct()];
bool singleBracket = bracketNames.Count <= 1;
Dictionary<Guid, TeamRef> teamsById = BuildTeamLookup(eliminationMatches, series);
// The cup receiving the lowest FromPosition is the top seed tier; brackets unnamed by any mapping sort after, by name.
Dictionary<string, int> seedByBracket = mappings
.GroupBy(mapping => mapping.Destination)
.ToDictionary(group => group.Key, group => group.Min(m => m.FromPosition));
List<CupChampion> champions = [];
foreach (string? bracketName in bracketNames)
{
Stage? finalStage = eliminationStages
.Where(stage => stage.BracketName == bracketName && stage.StageType == StageType.Final)
.OrderBy(stage => stage.Order)
.FirstOrDefault();
if (finalStage is null)
{
continue;
}
(Guid Winner, Guid Loser)? outcome = ResolveStageOutcome(finalStage, eliminationMatches, series);
TeamRef? champion = outcome is null ? null : Lookup(teamsById, outcome.Value.Winner);
if (champion is null)
{
continue;
}
int seedOrder = bracketName is not null && seedByBracket.TryGetValue(bracketName, out int seed)
? seed
: int.MaxValue;
champions.Add(new CupChampion(singleBracket ? null : bracketName, seedOrder, champion));
}
return [.. champions
.OrderBy(c => c.SeedOrder)
.ThenBy(c => c.CupName, StringComparer.Ordinal)];
}
/// <summary>
/// Picks the division's top cup name.
/// </summary>
private static string? ResolveTopBracketName(
IReadOnlyList<Stage> eliminationStages,
IReadOnlyList<DivisionPlayoffMapping> mappings)
{
List<string?> bracketNames = [.. eliminationStages
.Select(stage => stage.BracketName)
.Distinct()];
if (bracketNames.Count <= 1)
{
return bracketNames.FirstOrDefault();
}
DivisionPlayoffMapping? topMapping = mappings
.OrderBy(mapping => mapping.FromPosition)
.FirstOrDefault();
if (topMapping is not null && bracketNames.Contains(topMapping.Destination))
{
return topMapping.Destination;
}
return bracketNames
.Where(name => name is not null)
.OrderBy(name => name, StringComparer.Ordinal)
.FirstOrDefault() ?? bracketNames[0];
}
/// <summary>
/// Reads a single elimination stage's decided outcome as a winner and loser pair.
/// </summary>
private static (Guid Winner, Guid Loser)? ResolveStageOutcome(
Stage stage,
IReadOnlyList<Match> eliminationMatches,
IReadOnlyList<MatchSeries> series)
{
List<MatchSeries> stageSeries = [.. series.Where(s => s.StageId == stage.Id)];
if (stageSeries.Count > 0)
{
MatchSeries? decided = stageSeries.Find(s => s.WinningTeamId.HasValue);
if (decided is null)
{
return null;
}
Guid seriesWinner = decided.WinningTeamId!.Value;
Guid seriesLoser = seriesWinner == decided.HomeTeamId ? decided.VisitorTeamId : decided.HomeTeamId;
return (seriesWinner, seriesLoser);
}
Match? match = eliminationMatches
.Where(m => m.StageId == stage.Id
&& m.IsFinished
&& m.WinningTeamId.HasValue
&& m.HomeTeamId.HasValue
&& m.VisitorTeamId.HasValue)
.OrderByDescending(m => m.MatchDate)
.FirstOrDefault();
if (match is null)
{
return null;
}
Guid winner = match.WinningTeamId!.Value;
Guid loser = winner == match.HomeTeamId!.Value ? match.VisitorTeamId!.Value : match.HomeTeamId!.Value;
return (winner, loser);
}
private static Dictionary<Guid, TeamRef> BuildTeamLookup(
IReadOnlyList<Match> eliminationMatches,
IReadOnlyList<MatchSeries> series)
{
Dictionary<Guid, TeamRef> teamsById = [];
foreach (Match match in eliminationMatches)
{
AddTeam(teamsById, match.HomeTeam);
AddTeam(teamsById, match.VisitorTeam);
AddTeam(teamsById, match.WinningTeam);
}
foreach (MatchSeries s in series)
{
AddTeam(teamsById, s.HomeTeam);
AddTeam(teamsById, s.VisitorTeam);
AddTeam(teamsById, s.WinningTeam);
}
return teamsById;
}
private static void AddTeam(Dictionary<Guid, TeamRef> teamsById, Team? team)
{
if (team is null || teamsById.ContainsKey(team.Id))
{
return;
}
teamsById[team.Id] = new TeamRef(team.Id, team.Name, team.LogoUrl);
}
private static TeamRef? Lookup(Dictionary<Guid, TeamRef> teamsById, Guid teamId)
{
return teamsById.TryGetValue(teamId, out TeamRef? team) ? team : null;
}
}
@@ -0,0 +1,44 @@
using Application.Utils.Constants;
using Domain.Entities.Models;
using Domain.Enums;
using System;
namespace Application.Utils.Helper.MatchResult;
/// <summary>
/// Single source of truth for turning a home/visitor score pair into a finished match.
/// </summary>
public static class MatchResultFinalizer
{
/// <summary>
/// Applies a decisive final result to match, deriving the winning team from whichever score is higher.
/// </summary>
/// <param name="match">The match to finalize. Its Stage, HomeTeam, HomeTeamId and their visitor counterparts must be loaded.</param>
/// <param name="homeScore">The home team's final score.</param>
/// <param name="visitorScore">The visitor team's final score.</param>
/// <exception cref="InvalidOperationException">Thrown when the score is tied.</exception>
public static void ApplyResult(Match match, int homeScore, int visitorScore)
{
ArgumentNullException.ThrowIfNull(match);
if (homeScore == visitorScore)
{
throw new InvalidOperationException(
match.Stage.StageType == StageType.Group
? ErrorMessages.Match.GroupStageTieNotAllowed
: ErrorMessages.Match.PlayoffTieNotAllowed);
}
match.HomeScore = homeScore;
match.VisitorScore = visitorScore;
bool homeWon = homeScore > visitorScore;
match.WinningTeam = homeWon ? match.HomeTeam : match.VisitorTeam;
match.WinningTeamId = homeWon ? match.HomeTeamId : match.VisitorTeamId;
match.IsFinished = true;
match.Status = MatchStatus.Played;
}
}
@@ -0,0 +1,56 @@
using Application.Utils.Constants;
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Application.Utils.Helper.Playoff;
/// <summary>
/// Resolves the single bracket seed order for a multi-group cross-division cup.
/// </summary>
public static class CrossCupGroupSeeder
{
/// <summary>
/// Pools the top qualifiersPerGroup teams of each group's standings and returns them as one ordered seed list, best seed first, keeping each team's full Position (not just its id) so a caller can still apply position-range cup mappings over the pooled order.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when qualifiersPerGroup is less than 1, or when fewer than two teams are pooled across all groups, since a bracket needs at least two seeds.
/// </exception>
public static List<Position> PoolAndOrder(
IEnumerable<IReadOnlyList<Position>> groupStandings,
int qualifiersPerGroup)
{
if (qualifiersPerGroup < 1)
{
throw new InvalidOperationException(ErrorMessages.Playoff.InvalidQualifiersPerGroup(qualifiersPerGroup));
}
List<Position> pooled = [.. groupStandings.SelectMany(standings => standings.Take(qualifiersPerGroup))];
if (pooled.Count < 2)
{
throw new InvalidOperationException(ErrorMessages.Playoff.NotEnoughRankedTeams);
}
return [.. pooled
.OrderByDescending(position => position.Points)
.ThenByDescending(position => position.Wins)
.ThenByDescending(position => position.PointsDifference)
.ThenByDescending(position => position.PointsFor)
.ThenBy(position => position.TeamId)];
}
/// <summary>
/// Pools the top qualifiersPerGroup teams of each group's standings and returns them as one ordered seed list of team ids, best seed first.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when qualifiersPerGroup is less than 1, or when fewer than two teams are pooled across all groups, since a bracket needs at least two seeds.
/// </exception>
public static List<Guid> ResolveSeedOrder(
IEnumerable<IReadOnlyList<Position>> groupStandings,
int qualifiersPerGroup) =>
[.. PoolAndOrder(groupStandings, qualifiersPerGroup).Select(position => position.TeamId)];
}
@@ -0,0 +1,49 @@
using Application.Utils.Constants;
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Application.Utils.Helper.Playoff;
/// <summary>
/// Validates a division's position-range to playoff-destination mappings.
/// </summary>
public static class PlayoffMappingValidator
{
/// <summary>
/// Throws InvalidOperationException if any range is malformed or if two ranges overlap.
/// </summary>
public static void Validate(IEnumerable<DivisionPlayoffMapping> mappings)
{
List<DivisionPlayoffMapping> ordered = [.. mappings.OrderBy(m => m.FromPosition)];
foreach (DivisionPlayoffMapping mapping in ordered)
{
if (mapping.FromPosition < 1 || mapping.ToPosition < mapping.FromPosition)
{
throw new InvalidOperationException(
ErrorMessages.Playoff.InvalidRange(mapping.FromPosition, mapping.ToPosition));
}
if (string.IsNullOrWhiteSpace(mapping.Destination))
{
throw new InvalidOperationException(ErrorMessages.Playoff.EmptyDestination);
}
}
for (int i = 1; i < ordered.Count; i++)
{
// Ordered by FromPosition, so an overlap exists when the current range starts at or before the previous range's end.
if (ordered[i].FromPosition <= ordered[i - 1].ToPosition)
{
throw new InvalidOperationException(
ErrorMessages.Playoff.OverlappingRanges(
ordered[i - 1].FromPosition, ordered[i - 1].ToPosition,
ordered[i].FromPosition, ordered[i].ToPosition));
}
}
}
}
@@ -0,0 +1,59 @@
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Application.Utils.Helper.Playoff;
/// <summary>
/// Resolves which teams qualify for each playoff cup from the final group-stage standings and the per-division position-range mappings.
/// </summary>
public static class PlayoffQualificationResolver
{
/// <summary>
/// One division's contribution to the playoffs: its final standings, best team first, and its position-range to destination mappings.
/// </summary>
public sealed class DivisionStandings
{
public required IReadOnlyList<Position> Standings { get; init; }
public required IReadOnlyList<DivisionPlayoffMapping> Mappings { get; init; }
public int DivisionOrder { get; init; }
}
/// <summary>
/// Returns, per destination cup, the ordered list of qualifying team ids gathered across every contributing division, best seed first.
/// </summary>
public static Dictionary<string, List<Guid>> Resolve(IEnumerable<DivisionStandings> divisions)
{
List<(string Destination, int Position, int DivisionOrder, Guid TeamId)> qualifiers = [];
foreach (DivisionStandings division in divisions)
{
for (int i = 0; i < division.Standings.Count; i++)
{
int position = i + 1;
DivisionPlayoffMapping? mapping = division.Mappings
.FirstOrDefault(m => position >= m.FromPosition && position <= m.ToPosition);
if (mapping is null)
{
continue;
}
qualifiers.Add((mapping.Destination, position, division.DivisionOrder, division.Standings[i].TeamId));
}
}
return qualifiers
.GroupBy(q => q.Destination)
.ToDictionary(
group => group.Key,
group => group
.OrderBy(q => q.Position)
.ThenBy(q => q.DivisionOrder)
.Select(q => q.TeamId)
.ToList());
}
}
@@ -0,0 +1,80 @@
using Application.Utils.Constants;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Application.Utils.Helper.Playoff;
/// <summary>
/// Pairs a ranked list of teams into first-round playoff matchups using the standard bracket seeding order.
/// </summary>
public static class PlayoffSeeder
{
/// <summary>
/// Builds the first-round pairs for a ranked list of teams, best seed first.
/// </summary>
public static List<(Guid HomeTeamId, Guid? VisitorTeamId)> SeedPairs(IReadOnlyList<Guid> orderedTeamIds)
{
int count = orderedTeamIds.Count;
if (count < 2)
{
throw new InvalidOperationException(ErrorMessages.Playoff.NotEnoughRankedTeams);
}
int bracketSize = NextPowerOfTwo(count);
List<Guid?> paddedSeeds = [.. orderedTeamIds.Select(id => (Guid?) id)];
paddedSeeds.AddRange(Enumerable.Repeat((Guid?) null, bracketSize - count));
List<int> bracketOrder = BuildSeedOrder(bracketSize);
List<(Guid, Guid?)> pairs = [];
for (int i = 0; i < bracketSize; i += 2)
{
Guid? first = paddedSeeds[bracketOrder[i] - 1];
Guid? second = paddedSeeds[bracketOrder[i + 1] - 1];
(Guid? home, Guid? visitor) = first is null ? (second, first) : (first, second);
pairs.Add((home!.Value, visitor));
}
return pairs;
}
/// <summary>
/// Smallest power of two greater than or equal to value, the bracket size a seed pool of that many teams pads up to.
/// </summary>
public static int NextPowerOfTwo(int value)
{
int power = 1;
while (power < value)
{
power *= 2;
}
return power;
}
/// <summary>
/// Recursively builds the classic bracket seed-position order.
/// </summary>
private static List<int> BuildSeedOrder(int size)
{
if (size == 1)
{
return [1];
}
List<int> previous = BuildSeedOrder(size / 2);
List<int> current = [];
foreach (int seed in previous)
{
current.Add(seed);
current.Add(size + 1 - seed);
}
return current;
}
}
@@ -0,0 +1,38 @@
using Application.DTOs.Divisions.Response;
using Domain.Entities.Models;
using System.Collections.Generic;
using System.Linq;
namespace Application.Utils.Helper.Playoff;
/// <summary>
/// Turns a division's playoff mappings into the ordered qualification ranges the public standings table paints.
/// </summary>
public static class QualificationRangeBuilder
{
/// <summary>
/// Builds the ordered qualification ranges for a set of playoff mappings.
/// </summary>
/// <param name="mappings">The division's playoff mappings, in any order.</param>
/// <returns>The ordered qualification ranges; empty when there are none.</returns>
public static List<QualificationRangeResponse> Build(IEnumerable<DivisionPlayoffMapping>? mappings)
{
if (mappings is null)
{
return [];
}
return [.. mappings
.OrderBy(mapping => mapping.FromPosition)
.ThenBy(mapping => mapping.ToPosition)
.Select((mapping, index) => new QualificationRangeResponse
{
FromPosition = mapping.FromPosition,
ToPosition = mapping.ToPosition,
CupName = mapping.Destination,
Order = index,
})];
}
}
@@ -0,0 +1,44 @@
using System;
namespace Application.Utils.Helper.RoundRobin;
/// <summary>
/// Maps a 1-based round, jornada, number to its default calendar date.
/// </summary>
public static class RoundCalendar
{
/// <summary>
/// Weekday shift, in days, applied to a cross-division-cup group stage's jornadas relative to the regular-zone Sunday baseline.
/// </summary>
public const int CrossDivisionCupDayOffset = 3;
/// <summary>
/// The default calendar date for a round: the first Sunday on or after startDate, advanced by one week per round.
/// </summary>
/// <param name="startDate">The stage's start date.</param>
/// <param name="round">The 1-based round number.</param>
public static DateTime SundayForRound(DateTime startDate, int round)
{
if (round < 1)
{
throw new ArgumentOutOfRangeException(nameof(round), round, "Round numbers are 1-based.");
}
int daysUntilSunday = ((int)DayOfWeek.Sunday - (int)startDate.DayOfWeek + 7) % 7;
DateTime firstSunday = startDate.Date.AddDays(daysUntilSunday);
return firstSunday.AddDays(7 * (round - 1));
}
/// <summary>
/// Division-aware default date for a jornada.
/// </summary>
/// <param name="startDate">The stage's start date, the anchor.</param>
/// <param name="round">The 1-based round number.</param>
/// <param name="isCrossDivisionCup">Whether the stage's division is a cross-division cup.</param>
public static DateTime DateForRound(DateTime startDate, int round, bool isCrossDivisionCup)
{
DateTime sunday = SundayForRound(startDate, round);
return isCrossDivisionCup ? sunday.AddDays(CrossDivisionCupDayOffset) : sunday;
}
}
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Application.Utils.Helper.RoundRobin;
/// <summary>
/// Generates a round-robin fixture using the standard circle method.
/// </summary>
public static class RoundRobinScheduler
{
/// <summary>
/// A placeholder used internally to give an odd team count an even slot count during pairing; never appears in the returned fixture.
/// </summary>
private static readonly Guid Bye = Guid.Empty;
/// <summary>
/// Builds the fixture for a group of teams, tagging every pairing with its 1-based round, jornada, number.
/// </summary>
/// <param name="teamIds">The teams to schedule. An odd count is handled with a bye, one team sits out each round.</param>
/// <param name="legs">How many times each pair plays: 1 for single round-robin, 2 for double, and so on.</param>
/// <param name="random">Optional source of randomness, for deterministic tests.</param>
/// <returns>
/// One entry per match, each carrying the home/visitor teams and the round
/// it is played in. Empty when fewer than two teams are supplied.
/// </returns>
public static List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> GenerateRounds(
IReadOnlyList<Guid> teamIds, int legs, Random? random = null)
{
if (teamIds.Count < 2)
{
return [];
}
random ??= new Random();
// Shuffle the seating before applying the circle method so each generation yields a different-but-valid fixture while preserving the round structure.
List<Guid> rotation = [.. Shuffle([.. teamIds], random)];
if (rotation.Count % 2 != 0)
{
rotation.Add(Bye);
}
int slotCount = rotation.Count;
int roundsPerLeg = slotCount - 1;
List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> fixture = [];
for (int leg = 0; leg < legs; leg++)
{
// Alternate home/away on odd legs so a double round-robin gives each pair one home and one away game.
bool swapHomeAway = leg % 2 == 1;
List<Guid> current = [.. rotation];
for (int round = 0; round < roundsPerLeg; round++)
{
int roundNumber = (leg * roundsPerLeg) + round + 1;
for (int i = 0; i < slotCount / 2; i++)
{
Guid a = current[i];
Guid b = current[slotCount - 1 - i];
if (a != Bye && b != Bye)
{
fixture.Add(swapHomeAway
? (b, a, roundNumber)
: (a, b, roundNumber));
}
}
RotateInPlace(current);
}
}
return fixture;
}
/// <summary>
/// Builds the fixture as a flat list of pairings, discarding the round number.
/// </summary>
public static List<(Guid HomeTeamId, Guid VisitorTeamId)> GenerateFixture(
IReadOnlyList<Guid> teamIds, int legs, Random? random = null)
{
return [.. GenerateRounds(teamIds, legs, random)
.Select(pairing => (pairing.HomeTeamId, pairing.VisitorTeamId))];
}
/// <summary>
/// One round of the circle method: the first slot stays fixed, every other slot rotates one position clockwise.
/// </summary>
private static void RotateInPlace(List<Guid> slots)
{
Guid last = slots[^1];
for (int i = slots.Count - 1; i > 1; i--)
{
slots[i] = slots[i - 1];
}
slots[1] = last;
}
private static List<T> Shuffle<T>(List<T> items, Random random)
{
return [.. items.OrderBy(_ => random.Next())];
}
}
@@ -0,0 +1,29 @@
using Domain.Entities.Models;
using System;
using System.Linq;
namespace Application.Utils.Helper.Series;
/// <summary>
/// Determines whether a best-of-N playoff series has been decided yet.
/// </summary>
public static class SeriesDecisionCalculator
{
/// <summary>
/// Returns the winning team's id once one team has won more than half of the series' BestOf games, based on finished games recorded so far.
/// </summary>
public static Guid? DetermineWinner(MatchSeries series)
{
int winsNeeded = (series.BestOf / 2) + 1;
int homeWins = series.Matches.Count(game => game.IsFinished && game.WinningTeamId == series.HomeTeamId);
if (homeWins >= winsNeeded)
{
return series.HomeTeamId;
}
int visitorWins = series.Matches.Count(game => game.IsFinished && game.WinningTeamId == series.VisitorTeamId);
return visitorWins >= winsNeeded ? series.VisitorTeamId : null;
}
}
@@ -0,0 +1,28 @@
using System;
namespace Application.Utils.Helper.Slug;
/// <summary>
/// Composes the display string a Match's slug is generated from, fed through SlugGenerator.GenerateUniqueSlugAsync.
/// </summary>
public static class MatchSlugSourceBuilder
{
/// <summary>
/// Placeholder team name used when a match's home or visitor team is not yet assigned.
/// </summary>
public const string UnassignedTeamPlaceholder = "TBD";
/// <summary>
/// Builds the slug source string for a match.
/// </summary>
/// <param name="homeTeamName">The home team's name, or null/empty when not yet assigned.</param>
/// <param name="visitorTeamName">The visitor team's name, or null/empty when not yet assigned.</param>
/// <param name="matchDate">The match's date.</param>
public static string Build(string? homeTeamName, string? visitorTeamName, DateTime matchDate)
{
string home = string.IsNullOrWhiteSpace(homeTeamName) ? UnassignedTeamPlaceholder : homeTeamName;
string visitor = string.IsNullOrWhiteSpace(visitorTeamName) ? UnassignedTeamPlaceholder : visitorTeamName;
return $"{home} vs {visitor} {matchDate:yyyy-MM-dd}";
}
}
@@ -0,0 +1,61 @@
using System;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Application.Utils.Helper.Slug;
/// <summary>
/// Generates lowercase, kebab-case, URL-friendly slugs from display names.
/// </summary>
public static partial class SlugGenerator
{
/// <summary>
/// Converts text into a lowercase, kebab-case slug.
/// </summary>
/// <param name="text">The display name to convert.</param>
/// <returns>The generated slug. Never null, may be empty for input with no alphanumeric characters.</returns>
public static string GenerateSlug(string text)
{
string normalized = text.Normalize(NormalizationForm.FormD);
StringBuilder withoutAccents = new(normalized.Length);
foreach (char character in normalized)
{
if (CharUnicodeInfo.GetUnicodeCategory(character) != UnicodeCategory.NonSpacingMark)
{
withoutAccents.Append(character);
}
}
string lowercase = withoutAccents.ToString().Normalize(NormalizationForm.FormC).ToLowerInvariant();
string hyphenated = NonAlphanumericRunRegex().Replace(lowercase, "-");
return hyphenated.Trim('-');
}
/// <summary>
/// Generates a slug from text and, when it collides with an existing slug, appends a numeric suffix until slugExists reports no collision.
/// </summary>
/// <param name="text">The display name to convert.</param>
/// <param name="slugExists">Predicate that checks whether a candidate slug is already taken.</param>
/// <returns>A unique slug derived from text.</returns>
public static async Task<string> GenerateUniqueSlugAsync(string text, Func<string, Task<bool>> slugExists)
{
string baseSlug = GenerateSlug(text);
string candidate = baseSlug;
int suffix = 1;
while (await slugExists(candidate))
{
suffix++;
candidate = $"{baseSlug}-{suffix}";
}
return candidate;
}
[GeneratedRegex("[^a-z0-9]+")]
private static partial Regex NonAlphanumericRunRegex();
}
@@ -0,0 +1,24 @@
using Application.Utils.Constants;
using Application.Utils.Constants.Stage;
using Domain.Enums;
using System;
namespace Application.Utils.Helper.StageHelper;
public static class StageHelper
{
/// <exception cref="ArgumentException">Thrown when an invalid stage type is provided.</exception>
public static int GetMaxTeamsForStage(StageType stageType)
{
return stageType switch
{
StageType.Group => MaxTeams.Group,
StageType.QuarterFinal => MaxTeams.QuarterFinal,
StageType.SemiFinal => MaxTeams.SemiFinal,
StageType.ThirdPlace => MaxTeams.ThirdPlace,
StageType.Final => MaxTeams.Final,
_ => throw new ArgumentException(ErrorMessages.Stage.InvalidStageType)
};
}
}
@@ -0,0 +1,27 @@
using Domain.Entities.Models;
using System;
using System.Collections.Generic;
namespace Application.Utils.Helper.Standings;
/// <summary>
/// Standings for a single Group stage within a division.
/// </summary>
public class GroupStandings
{
/// <summary>
/// The id of the Group stage these standings belong to.
/// </summary>
public required Guid StageId { get; set; }
/// <summary>
/// The Group stage's name, used as the table label.
/// </summary>
public required string StageName { get; set; }
/// <summary>
/// The ordered standings for the teams in this group.
/// </summary>
public required List<Position> Positions { get; set; }
}
@@ -0,0 +1,295 @@
using Domain.Entities.Models;
using Domain.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Application.Utils.Helper.Standings;
/// <summary>
/// Computes division standings from a set of matches.
/// </summary>
public static class PositionCalculator
{
public const int DefaultPointsForWin = 2;
public const int DefaultPointsForLoss = 1;
/// <summary>
/// Builds one Position per team that appears in at least one finished, fully-seeded match, ordered by the group-stage tiebreaker chain.
/// </summary>
/// <param name="matches">The zone's matches.</param>
/// <param name="pointsForWin">Table points for a win. Defaults to 2.</param>
/// <param name="pointsForLoss">Table points for a loss. Defaults to 1.</param>
/// <param name="deductions">
/// Disciplinary point deductions to subtract from the affected teams'
/// totals before ranking. Each team's deductions are summed and subtracted
/// from its Position.Points; the amount and reason are
/// surfaced on Position.PointDeduction. The subtraction is
/// raw and is never clamped — a heavy penalty may legitimately drop a
/// team's total below zero and below other teams. Null/empty leaves every
/// total untouched.
/// </param>
/// <param name="rosterTeams">
/// Optional teams assigned to the zone. When provided, each is seeded into
/// the table at 0-0 so every assigned team shows from the start, before any
/// match is played. Null keeps the legacy behavior, only teams with a
/// finished match appear.
/// </param>
public static List<Position> CalculatePositions(
IEnumerable<Match> matches,
int pointsForWin = DefaultPointsForWin,
int pointsForLoss = DefaultPointsForLoss,
IEnumerable<TeamPointDeduction>? deductions = null,
IEnumerable<Team>? rosterTeams = null)
{
List<Match> finishedMatches = [.. matches.Where(IsSeededAndFinished)];
Dictionary<Guid, Position> positionsByTeamId = [];
// Seed every team assigned to the zone at 0-0 so the table shows all of them before any match is played, instead of an empty standings table until the first result.
if (rosterTeams is not null)
{
foreach (Team team in rosterTeams)
{
EnsurePosition(positionsByTeamId, team);
}
}
foreach (Match match in finishedMatches)
{
bool homeWon = match.WinningTeamId == match.HomeTeamId;
ApplyResult(positionsByTeamId, match.HomeTeam!, match.HomeScore!.Value, match.VisitorScore!.Value, homeWon, pointsForWin, pointsForLoss);
ApplyResult(positionsByTeamId, match.VisitorTeam!, match.VisitorScore.Value, match.HomeScore.Value, !homeWon, pointsForWin, pointsForLoss);
}
foreach (Position position in positionsByTeamId.Values)
{
position.PointsDifference = position.PointsFor - position.PointsAgainst;
}
// Subtract disciplinary deductions before ranking so the tiebreaker chain sees the penalised totals and the table re-orders correctly around a deducted team.
ApplyDeductions(positionsByTeamId.Values, deductions);
return OrderWithTiebreakers(positionsByTeamId.Values, finishedMatches, pointsForWin, pointsForLoss);
}
/// <summary>
/// Subtracts each team's accumulated disciplinary deductions from its standings total.
/// </summary>
public static void ApplyDeductions(
IEnumerable<Position> positions,
IEnumerable<TeamPointDeduction>? deductions)
{
if (deductions is null)
{
return;
}
Dictionary<Guid, List<TeamPointDeduction>> byTeam = deductions
.GroupBy(deduction => deduction.TeamId)
.ToDictionary(group => group.Key, group => group.ToList());
if (byTeam.Count == 0)
{
return;
}
foreach (Position position in positions)
{
if (!byTeam.TryGetValue(position.TeamId, out List<TeamPointDeduction>? teamDeductions))
{
continue;
}
int totalDeducted = teamDeductions.Sum(deduction => deduction.Points);
position.Points -= totalDeducted;
position.PointDeduction = new AppliedPointDeduction
{
Points = totalDeducted,
Reason = string.Join("; ", teamDeductions.Select(deduction => deduction.Reason)),
};
}
}
private static bool IsSeededAndFinished(Match match)
{
return match.IsFinished
&& match.HomeTeam is not null && match.VisitorTeam is not null
&& match.HomeScore is not null && match.VisitorScore is not null;
}
/// <summary>
/// Orders the positions applying the tiebreaker chain stepwise: teams are first sorted by PTS, then PG, then whole-zone DG.
/// </summary>
private static List<Position> OrderWithTiebreakers(
IEnumerable<Position> positions,
IReadOnlyList<Match> matches,
int pointsForWin,
int pointsForLoss)
{
List<Position> ranked = [.. positions
.OrderByDescending(p => p.Points)
.ThenByDescending(p => p.Wins)
.ThenByDescending(p => p.PointsDifference)];
List<Position> result = [];
int index = 0;
while (index < ranked.Count)
{
int groupEnd = index + 1;
while (groupEnd < ranked.Count && SharesBaseCriteria(ranked[index], ranked[groupEnd]))
{
groupEnd++;
}
List<Position> tiedGroup = ranked.GetRange(index, groupEnd - index);
if (tiedGroup.Count == 1)
{
result.Add(tiedGroup[0]);
}
else
{
result.AddRange(BreakTie(tiedGroup, matches, pointsForWin, pointsForLoss));
}
index = groupEnd;
}
AssignResolvedBy(result);
return result;
}
private static bool SharesBaseCriteria(Position a, Position b)
{
return a.Points == b.Points && a.Wins == b.Wins && a.PointsDifference == b.PointsDifference;
}
/// <summary>
/// Breaks a set of teams tied on PTS/PG/DG using the head-to-head mini-table, points earned only in games among the tied set.
/// </summary>
private static List<Position> BreakTie(
List<Position> tiedGroup,
IReadOnlyList<Match> matches,
int pointsForWin,
int pointsForLoss)
{
HashSet<Guid> tiedIds = [.. tiedGroup.Select(p => p.TeamId)];
List<Match> h2hMatches = [.. matches.Where(m =>
tiedIds.Contains(m.HomeTeamId!.Value) && tiedIds.Contains(m.VisitorTeamId!.Value))];
Dictionary<Guid, int> h2hPoints = tiedGroup.ToDictionary(p => p.TeamId, _ => 0);
Dictionary<Guid, int> h2hDifference = tiedGroup.ToDictionary(p => p.TeamId, _ => 0);
foreach (Match match in h2hMatches)
{
Guid winnerId = match.WinningTeamId!.Value;
Guid homeId = match.HomeTeamId!.Value;
Guid visitorId = match.VisitorTeamId!.Value;
h2hPoints[winnerId] += pointsForWin;
h2hPoints[winnerId == homeId ? visitorId : homeId] += pointsForLoss;
int margin = match.HomeScore!.Value - match.VisitorScore!.Value;
h2hDifference[homeId] += margin;
h2hDifference[visitorId] -= margin;
}
// DG en H2H only applies when the tied teams played each other more than once.
bool playedMoreThanOnce = h2hMatches.Count > 1;
return [.. tiedGroup
.OrderByDescending(p => h2hPoints[p.TeamId])
.ThenByDescending(p => playedMoreThanOnce ? h2hDifference[p.TeamId] : 0)];
}
/// <summary>
/// Records, for each team, the criterion that separated it from the team ranked immediately above it, so the standings UI can show why each tie was broken.
/// </summary>
private static void AssignResolvedBy(List<Position> ordered)
{
for (int i = 1; i < ordered.Count; i++)
{
Position above = ordered[i - 1];
Position current = ordered[i];
if (above.Points != current.Points)
{
current.ResolvedBy = null;
}
else if (above.Wins != current.Wins)
{
current.ResolvedBy = TiebreakerCriterion.GamesWon;
}
else if (above.PointsDifference != current.PointsDifference)
{
current.ResolvedBy = TiebreakerCriterion.PointsDifference;
}
else
{
// Tied on all whole-zone criteria: the head-to-head chain put them in this order, and PG-in-H2H cannot cheaply be told apart from DG-in-H2H here, so HeadToHead is reported as the family.
current.ResolvedBy = TiebreakerCriterion.HeadToHead;
}
}
}
/// <summary>
/// Returns the team's Position, creating a zeroed one on first touch.
/// </summary>
private static Position EnsurePosition(
Dictionary<Guid, Position> positionsByTeamId,
Team team)
{
if (!positionsByTeamId.TryGetValue(team.Id, out Position? position))
{
position = new Position
{
TeamId = team.Id,
TeamName = team.Name,
LogoUrl = team.LogoUrl,
MatchesPlayed = 0,
Wins = 0,
Losses = 0,
PointsFor = 0,
PointsAgainst = 0,
PointsDifference = 0,
Points = 0,
};
positionsByTeamId[team.Id] = position;
}
return position;
}
private static void ApplyResult(
Dictionary<Guid, Position> positionsByTeamId,
Team team,
int pointsFor,
int pointsAgainst,
bool won,
int pointsForWin,
int pointsForLoss)
{
Position position = EnsurePosition(positionsByTeamId, team);
position.MatchesPlayed += 1;
position.PointsFor += pointsFor;
position.PointsAgainst += pointsAgainst;
if (won)
{
position.Wins += 1;
position.Points += pointsForWin;
}
else
{
position.Losses += 1;
position.Points += pointsForLoss;
}
}
}
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Application.Utils.Helper.SubGroupDistribution;
/// <summary>
/// Deals a division's roster into evenly balanced sub-groups, the shared algorithm behind rebuild and auto-distribute.
/// </summary>
public static class SubGroupDistribution
{
/// <summary>
/// Minimum number of teams a single sub-group must receive for the split to stay playable.
/// </summary>
public const int MinTeamsPerSubGroup = 4;
/// <summary>
/// Whether subGroupCount groups over totalTeams teams keeps every group at or above the minimum size, treating an empty roster as always valid.
/// </summary>
public static bool MeetsMinimumSize(int totalTeams, int subGroupCount)
{
if (totalTeams == 0)
{
return true;
}
return totalTeams / subGroupCount >= MinTeamsPerSubGroup;
}
/// <summary>
/// Randomly deals rosterTeamIds into subGroupCount groups by round robin, so no two groups differ in size by more than one team.
/// </summary>
public static List<List<Guid>> Distribute(IReadOnlyList<Guid> rosterTeamIds, int subGroupCount)
{
List<Guid> shuffled = [.. rosterTeamIds];
for (int i = shuffled.Count - 1; i > 0; i--)
{
int swapIndex = Random.Shared.Next(i + 1);
(shuffled[swapIndex], shuffled[i]) = (shuffled[i], shuffled[swapIndex]);
}
List<List<Guid>> groups = [.. Enumerable.Range(0, subGroupCount).Select(_ => new List<Guid>())];
for (int i = 0; i < shuffled.Count; i++)
{
groups[i % subGroupCount].Add(shuffled[i]);
}
return groups;
}
}
@@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
namespace Application.Utils.Helper.SupabaseHelper;
/// <summary>
/// Narrow raw-storage boundary over the Supabase Storage client, implemented by SupabaseHelper.
/// </summary>
public interface ISupabaseRawStorage
{
/// <summary>
/// Uploads raw content to objectPath in bucket.
/// </summary>
/// <param name="objectPath">The exact destination path within the bucket.</param>
/// <param name="content">The content stream to upload.</param>
/// <param name="bucket">Target bucket; null uses the configured SupaBase:BucketName.</param>
Task UploadRawAsync(string objectPath, Stream content, string? bucket = null);
/// <summary>
/// Lists raw objects under prefix in bucket.
/// </summary>
/// <param name="prefix">The bucket-relative path prefix to list.</param>
/// <param name="bucket">Target bucket; null uses the configured SupaBase:BucketName.</param>
Task<IReadOnlyList<SupabaseStorageEntry>> ListRawAsync(string prefix, string? bucket = null);
/// <summary>
/// Removes the raw object at objectPath in bucket.
/// </summary>
/// <param name="objectPath">The exact object path within the bucket.</param>
/// <param name="bucket">Target bucket; null uses the configured SupaBase:BucketName.</param>
Task RemoveRawAsync(string objectPath, string? bucket = null);
/// <summary>
/// Downloads the raw content of the object at objectPath in bucket.
/// </summary>
/// <param name="objectPath">The exact object path within the bucket.</param>
/// <param name="bucket">Target bucket; null uses the configured SupaBase:BucketName.</param>
Task<byte[]> DownloadRawAsync(string objectPath, string? bucket = null);
}
@@ -0,0 +1,8 @@
using System;
namespace Application.Utils.Helper.SupabaseHelper;
/// <summary>
/// Minimal listing metadata for a raw Supabase Storage object, decoupled from the third-party Supabase.Storage.FileObject type so fakes don't need to construct it.
/// </summary>
public sealed record SupabaseStorageEntry(string Name, DateTimeOffset? UpdatedAt);
@@ -0,0 +1,53 @@
using Application.DTOs.Team.Response;
using Domain.Entities.Models;
using System;
namespace Application.Utils.Helper.TeamProfile;
/// <summary>
/// Pure projection of a Match into a TeamMatchResponse oriented from one team's perspective.
/// </summary>
public static class TeamMatchMapper
{
/// <summary>
/// Projects match from the point of view of the team identified by teamId.
/// </summary>
/// <param name="match">The match to project. Its HomeTeam/VisitorTeam/Venue
/// navigations should be loaded for names and logos to be populated.</param>
/// <param name="teamId">The team whose perspective to orient the result to.</param>
/// <returns>The team-oriented match projection.</returns>
public static TeamMatchResponse Map(Match match, Guid teamId)
{
bool isHome = match.HomeTeamId == teamId;
Domain.Entities.Models.Team? opponent = isHome ? match.VisitorTeam : match.HomeTeam;
Guid? opponentId = isHome ? match.VisitorTeamId : match.HomeTeamId;
int? teamScore = isHome ? match.HomeScore : match.VisitorScore;
int? opponentScore = isHome ? match.VisitorScore : match.HomeScore;
// Basketball has no draws, a finished match always has a winning team since a walkover sets one too, so the result is W when this team wins and L otherwise.
string? result = null;
if (match.IsFinished)
{
result = match.WinningTeamId == teamId ? "W" : "L";
}
return new TeamMatchResponse
{
MatchId = match.Id,
MatchDate = match.MatchDate,
IsFinished = match.IsFinished,
Status = match.Status.ToString(),
IsHome = isHome,
OpponentTeamId = opponentId ?? Guid.Empty,
OpponentName = opponent?.Name ?? string.Empty,
OpponentLogoUrl = opponent?.LogoUrl,
TeamScore = teamScore,
OpponentScore = opponentScore,
Result = result,
VenueName = match.Venue?.Name,
};
}
}
@@ -0,0 +1,247 @@
using Application.DTOs.Tournament.Response;
using Domain.Entities.Models;
using Domain.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
using SubGroupDistributionHelper = Application.Utils.Helper.SubGroupDistribution.SubGroupDistribution;
namespace Application.Utils.Helper.Tournament;
/// <summary>
/// Single source of truth for whether a tournament can be completed once it is started.
/// </summary>
public static class TournamentCompletabilityValidator
{
/// <summary>
/// Minimum number of teams a zone, and each cross-cup group, must have assigned for the competition it feeds to be playable or completable.
/// </summary>
public const int MinTeamsPerZone = 2;
/// <summary>
/// Minimum number of HABILITADO players an enrolled team must have for the tournament to be completable.
/// </summary>
public const int MinPlayersPerTeam = 4;
/// <summary>
/// Evaluates every completability rule against the loaded tournament graph and returns one issue per violation.
/// </summary>
/// <param name="tournament">
/// The tournament with its Divisions loaded, each division's Stages, and
/// each stage's StageTeamMatches and PlayoffMappings loaded.
/// </param>
/// <param name="enrolledRegistrations">
/// The tournament's team registrations, the enrolled teams, each ideally with
/// its Team loaded so team names can be reported.
/// </param>
/// <param name="playerCountsByTeam">
/// Each enrolled team's HABILITADO player count for this tournament, per
/// PlayerTeamRegistration.IsHabilitado — NOT its raw
/// registered-roster size, since a registered-but-not-yet-approved player
/// could never legally play anyway. Used by the TeamTooFewPlayers rule.
/// Omitted, null, skips that rule entirely — callers that don't have this
/// data loaded are unaffected.
/// </param>
public static IReadOnlyList<CompletabilityIssue> Validate(
Domain.Entities.Models.Tournament tournament,
IReadOnlyCollection<TeamTournamentRegistration> enrolledRegistrations,
IReadOnlyDictionary<Guid, int>? playerCountsByTeam = null)
{
ArgumentNullException.ThrowIfNull(tournament);
ArgumentNullException.ThrowIfNull(enrolledRegistrations);
List<CompletabilityIssue> issues = [];
List<Division> regularDivisions =
[.. tournament.Divisions.Where(division => !division.IsCrossDivisionCup)];
List<Division> crossCupDivisions =
[.. tournament.Divisions.Where(division => division.IsCrossDivisionCup)];
Dictionary<Guid, string> teamNames = BuildTeamNames(tournament, enrolledRegistrations);
Dictionary<Guid, HashSet<Guid>> assignedTeamsByDivision = regularDivisions
.ToDictionary(division => division.Id, division => GroupStageTeamIds(division));
Dictionary<Guid, HashSet<Guid>> zonesByTeam = [];
foreach ((Guid divisionId, HashSet<Guid> teamIds) in assignedTeamsByDivision)
{
foreach (Guid teamId in teamIds)
{
if (!zonesByTeam.TryGetValue(teamId, out HashSet<Guid>? zones))
{
zonesByTeam[teamId] = zones = [];
}
zones.Add(divisionId);
}
}
foreach (Division division in regularDivisions)
{
int assigned = assignedTeamsByDivision[division.Id].Count;
if (assigned < MinTeamsPerZone)
{
issues.Add(new CompletabilityIssue
{
Code = CompletabilityIssueCodes.ZoneTooFewTeams,
DivisionName = division.Name,
AssignedTeams = assigned,
});
}
}
// A team assigned only to the cross cup does not satisfy zone assignment.
HashSet<Guid> enrolledTeamIds = [.. enrolledRegistrations.Select(registration => registration.TeamId)];
foreach (Guid teamId in enrolledTeamIds.OrderBy(id => ResolveTeamName(teamNames, id)))
{
if (!zonesByTeam.ContainsKey(teamId))
{
issues.Add(new CompletabilityIssue
{
Code = CompletabilityIssueCodes.TeamNotAssigned,
TeamName = ResolveTeamName(teamNames, teamId),
});
}
}
foreach ((Guid teamId, HashSet<Guid> zones) in zonesByTeam
.Where(entry => entry.Value.Count > 1)
.OrderBy(entry => ResolveTeamName(teamNames, entry.Key)))
{
issues.Add(new CompletabilityIssue
{
Code = CompletabilityIssueCodes.TeamInMultipleZones,
TeamName = ResolveTeamName(teamNames, teamId),
});
}
// A mapping whose FromPosition exceeds the zone's assigned team count seeds a cup from positions that will never exist.
foreach (Division division in regularDivisions)
{
int assigned = assignedTeamsByDivision[division.Id].Count;
foreach (DivisionPlayoffMapping mapping in division.PlayoffMappings
.Where(mapping => mapping.FromPosition > assigned)
.OrderBy(mapping => mapping.FromPosition))
{
issues.Add(new CompletabilityIssue
{
Code = CompletabilityIssueCodes.PlayoffRangeExceedsTeams,
DivisionName = division.Name,
FromPosition = mapping.FromPosition,
AssignedTeams = assigned,
});
}
}
// A rebuild always deals a balanced split, so this mainly catches a hand-edited imbalance or a group left below the minimum after a manual move.
foreach (Division division in regularDivisions)
{
List<Stage> subGroupStages = [.. division.Stages.Where(stage => stage.StageType == StageType.Group)];
if (subGroupStages.Count <= 1)
{
continue;
}
List<int> subGroupSizes = [.. subGroupStages
.Select(stage => stage.StageTeamMatches.Select(match => match.TeamId).Distinct().Count())];
int smallestSubGroup = subGroupSizes.Min();
int largestSubGroup = subGroupSizes.Max();
if (smallestSubGroup < SubGroupDistributionHelper.MinTeamsPerSubGroup || largestSubGroup - smallestSubGroup >= 2)
{
issues.Add(new CompletabilityIssue
{
Code = CompletabilityIssueCodes.SubGroupTooFewTeams,
DivisionName = division.Name,
AssignedTeams = smallestSubGroup,
});
}
}
foreach (Division crossCup in crossCupDivisions)
{
foreach (Stage groupStage in crossCup.Stages.Where(stage => stage.StageType == StageType.Group))
{
int assigned = groupStage.StageTeamMatches.Select(match => match.TeamId).Distinct().Count();
if (assigned < MinTeamsPerZone)
{
issues.Add(new CompletabilityIssue
{
Code = CompletabilityIssueCodes.CrossCupGroupTooFewTeams,
DivisionName = crossCup.Name,
AssignedTeams = assigned,
});
}
}
}
// Every enrolled team needs at least MinPlayersPerTeam HABILITADO players, basketball's minimum to field a legal lineup without a walkover, skipped when the caller did not load player-count data.
if (playerCountsByTeam is not null)
{
foreach (Guid teamId in enrolledTeamIds.OrderBy(id => ResolveTeamName(teamNames, id)))
{
int playerCount = playerCountsByTeam.GetValueOrDefault(teamId);
if (playerCount < MinPlayersPerTeam)
{
issues.Add(new CompletabilityIssue
{
Code = CompletabilityIssueCodes.TeamTooFewPlayers,
TeamName = ResolveTeamName(teamNames, teamId),
PlayerCount = playerCount,
});
}
}
}
return issues;
}
/// <summary>
/// Distinct ids of the teams assigned to a division's Group stages via StageTeamMatch rows.
/// </summary>
private static HashSet<Guid> GroupStageTeamIds(Division division)
{
return [.. division.Stages
.Where(stage => stage.StageType == StageType.Group)
.SelectMany(stage => stage.StageTeamMatches)
.Select(match => match.TeamId)];
}
/// <summary>
/// Builds a team-id to team-name lookup from every source that carries a Team navigation, so an issue can report a readable team name whichever rule fired.
/// </summary>
private static Dictionary<Guid, string> BuildTeamNames(
Domain.Entities.Models.Tournament tournament,
IReadOnlyCollection<TeamTournamentRegistration> enrolledRegistrations)
{
Dictionary<Guid, string> names = [];
foreach (TeamTournamentRegistration registration in enrolledRegistrations
.Where(registration => registration.Team is not null))
{
names[registration.TeamId] = registration.Team!.Name;
}
foreach (StageTeamMatch match in tournament.Divisions
.SelectMany(division => division.Stages)
.SelectMany(stage => stage.StageTeamMatches)
.Where(match => match.Team is not null))
{
names[match.TeamId] = match.Team!.Name;
}
return names;
}
/// <summary>
/// Resolves a team's display name, falling back to its id when no Team navigation was loaded for it.
/// </summary>
private static string ResolveTeamName(Dictionary<Guid, string> teamNames, Guid teamId)
{
return teamNames.TryGetValue(teamId, out string? name) ? name : teamId.ToString();
}
}
@@ -0,0 +1,135 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.Scorer.Response;
using Application.Interfaces.Mappers;
using Application.Utils.Constants.Scorer;
using Domain.Entities.Models;
using Riok.Mapperly.Abstractions;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Application.Utils.Mappers;
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)]
public partial class ScorerMapper : IScorerMapper
{
/// <summary>
/// Collapses one row per scoring event into a single row per player, summing their points across the paginated page.
/// </summary>
public PaginatedResponse<ScorerByPlayerResponse> FromPaginatedScorerToPaginatedScorerByPlayerResponse(PaginatedResponse<Scorer> paginatedScorers)
{
List<ScorerByPlayerResponse> groupedItems = [.. paginatedScorers.Items
.GroupBy(scorer => scorer.PlayerId)
.Select(group => new ScorerByPlayerResponse
{
PlayerId = group.Key,
Points = group.Sum(scorer => scorer.Points),
FullName = group
.Select(scorer => scorer.Player?.FullName)
.FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? string.Empty
})
.OrderByDescending(item => item.Points)];
return new PaginatedResponse<ScorerByPlayerResponse>
{
Items = groupedItems,
Page = paginatedScorers.Page,
PageSize = paginatedScorers.PageSize,
TotalCount = groupedItems.Count
};
}
/// <summary>
/// Computes each team's league points across the given matches using ScoreConstants for win, draw and loss, skipping matches that aren't finished yet or don't have both teams assigned.
/// </summary>
public PaginatedResponse<ScorerByTeamResponse> FromPaginatedMatchToPaginatedScorerByTeamResponse(PaginatedResponse<Match> paginatedMatches)
{
Dictionary<Guid, ScorerByTeamResponse> scoreboard = [];
foreach (Match match in paginatedMatches.Items)
{
if (match.HomeTeamId is null || match.VisitorTeamId is null)
{
continue;
}
Guid homeTeamId = match.HomeTeamId.Value;
Guid visitorTeamId = match.VisitorTeamId.Value;
string homeTeamName = match.HomeTeam?.Name ?? string.Empty;
string visitorTeamName = match.VisitorTeam?.Name ?? string.Empty;
if (!scoreboard.TryGetValue(homeTeamId, out ScorerByTeamResponse? homeEntry))
{
homeEntry = new ScorerByTeamResponse
{
TeamId = homeTeamId,
Name = homeTeamName,
Points = 0
};
scoreboard[homeTeamId] = homeEntry;
}
if (!scoreboard.TryGetValue(visitorTeamId, out ScorerByTeamResponse? visitorEntry))
{
visitorEntry = new ScorerByTeamResponse
{
TeamId = visitorTeamId,
Name = visitorTeamName,
Points = 0
};
scoreboard[visitorTeamId] = visitorEntry;
}
if (!match.IsFinished)
{
continue;
}
bool isDraw = match.HomeScore == match.VisitorScore;
bool homeWon = match.HomeScore > match.VisitorScore;
if (isDraw)
{
homeEntry.Points += ScoreConstants.PointsForDraw;
visitorEntry.Points += ScoreConstants.PointsForDraw;
continue;
}
if (homeWon)
{
homeEntry.Points += ScoreConstants.PointsForWin;
visitorEntry.Points += ScoreConstants.PointsForLoss;
}
else
{
homeEntry.Points += ScoreConstants.PointsForLoss;
visitorEntry.Points += ScoreConstants.PointsForWin;
}
}
List<ScorerByTeamResponse> items = [.. scoreboard.Values.OrderByDescending(x => x.Points)];
return new PaginatedResponse<ScorerByTeamResponse>
{
Items = items,
Page = paginatedMatches.Page,
PageSize = paginatedMatches.PageSize,
TotalCount = items.Count
};
}
public ScorerByPlayerResponse FromScorerToScorerByPlayerResponse(Scorer scorer)
{
throw new System.NotImplementedException();
}
public ScorerByTeamResponse FromScorerToScorerByTeamResponse(Scorer scorer)
{
throw new System.NotImplementedException();
}
}
@@ -0,0 +1,13 @@
using Application.Interfaces.Mappers;
using Domain.Entities.Models;
using Riok.Mapperly.Abstractions;
namespace Application.Utils.Mappers;
[Mapper]
public partial class TeamMapper : ITeamMapper
{
public partial void ApplyUpdate(Team source, [MappingTarget] Team target);
}
@@ -0,0 +1,17 @@
namespace Application.Utils.Options;
/// <summary>
/// Configurable roster limits.
/// </summary>
public class RosterOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Roster";
/// <summary>
/// Maximum number of players allowed in a single team's roster for one tournament season.
/// </summary>
public int MaxPlayersPerTeam { get; set; } = 25;
}