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,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),
};
}
}