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,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="AutoMapper" Version="16.2.0">
<NoWarn>NU1903</NoWarn>
</PackageReference>
<PackageReference Include="LinqKit.Microsoft.EntityFrameworkCore" Version="8.1.11" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.12" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.22.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
</Project>
@@ -0,0 +1,29 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Application.Backup;
/// <summary>
/// Process-wide single-flight guard for backup and restore write operations.
/// </summary>
public sealed class BackupOperationLock
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
/// <summary>
/// Attempts to acquire the lock, waiting up to timeout; callers pass TimeSpan.Zero to fail fast.
/// </summary>
public Task<bool> WaitAsync(TimeSpan timeout, CancellationToken ct = default)
{
return _semaphore.WaitAsync(timeout, ct);
}
/// <summary>
/// Releases the lock; must be called exactly once for every successful WaitAsync that returned true.
/// </summary>
public void Release()
{
_semaphore.Release();
}
}
@@ -0,0 +1,244 @@
using Application.DTOs.Backup.Response;
using Application.Interfaces.Backup;
using Application.Interfaces.Services;
using Application.Utils.Constants;
using Domain.Constants;
using Domain.Entities.Models;
using Domain.Enums;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Application.Backup;
/// <summary>
/// The one shared write path for backup operations.
/// </summary>
public sealed class BackupOperationsService(
IBackupCatalog catalog,
IBackupStorage storage,
IDatabaseBackupService backupService,
IDatabaseRestoreService restoreService,
IBackupRetentionPolicy retentionPolicy,
BackupOptions options,
BackupOperationLock operationLock,
IMaintenanceModeState maintenanceModeState,
IAuditService auditService,
ILogger<BackupOperationsService> logger) : IBackupOperationsService
{
public Task<IReadOnlyList<BackupRecord>> ListNewestFirstAsync(CancellationToken ct = default) =>
catalog.ListNewestFirstAsync(ct);
public async Task<BackupOperationResult> CreateBackupAsync(BackupOrigin origin, CancellationToken ct = default)
{
if (!await operationLock.WaitAsync(TimeSpan.Zero, ct))
{
return new BackupOperationResult(BackupOperationOutcome.Busy, null, ErrorMessages.Backup.OperationInProgress);
}
try
{
return await CreateBackupCoreAsync(origin, applyRetention: true, ct);
}
finally
{
operationLock.Release();
}
}
public async Task<BackupOperationResult> DeleteBackupAsync(Guid id, CancellationToken ct = default)
{
if (!await operationLock.WaitAsync(TimeSpan.Zero, ct))
{
return new BackupOperationResult(BackupOperationOutcome.Busy, null, ErrorMessages.Backup.OperationInProgress);
}
try
{
BackupRecord? record = await catalog.GetByIdAsync(id, ct);
if (record is null)
{
return new BackupOperationResult(BackupOperationOutcome.NotFound, null, null);
}
try
{
await storage.DeleteAsync(record.StoragePath, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// An already-missing file deleted out-of-band must not block removing the catalog row, so log and continue.
logger.LogWarning(
ex,
"Backup file could not be deleted from storage; removing catalog record anyway. StoragePath: {StoragePath}",
record.StoragePath);
}
await catalog.RemoveAsync(record.Id, ct);
return new BackupOperationResult(BackupOperationOutcome.Completed, BackupRecordResponse.FromEntity(record), null);
}
finally
{
operationLock.Release();
}
}
/// <summary>
/// Enters maintenance mode, takes an automatic safety backup, restores the selected one, then exits.
/// </summary>
public async Task<BackupOperationResult> RestoreBackupAsync(Guid id, CancellationToken ct = default)
{
if (!await operationLock.WaitAsync(TimeSpan.Zero, ct))
{
return new BackupOperationResult(BackupOperationOutcome.Busy, null, ErrorMessages.Backup.OperationInProgress);
}
try
{
BackupRecord? record = await catalog.GetByIdAsync(id, ct);
if (record is null)
{
return new BackupOperationResult(BackupOperationOutcome.NotFound, null, null);
}
maintenanceModeState.Enter($"Restoring backup {record.Id}.");
string? tempFilePath = null;
try
{
BackupOperationResult safetyBackup = await CreateBackupCoreAsync(BackupOrigin.Job, applyRetention: false, ct);
if (safetyBackup.Outcome != BackupOperationOutcome.Completed)
{
return new BackupOperationResult(BackupOperationOutcome.Failed, null, safetyBackup.Message);
}
tempFilePath = Path.Combine(Path.GetTempPath(), $"restore-{Guid.NewGuid():N}.sql");
await using (Stream source = await storage.OpenReadAsync(record.StoragePath, ct))
await using (FileStream destination = File.Create(tempFilePath))
{
await source.CopyToAsync(destination, ct);
}
await restoreService.RestoreAsync(tempFilePath, ct);
// Logging the restore is non-throwing by contract via IAuditService.LogAsync, so a logging hiccup never turns a successful restore into a reported failure.
await auditService.LogAsync(
AuditAction.BackupRestore,
targetType: nameof(BackupRecord),
targetId: record.Id.ToString(),
targetName: record.StoragePath,
detail: $"Origen: {record.Origin}",
ct: ct);
return new BackupOperationResult(BackupOperationOutcome.Completed, safetyBackup.Record, null);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Restore failed; maintenance mode will be cleared and the host keeps running.");
return new BackupOperationResult(BackupOperationOutcome.Failed, null, ex.Message);
}
finally
{
if (tempFilePath is not null && File.Exists(tempFilePath))
{
File.Delete(tempFilePath);
}
maintenanceModeState.Exit();
}
}
finally
{
operationLock.Release();
}
}
/// <summary>
/// Assumes the lock is already held by the caller, so a restore's safety backup avoids self-deadlock.
/// </summary>
private async Task<BackupOperationResult> CreateBackupCoreAsync(BackupOrigin origin, bool applyRetention, CancellationToken ct)
{
Stream dump;
try
{
dump = await backupService.CreateDumpAsync(ct);
}
catch (BackupExecutionException ex)
{
logger.LogError(ex, "Backup dump failed; no catalog record written.");
return new BackupOperationResult(BackupOperationOutcome.Failed, null, ex.Message);
}
await using (dump)
{
string name = $"backup-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}.sql";
long sizeBytes = dump.Length;
try
{
await storage.StoreAsync(name, dump, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Failed to store backup dump; no catalog record written.");
return new BackupOperationResult(BackupOperationOutcome.Failed, null, ex.Message);
}
BackupRecord record = new()
{
CreatedBy = AuditConstants.SystemUser,
StoragePath = name,
SizeBytes = sizeBytes,
Origin = origin,
};
BackupRecord added = await catalog.AddAsync(record, ct);
if (applyRetention)
{
await ApplyRetentionAsync(ct);
}
return new BackupOperationResult(BackupOperationOutcome.Completed, BackupRecordResponse.FromEntity(added), null);
}
}
private async Task ApplyRetentionAsync(CancellationToken ct)
{
IReadOnlyList<BackupRecord> all = await catalog.ListNewestFirstAsync(ct);
IReadOnlyList<BackupFile> files = all
.Select(r => new BackupFile(r.StoragePath, new DateTimeOffset(DateTime.SpecifyKind(r.DateCreated, DateTimeKind.Utc))))
.ToList();
IReadOnlyList<BackupFile> toDelete = retentionPolicy.SelectForDeletion(files, options.RetentionCount);
if (toDelete.Count == 0)
{
return;
}
Dictionary<string, BackupRecord> byStoragePath = all.ToDictionary(r => r.StoragePath, StringComparer.Ordinal);
IEnumerable<BackupRecord> recordsToDelete = toDelete
.Select(stale => byStoragePath.GetValueOrDefault(stale.Name))
.OfType<BackupRecord>();
foreach (BackupRecord record in recordsToDelete)
{
try
{
await storage.DeleteAsync(record.StoragePath, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Failed to delete stale backup file {StoragePath} during retention pruning.", record.StoragePath);
}
await catalog.RemoveAsync(record.Id, ct);
}
}
}
@@ -0,0 +1,35 @@
using Application.Interfaces.Backup;
using Application.Utils.Constants;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Application.Backup;
/// <summary>
/// Pure keep-last-N retention policy that retains the newest entries by timestamp with no I/O.
/// </summary>
public sealed class KeepLastNRetentionPolicy : IBackupRetentionPolicy
{
public IReadOnlyList<BackupFile> SelectForDeletion(IReadOnlyList<BackupFile> existing, int retainCount)
{
ArgumentNullException.ThrowIfNull(existing);
if (retainCount < 0)
{
throw new ArgumentOutOfRangeException(nameof(retainCount), ErrorMessages.Backup.RetentionCountNegative);
}
if (existing.Count <= retainCount)
{
return [];
}
return existing
.OrderByDescending(f => f.Timestamp)
.ThenBy(f => f.Name, StringComparer.Ordinal)
.Skip(retainCount)
.ToList();
}
}
@@ -0,0 +1,31 @@
using Application.Interfaces.Backup;
using System;
namespace Application.Backup;
/// <summary>
/// In-memory IMaintenanceModeState singleton, pure process state with no OS or network I/O.
/// </summary>
public sealed class MaintenanceModeState : IMaintenanceModeState
{
public bool IsActive { get; private set; }
public string? Reason { get; private set; }
public DateTimeOffset? EnteredAtUtc { get; private set; }
public void Enter(string reason)
{
Reason = reason;
EnteredAtUtc = DateTimeOffset.UtcNow;
IsActive = true;
}
public void Exit()
{
IsActive = false;
Reason = null;
EnteredAtUtc = null;
}
}
@@ -0,0 +1,17 @@
namespace Application.DTOs.Abstract.Request;
/// <summary>
/// Defines the ordering request properties.
/// </summary>
public interface IOrderRequest
{
/// <summary>
/// The property name to sort by.
/// </summary>
string? OrderBy { get; set; }
/// <summary>
/// The sort order. Default is Ascending.
/// </summary>
SortOrder? Order { get; set; }
}
@@ -0,0 +1,17 @@
namespace Application.DTOs.Abstract.Request;
/// <summary>
/// Defines the pagination request properties.
/// </summary>
public interface IPaginationRequest
{
/// <summary>
/// The page number for pagination. Default is 1.
/// </summary>
int PageNumber { get; set; }
/// <summary>
/// The page size for pagination. Defaults to 100.
/// </summary>
int PageSize { get; set; }
}
@@ -0,0 +1,47 @@
using Application.Utils.Constants.Pagination;
namespace Application.DTOs.Abstract.Request;
/// <summary>
/// Represents a request for paginated and filtered data.
/// </summary>
public class PaginatedFilterRequest : IPaginationRequest, IOrderRequest
{
/// <summary>
/// The page number for pagination. Default is 1.
/// </summary>
public int PageNumber { get; set; } = 1;
private int _pageSize = PaginationDefaults.DefaultPageSize;
/// <summary>
/// The page size for pagination; values outside 1-100 are silently clamped rather than rejected.
/// </summary>
public int PageSize
{
get => _pageSize;
set => _pageSize = ClampPageSize(value);
}
private static int ClampPageSize(int requestedPageSize)
{
if (requestedPageSize < 1)
{
return PaginationDefaults.DefaultPageSize;
}
return requestedPageSize > PaginationDefaults.MaxPageSize
? PaginationDefaults.MaxPageSize
: requestedPageSize;
}
/// <summary>
/// The property name to sort by.
/// </summary>
public string? OrderBy { get; set; } = "DateCreated";
/// <summary>
/// The sort order. Default is Ascending.
/// </summary>
public SortOrder? Order { get; set; } = SortOrder.Ascending;
}
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Abstract.Request;
/// <summary>
/// Enum to specify the sort order.
/// </summary>
public enum SortOrder
{
/// <summary>
/// Ascending order.
/// </summary>
[Display(Name = "asc")]
Ascending,
/// <summary>
/// Descending order.
/// </summary>
[Display(Name = "desc")]
Descending
}
@@ -0,0 +1,14 @@
using System;
namespace Application.DTOs.Abstract.Response;
/// <summary>
/// Base response class containing common properties for responses.
/// </summary>
public class BaseEntityResponse
{
/// <summary>
/// The unique identifier of the entity.
/// </summary>
public required Guid Id { get; set; }
}
@@ -0,0 +1,31 @@
using System.Collections.Generic;
namespace Application.DTOs.Abstract.Response;
/// <summary>
/// A page of results together with the metadata needed to fetch adjacent pages.
/// </summary>
/// <typeparam name="T">The type of item contained in the page.</typeparam>
public class PaginatedResponse<T>
{
/// <summary>
/// The items in the collection.
/// </summary>
public IEnumerable<T> Items { get; set; } = [];
/// <summary>
/// The current page number.
/// </summary>
public required int Page { get; set; }
/// <summary>
/// The number of items in a page.
/// </summary>
public required int PageSize { get; set; }
/// <summary>
/// The total number of items in the collection.
/// </summary>
public required int TotalCount { get; set; }
}
@@ -0,0 +1,27 @@
using Application.DTOs.Abstract.Request;
using Application.Utils.Constants.Pagination;
using Domain.Enums;
namespace Application.DTOs.AuditLogs.Request;
/// <summary>
/// Filtering and pagination for the audit-trail listing, defaulting to newest-first.
/// </summary>
public class AuditLogFilteredRequest : PaginatedFilterRequest
{
public AuditLogFilteredRequest()
{
Order = SortOrder.Descending;
}
/// <summary>
/// Optional filter by the actor who performed the action.
/// </summary>
public string? Actor { get; set; }
/// <summary>
/// Optional filter by the action type.
/// </summary>
public AuditAction? Action { get; set; }
}
@@ -0,0 +1,46 @@
using Application.DTOs.Abstract.Response;
using System;
namespace Application.DTOs.AuditLogs.Response;
/// <summary>
/// Audit-trail entry as returned by the listing endpoint.
/// </summary>
public class AuditLogResponse : BaseEntityResponse
{
/// <summary>
/// The sensitive action that was performed, as the enum name.
/// </summary>
public required string Action { get; set; }
/// <summary>
/// Who performed the action, as an email address or "System".
/// </summary>
public required string Actor { get; set; }
/// <summary>
/// The kind of entity targeted, when applicable.
/// </summary>
public string? TargetType { get; set; }
/// <summary>
/// Identifier of the targeted entity, when applicable.
/// </summary>
public string? TargetId { get; set; }
/// <summary>
/// Null when there is no single named target or the entry predates this field.
/// </summary>
public string? TargetName { get; set; }
/// <summary>
/// Free-form human-readable context.
/// </summary>
public string? Detail { get; set; }
/// <summary>
/// When the action happened, in UTC.
/// </summary>
public required DateTime Timestamp { get; set; }
}
@@ -0,0 +1,23 @@
using Application.Utils.Constants.Validation;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Auth.Request;
/// <summary>
/// Payload the invited user submits from the activation email link to set their first password.
/// </summary>
public sealed class ActivateAccountRequest
{
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
/// <summary>
/// Activation token received via email, URL-decoded automatically by the framework.
/// </summary>
[Required]
public string Token { get; set; } = string.Empty;
[Required, MinLength(UserFieldLengths.PasswordMinLength)]
public string NewPassword { get; set; } = string.Empty;
}
@@ -0,0 +1,28 @@
using Application.Utils.Constants.Validation;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Auth.Request;
/// <summary>
/// Request to create a user by email only; the system emails a magic activation link to set the password.
/// </summary>
public sealed class InviteUserRequest
{
[Required(ErrorMessage = "Email is required.")]
[EmailAddress(ErrorMessage = "Invalid email format.")]
public required string Email { get; set; }
/// <summary>
/// Optional contact phone number.
/// </summary>
[ArgentinePhoneNumber]
[MaxLength(UserFieldLengths.PhoneMaxLength, ErrorMessage = "Phone number must not exceed 15 characters.")]
public string? Phone { get; set; }
/// <summary>
/// Target role. Accepted values: ADMIN, OWNER.
/// </summary>
[Required(ErrorMessage = "Role is required.")]
public required string Role { get; set; }
}
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Auth.Request;
/// <summary>
/// Represents a request object for user login.
/// </summary>
public class LogInUserRequest
{
[Required(ErrorMessage = "The Email field is required.")]
public required string Email { get; set; }
[Required(ErrorMessage = "The Password field is required.")]
public required string Password { get; set; }
}
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Auth.Request;
/// <summary>
/// Request to login using a magic-link token.
/// </summary>
public sealed class MagicLinkLoginRequest
{
[Required(ErrorMessage = "The Email field is required.")]
[EmailAddress(ErrorMessage = "Invalid email format.")]
public required string Email { get; set; }
[Required(ErrorMessage = "The Token field is required.")]
public required string Token { get; set; }
}
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Auth.Request;
/// <summary>
/// Request to generate a magic-link login token.
/// </summary>
public sealed class MagicLinkRequest
{
[Required(ErrorMessage = "The Email field is required.")]
[EmailAddress(ErrorMessage = "Invalid email format.")]
public required string Email { get; set; }
}
@@ -0,0 +1,23 @@
using Application.Utils.Constants.Validation;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Auth.Request;
/// <summary>
/// Payload for confirming a password reset from the email link.
/// </summary>
public sealed class PasswordResetConfirmRequest
{
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
/// <summary>
/// Token received via email, URL-decoded automatically by the framework.
/// </summary>
[Required]
public string Token { get; set; } = string.Empty;
[Required, MinLength(UserFieldLengths.PasswordMinLength)]
public string NewPassword { get; set; } = string.Empty;
}
@@ -0,0 +1,12 @@
namespace Application.DTOs.Auth.Request;
/// <summary>
/// Represents a request object for refreshing a JWT token.
/// </summary>
public class RefreshTokenRequest
{
/// <summary>
/// The refresh token to use for generating a new JWT token.
/// </summary>
public required string RefreshToken { get; set; }
}
@@ -0,0 +1,36 @@
using Application.Utils.Constants.Validation;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Auth.Request;
/// <summary>
/// Request to register a new user. Password is generated automatically by the system.
/// </summary>
public sealed class RegisterUserRequest
{
[Required(ErrorMessage = "Email is required.")]
[EmailAddress(ErrorMessage = "Invalid email format.")]
public required string Email { get; set; }
/// <summary>
/// Display name for the user; must be unique across all accounts.
/// </summary>
[Required(ErrorMessage = "Username is required.")]
[StringLength(UserFieldLengths.UsernameMaxLength, MinimumLength = UserFieldLengths.UsernameMinLength,
ErrorMessage = "Username must be between 3 and 50 characters.")]
public required string Username { get; set; }
/// <summary>
/// Optional contact phone number.
/// </summary>
[ArgentinePhoneNumber]
[MaxLength(UserFieldLengths.PhoneMaxLength, ErrorMessage = "Phone number must not exceed 15 characters.")]
public string? Phone { get; set; }
/// <summary>
/// Target role. Accepted values: ADMIN, OWNER.
/// </summary>
[Required(ErrorMessage = "Role is required.")]
public required string Role { get; set; }
}
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Auth.Request;
/// <summary>
/// Self-service request to receive a password-reset magic link by email.
/// </summary>
public sealed class RequestPasswordResetRequest
{
[Required(ErrorMessage = "The Email field is required.")]
[EmailAddress(ErrorMessage = "Invalid email format.")]
public required string Email { get; set; }
}
@@ -0,0 +1,13 @@
using System;
namespace Application.DTOs.Auth.Response;
/// <summary>
/// Confirmation payload returned after inviting a user by email; the account has no password until activation.
/// </summary>
public sealed class InviteUserResponse(Guid userId, string email, string role)
{
public Guid UserId { get; init; } = userId;
public string Email { get; init; } = email;
public string Role { get; init; } = role;
}
@@ -0,0 +1,10 @@
namespace Application.DTOs.Auth.Response;
/// <summary>
/// Response for magic-link request flow.
/// </summary>
public sealed class MagicLinkResponse(string message, string? magicLink = null)
{
public string Message { get; init; } = message;
public string? MagicLink { get; init; } = magicLink;
}
@@ -0,0 +1,15 @@
using System;
namespace Application.DTOs.Auth.Response;
/// <summary>
/// Confirmation payload returned after successfully registering a new user.
/// </summary>
public sealed class RegisterUserResponse(Guid userId, string email, string username, string role, string? phone)
{
public Guid UserId { get; init; } = userId;
public string Email { get; init; } = email;
public string Username { get; init; } = username;
public string Role { get; init; } = role;
public string? PhoneNumber { get; init; } = phone;
}
@@ -0,0 +1,21 @@
using System;
namespace Application.DTOs.Auth.Response;
/// <summary>
/// Represents the response containing access token, expiration time, and refresh token.
/// </summary>
public sealed class TokenResponse(string accessToken, TimeSpan expiresIn, string? refreshToken)
{
/// <summary>
/// The access token used for authorization.
/// </summary>
public string AccessToken { get; init; } = accessToken;
public TimeSpan ExpiresIn { get; init; } = expiresIn;
/// <summary>
/// The refresh token used to obtain a new access token.
/// </summary>
public string? RefreshToken { get; init; } = refreshToken;
}
@@ -0,0 +1,18 @@
using Domain.Entities.Models;
using System;
namespace Application.DTOs.Backup.Response;
/// <summary>
/// Response projection of a catalogued BackupRecord, with Origin serialized as its enum name.
/// </summary>
public sealed record BackupRecordResponse(
Guid Id, DateTime CreatedAt, long SizeBytes, string Origin, string StoragePath)
{
public static BackupRecordResponse FromEntity(BackupRecord record)
{
return new BackupRecordResponse(
record.Id, record.DateCreated, record.SizeBytes, record.Origin.ToString(), record.StoragePath);
}
}
@@ -0,0 +1,8 @@
using System;
namespace Application.DTOs.Backup.Response;
/// <summary>
/// The current maintenance window; Reason and EnteredAtUtc are null when IsActive is false.
/// </summary>
public sealed record MaintenanceStatusResponse(bool IsActive, string? Reason, DateTimeOffset? EnteredAtUtc);
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Http;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.BlogPosts.Request;
/// <summary>
/// Represents a request to create a new blog post, including file upload for the photo.
/// </summary>
public class CreateBlogPostRequest
{
[Required]
[MaxLength(50)]
public required string Author { get; set; }
[Required]
public required string Title { get; set; }
public IFormFile? PhotoFile { get; set; }
[Required]
public required string MarkdownText { get; set; }
/// <summary>
/// Whether the post is published or a draft; defaults to true so existing create flows are unaffected.
/// </summary>
public bool IsPublished { get; set; } = true;
}
@@ -0,0 +1,23 @@
using Application.DTOs.Abstract.Request;
namespace Application.DTOs.BlogPosts.Request;
/// <summary>
/// Represents a request to get filtered blog posts.
/// </summary>
public class GetBlogPostsFilteredRequest : PaginatedFilterRequest
{
/// <summary>
/// Overrides the base PaginatedFilterRequest default to sort newest-created-first.
/// </summary>
public GetBlogPostsFilteredRequest()
{
// String literal matches EntityBase.DateCreated; QueryableExtensions.SortBy resolves this name against the entity by reflection.
OrderBy = "DateCreated";
Order = SortOrder.Descending;
}
public string? Author { get; set; }
public string? Title { get; set; }
}
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Http;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.BlogPosts.Request;
/// <summary>
/// Represents a request to update the photo for a blog post.
/// </summary>
public class UpdateBlogPostPhotoRequest
{
[Required]
public required IFormFile PhotoFile { get; set; }
}
@@ -0,0 +1,28 @@
namespace Application.DTOs.BlogPosts.Request;
/// <summary>
/// Represents a request to update a blog post.
/// </summary>
public class UpdateBlogPostRequest
{
/// <summary>
/// Null leaves the current title unchanged.
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Null leaves the current Markdown content unchanged.
/// </summary>
public string? MarkdownText { get; set; }
/// <summary>
/// Null leaves the current author unchanged.
/// </summary>
public string? Author { get; set; }
/// <summary>
/// The updated publication state; null leaves it unchanged, true publishes, false reverts to a draft.
/// </summary>
public bool? IsPublished { get; set; }
}
@@ -0,0 +1,33 @@
using Application.DTOs.Abstract.Response;
using System;
namespace Application.DTOs.BlogPosts.Response;
/// <summary>
/// Represents the response data for a blog post.
/// </summary>
public class BlogPostResponse : BaseEntityResponse
{
public required string Author { get; set; }
public required string Title { get; set; }
/// <summary>
/// The unique, URL-friendly identifier used in public blog post links.
/// </summary>
public required string Slug { get; set; }
public int Views { get; set; }
public string? PhotoUrl { get; set; }
public required string MarkdownText { get; set; }
public required DateTime CreatedAt { get; set; }
/// <summary>
/// Whether the post is published and visible publicly, or is a draft.
/// </summary>
public bool IsPublished { get; set; }
}
@@ -0,0 +1,49 @@
using System;
namespace Application.DTOs.Champions.Response;
/// <summary>
/// One row of the champions history for a finished division, with context to render a table across seasons.
/// </summary>
public class ChampionHistoryResponse
{
/// <summary>
/// The id of the tournament the champion was crowned in.
/// </summary>
public required Guid TournamentId { get; set; }
/// <summary>
/// The name of the tournament.
/// </summary>
public required string TournamentName { get; set; }
/// <summary>
/// The season name, or null when the tournament is not grouped under any season.
/// </summary>
public string? SeasonName { get; set; }
/// <summary>
/// The season's calendar year, or null when there is no season or year set.
/// </summary>
public int? SeasonYear { get; set; }
/// <summary>
/// The competitive category of the division, Masculine or Feminine.
/// </summary>
public required string Category { get; set; }
/// <summary>
/// The name of the division that was won.
/// </summary>
public required string DivisionName { get; set; }
/// <summary>
/// The name of the sub-cup that was won, or null when the division crowns a single champion.
/// </summary>
public string? CupName { get; set; }
/// <summary>
/// The team that won this cup, in first place.
/// </summary>
public required PodiumTeamResponse ChampionTeam { get; set; }
}
@@ -0,0 +1,39 @@
using System;
namespace Application.DTOs.Champions.Response;
/// <summary>
/// The final podium of a single competition; any place not yet decided is null rather than guessed.
/// </summary>
public class PodiumResponse
{
/// <summary>
/// The id of the division this podium belongs to.
/// </summary>
public required Guid DivisionId { get; set; }
/// <summary>
/// The name of the division this podium belongs to.
/// </summary>
public required string DivisionName { get; set; }
/// <summary>
/// True when the division's champion is decided by a playoff Final rather than by group standings.
/// </summary>
public bool HasPlayoff { get; set; }
/// <summary>
/// The champion in first place, or null when not yet decided.
/// </summary>
public PodiumTeamResponse? First { get; set; }
/// <summary>
/// The runner-up in second place, or null when not yet decided.
/// </summary>
public PodiumTeamResponse? Second { get; set; }
/// <summary>
/// The team in third place, or null when not yet decided.
/// </summary>
public PodiumTeamResponse? Third { get; set; }
}
@@ -0,0 +1,24 @@
using System;
namespace Application.DTOs.Champions.Response;
/// <summary>
/// A lightweight reference to a team occupying a podium place, carrying only what a team badge needs.
/// </summary>
public class PodiumTeamResponse
{
/// <summary>
/// The unique identifier of the team.
/// </summary>
public required Guid TeamId { get; set; }
/// <summary>
/// The display name of the team.
/// </summary>
public required string TeamName { get; set; }
/// <summary>
/// The URL of the team's logo.
/// </summary>
public required string LogoUrl { get; set; }
}
@@ -0,0 +1,11 @@
using System;
namespace Application.DTOs.Club.Request;
/// <summary>
/// Links a club as a squad of the given parent institution club.
/// </summary>
public class LinkClubParentRequest
{
public required Guid ParentClubId { get; set; }
}
@@ -0,0 +1,9 @@
namespace Application.DTOs.Club.Request;
/// <summary>
/// Renames a club. The club's slug (used in its public URL) never changes, only its display name.
/// </summary>
public class RenameClubRequest
{
public required string Name { get; set; }
}
@@ -0,0 +1,10 @@
namespace Application.DTOs.Club.Response;
/// <summary>
/// Outcome of the idempotent club backfill; a second run reports zeros.
/// </summary>
public class ClubBackfillResult
{
public required int ClubsCreated { get; set; }
public required int TeamsLinked { get; set; }
}
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
namespace Application.DTOs.Club.Response;
/// <summary>
/// A club's stable identity plus every per-season team that belongs to it.
/// </summary>
public class ClubHistoryResponse
{
public required Guid Id { get; set; }
public required string Name { get; set; }
public required string Slug { get; set; }
public string? LogoUrl { get; set; }
/// <summary>
/// The per-season teams that make up this club's history.
/// </summary>
public required List<ClubTeamSeasonResponse> Teams { get; set; } = [];
/// <summary>
/// The parent institution this club is a squad of, or null when this club has no parent linked.
/// </summary>
public ClubSummaryResponse? ParentClub { get; set; }
/// <summary>
/// Other squads linked to this club as their parent institution, empty when this club has none.
/// </summary>
public required List<ClubSummaryResponse> ChildClubs { get; set; } = [];
}
@@ -0,0 +1,17 @@
using System;
namespace Application.DTOs.Club.Response;
/// <summary>
/// One season a club's team participated in.
/// </summary>
public class ClubSeasonResponse
{
public required Guid TournamentId { get; set; }
public string? TournamentName { get; set; }
/// <summary>
/// Sort key only, not displayed; defaults to the minimum date so unresolved tournaments sort last.
/// </summary>
public DateTime StartDate { get; set; }
}
@@ -0,0 +1,14 @@
using System;
namespace Application.DTOs.Club.Response;
/// <summary>
/// A minimal club identity, used for pickers and for referencing a related club (parent/child) without pulling in its full season history.
/// </summary>
public class ClubSummaryResponse
{
public required Guid Id { get; set; }
public required string Name { get; set; }
public required string Slug { get; set; }
public string? LogoUrl { get; set; }
}
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace Application.DTOs.Club.Response;
/// <summary>
/// One per-season team row belonging to a club, with the seasons it participated in.
/// </summary>
public class ClubTeamSeasonResponse
{
public required Guid TeamId { get; set; }
public required string Name { get; set; }
public required string Slug { get; set; }
public required string ThreeLetterCode { get; set; }
/// <summary>
/// The tournaments this team was registered in, sourced from its registration records.
/// </summary>
public required List<ClubSeasonResponse> Seasons { get; set; } = [];
}
@@ -0,0 +1,14 @@
namespace Application.DTOs.DataMaintenance.Response;
/// <summary>
/// Row counts created by the sample-data seed, for the admin UI's success summary.
/// </summary>
public sealed record DataSeedResult(
int Tournaments,
int Divisions,
int Teams,
int Players,
int Matches,
int PlayerSanctions,
int BlogPosts
);
@@ -0,0 +1,21 @@
namespace Application.DTOs.DataMaintenance.Response;
/// <summary>
/// Row counts removed by the sample-data wipe, for the admin UI's success summary.
/// </summary>
public sealed record DataWipeResult(
int Tournaments,
int Divisions,
int Teams,
int Players,
int Matches,
int MatchSeries,
int PlayerSanctions,
int PlayerStatistics,
int Scorers,
int StageTeamMatches,
int PlayerTeamRegistrations,
int Stages,
int Venues,
int BlogPosts
);
@@ -0,0 +1,54 @@
using Application.Utils.Constants.Validation;
using Domain.Enums;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Divisions.Request;
/// <summary>
/// Represents a request to create a division.
/// </summary>
public class CreateDivisionRequest
{
[Required(ErrorMessage = "The Name field is required.")]
[MaxLength(DivisionFieldLengths.NameMaxLength)]
public required string Name { get; set; }
[Required(ErrorMessage = "The TournamentId field is required.")]
public required Guid TournamentId { get; set; }
/// <summary>
/// Marks this division as a cross-division cup that intentionally draws teams from every division.
/// </summary>
public bool IsCrossDivisionCup { get; set; } = false;
/// <summary>
/// Points awarded for a win in this division's standings; defaults to 2.
/// </summary>
[Range(0, int.MaxValue, ErrorMessage = "PointsForWin cannot be negative.")]
public int PointsForWin { get; set; } = 2;
/// <summary>
/// Points awarded for a loss in this division's standings; defaults to 1.
/// </summary>
[Range(0, int.MaxValue, ErrorMessage = "PointsForLoss cannot be negative.")]
public int PointsForLoss { get; set; } = 1;
/// <summary>
/// How many teams qualify from each internal group of a multi-group cross-division cup; defaults to 1.
/// </summary>
[Range(1, int.MaxValue, ErrorMessage = "QualifiersPerGroup must be at least 1.")]
public int QualifiersPerGroup { get; set; } = 1;
/// <summary>
/// Competitive category of the division, which must match the parent tournament's category.
/// </summary>
public TournamentCategory Category { get; set; } = TournamentCategory.Masculine;
/// <summary>
/// Optional position-range to playoff-destination mapping the wizard sends to seed multiple cups.
/// </summary>
public List<PlayoffMappingRequest>? PlayoffMappings { get; set; }
}
@@ -0,0 +1,9 @@
using System;
using System.Collections.Generic;
namespace Application.DTOs.Divisions.Request;
public class EnrollTeamsRequest
{
public List<Guid> TeamIds { get; set; } = [];
}
@@ -0,0 +1,16 @@
using Application.DTOs.Abstract.Request;
using System;
namespace Application.DTOs.Divisions.Request;
/// <summary>
/// Represents a request to get filtered divisions.
/// </summary>
public class GetDivisionsFilteredRequest : PaginatedFilterRequest
{
public string? Name { get; set; }
public bool? IsFinished { get; set; }
public Guid? TournamentId { get; set; }
}
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Divisions.Request;
/// <summary>
/// One position-range to playoff-destination entry the wizard sends for a division; ranges must not overlap.
/// </summary>
public class PlayoffMappingRequest
{
/// <summary>
/// First standings position in the range, 1-based and inclusive.
/// </summary>
[Range(1, int.MaxValue, ErrorMessage = "FromPosition must be 1 or greater.")]
public required int FromPosition { get; set; }
/// <summary>
/// Last standings position in the range, 1-based and inclusive.
/// </summary>
[Range(1, int.MaxValue, ErrorMessage = "ToPosition must be 1 or greater.")]
public required int ToPosition { get; set; }
/// <summary>
/// The playoff cup the teams in this range qualify for, matching an elimination stage's BracketName.
/// </summary>
[Required(ErrorMessage = "The Destination field is required.")]
public required string Destination { get; set; }
}
@@ -0,0 +1,12 @@
using System;
namespace Application.DTOs.Divisions.Request;
public class ReassignTeamToSubGroupRequest
{
public required Guid TeamId { get; set; }
public required Guid FromStageId { get; set; }
public required Guid ToStageId { get; set; }
}
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Divisions.Request;
public class RebuildSubGroupsRequest
{
[Range(1, int.MaxValue, ErrorMessage = "SubGroupCount must be at least 1.")]
public required int SubGroupCount { get; set; }
}
@@ -0,0 +1,9 @@
using System;
using System.Collections.Generic;
namespace Application.DTOs.Divisions.Request;
public class UnenrollTeamsRequest
{
public List<Guid> TeamIds { get; set; } = [];
}
@@ -0,0 +1,22 @@
using Application.Utils.Constants.Validation;
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Divisions.Request;
/// <summary>
/// Represents a request to create a division.
/// </summary>
public class UpdateDivisionRequest
{
[Required(ErrorMessage = "The Name field is required.")]
[MaxLength(DivisionFieldLengths.NameMaxLength)]
public required string Name { get; set; }
public required bool IsFinished { get; set; }
/// <summary>
/// When provided, moves the division and everything under it to this tournament; null keeps it in place.
/// </summary>
public Guid? TournamentId { get; set; }
}
@@ -0,0 +1,25 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.Match.Response;
using System;
using System.Collections.Generic;
namespace Application.DTOs.Divisions.Response;
/// <summary>
/// Represents a response for a division, inheriting from the base response.
/// </summary>
public class DetailedDivisionResponse : BaseEntityResponse
{
public required string Name { get; set; }
public required bool IsFinished { get; set; }
public required IEnumerable<PositionResponse> Positions { get; set; }
public required Guid TournamentId { get; set; }
/// <summary>
/// The matches grouped by week in the division.
/// </summary>
public required IDictionary<int, IEnumerable<MinimalMatchResponse>> MatchesByWeek { get; set; }
}
@@ -0,0 +1,74 @@
using Domain.Enums;
using System;
using System.Collections.Generic;
namespace Application.DTOs.Divisions.Response;
/// <summary>
/// The response structure for a division, including its matches and positions.
/// </summary>
public class DivisionResponse
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>
/// The unique, URL-friendly identifier used in public division links.
/// </summary>
public string Slug { get; set; } = string.Empty;
public bool IsFinished { get; set; }
/// <summary>
/// For a multi-group cup this is the pooled union across every group; use GroupStandings for per-group tables.
/// </summary>
public List<PositionResponse>? Positions { get; set; }
/// <summary>
/// One standings table per Group stage, null or empty when the division has no Group stage yet.
/// </summary>
public List<GroupStandingsResponse>? GroupStandings { get; set; }
public Guid TournamentId { get; set; }
/// <summary>
/// The parent tournament's slug, when loaded; null otherwise, for building a clean tournament link.
/// </summary>
public string? TournamentSlug { get; set; }
/// <summary>
/// Whether this division is a cross-division cup that intentionally draws teams from every division.
/// </summary>
public bool IsCrossDivisionCup { get; set; }
/// <summary>
/// Competitive category of the division, always matching the parent tournament's category.
/// </summary>
public TournamentCategory Category { get; set; }
/// <summary>
/// Points awarded for a win in this division's standings.
/// </summary>
public int PointsForWin { get; set; }
/// <summary>
/// Points awarded for a loss in this division's standings.
/// </summary>
public int PointsForLoss { get; set; }
/// <summary>
/// How many teams qualify from each internal group of a multi-group cup; defaults to 1.
/// </summary>
public int QualifiersPerGroup { get; set; }
/// <summary>
/// The division's position-range to playoff-destination mapping.
/// </summary>
public List<PlayoffMappingResponse>? PlayoffMappings { get; set; }
/// <summary>
/// The standings-position ranges that qualify to a playoff cup, ordered top-down; empty when none exist.
/// </summary>
public List<QualificationRangeResponse>? QualificationRanges { get; set; }
}
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace Application.DTOs.Divisions.Response;
/// <summary>
/// The standings of a single Group stage; a multi-group division sends one entry per internal group.
/// </summary>
public class GroupStandingsResponse
{
/// <summary>
/// The id of the Group stage these standings belong to.
/// </summary>
public Guid StageId { get; set; }
/// <summary>
/// The Group stage's name, used as the table label.
/// </summary>
public string StageName { get; set; } = string.Empty;
/// <summary>
/// The ordered standings for the teams in this group.
/// </summary>
public List<PositionResponse> Positions { get; set; } = [];
}
@@ -0,0 +1,12 @@
using Application.DTOs.Abstract.Response;
namespace Application.DTOs.Divisions.Response;
/// <summary>
/// Represents a minimal response for a tournament.
/// </summary>
public class MinimalDivisionResponse : BaseEntityResponse
{
public required string Name { get; set; }
public required bool IsFinished { get; set; }
}
@@ -0,0 +1,14 @@
using System;
namespace Application.DTOs.Divisions.Response;
/// <summary>
/// One position-range to playoff-destination entry of a division.
/// </summary>
public class PlayoffMappingResponse
{
public Guid Id { get; set; }
public int FromPosition { get; set; }
public int ToPosition { get; set; }
public string Destination { get; set; } = string.Empty;
}
@@ -0,0 +1,40 @@
using System;
using Application.DTOs.PointDeductions.Response;
namespace Application.DTOs.Divisions.Response;
/// <summary>
/// Represents the position of a team in a division, including various match statistics.
/// </summary>
public class PositionResponse
{
public required Guid TeamId { get; set; }
public required string TeamName { get; set; }
public required string LogoUrl { get; set; }
public required int MatchesPlayed { get; set; }
public required int Wins { get; set; }
public required int Losses { get; set; }
public required int PointsFor { get; set; }
public required int PointsAgainst { get; set; }
public required int PointsDifference { get; set; }
/// <summary>
/// The total points accumulated by the team, with any disciplinary deduction already subtracted.
/// </summary>
public required int Points { get; set; }
/// <summary>
/// The disciplinary point deduction applied to this team, or null when there is none.
/// </summary>
public AppliedPointDeductionResponse? PointDeduction { get; set; }
}
@@ -0,0 +1,27 @@
namespace Application.DTOs.Divisions.Response;
/// <summary>
/// One standings-position range that qualifies to a playoff cup, derived from the division's mapping set.
/// </summary>
public class QualificationRangeResponse
{
/// <summary>
/// First standings position in the range, 1-based and inclusive.
/// </summary>
public int FromPosition { get; set; }
/// <summary>
/// Last standings position in the range, 1-based and inclusive.
/// </summary>
public int ToPosition { get; set; }
/// <summary>
/// The cup the teams in this range qualify for.
/// </summary>
public string CupName { get; set; } = string.Empty;
/// <summary>
/// The cup's rank, top-down, where 0 is the top cup; drives the color the frontend paints each range.
/// </summary>
public int Order { get; set; }
}
@@ -0,0 +1,30 @@
using Domain.Enums;
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Match.Request;
/// <summary>
/// Represents a request to create a match.
/// </summary>
public class CreateMatchRequest
{
[Required(ErrorMessage = "The MatchDate field is required.")]
public required DateTime MatchDate { get; set; }
[AllowedValues(MatchType.Regular, MatchType.Playoff)]
public MatchType? Type { get; set; } = MatchType.Regular;
[Required(ErrorMessage = "The HomeTeamId field is required.")]
public required Guid HomeTeamId { get; set; }
[Required(ErrorMessage = "The VisitorTeamId field is required.")]
public required Guid VisitorTeamId { get; set; }
[Required(ErrorMessage = "The DivisionId field is required.")]
public required Guid StageId { get; set; }
public Guid? VenueId { get; set; }
}
@@ -0,0 +1,27 @@
using Application.DTOs.Abstract.Request;
using Domain.Enums;
using System;
namespace Application.DTOs.Match.Request;
/// <summary>
/// Represents a request to filter and paginate matches.
/// </summary>
public class GetMatchesFilteredRequest : PaginatedFilterRequest
{
public string? HomeTeamName { get; set; }
public string? VisitorTeamName { get; set; }
public Guid? StageId { get; set; }
public Guid? DivisionId { get; set; }
public Guid? TournamentId { get; set; }
public MatchType? Type { get; set; }
public bool? IsFinished { get; set; }
}
@@ -0,0 +1,22 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Match.Request;
/// <summary>
/// Request to mark a match as a walkover, awarding the present team the regulation default result.
/// </summary>
public class LoadWalkOverRequest
{
/// <summary>
/// The walkover winner; must be one of the match's two teams.
/// </summary>
[Required]
public required Guid PresentTeamId { get; set; }
/// <summary>
/// Optional override for the present team's awarded score; the regulation default is used when omitted.
/// </summary>
[Range(0, int.MaxValue, ErrorMessage = "The PresentTeamScore must be a non-negative number.")]
public int? PresentTeamScore { get; set; }
}
@@ -0,0 +1,14 @@
using System;
namespace Application.DTOs.Match.Request;
/// <summary>
/// Request to suspend a match and optionally reschedule it, never changing its round.
/// </summary>
public class SuspendMatchRequest
{
/// <summary>
/// Optional new date and time for the match; null suspends it in place without rescheduling.
/// </summary>
public DateTime? MatchDate { get; set; }
}
@@ -0,0 +1,13 @@
using System;
namespace Application.DTOs.Match.Request;
/// <summary>
/// Request to reschedule a match; edits only the calendar date and venue.
/// </summary>
public class UpdateMatchRequest
{
public DateTime? MatchDate { get; set; }
public Guid? VenueId { get; set; }
}
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Match.Request;
/// <summary>
/// Represents a request to update the score of a match.
/// </summary>
public class UpdateMatchScoreRequest
{
[Required]
[Range(0, int.MaxValue, ErrorMessage = "The HomeScore must be a non-negative number.")]
public required int HomeScore { get; set; }
[Required]
[Range(0, int.MaxValue, ErrorMessage = "The VisitorScore must be a non-negative number.")]
public required int VisitorScore { get; set; }
}
@@ -0,0 +1,56 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.Team.Response;
using Application.DTOs.Venue.Response;
using System;
namespace Application.DTOs.Match.Response;
/// <summary>
/// Represents the response data for a match.
/// </summary>
public class DetailedMatchResponse : BaseEntityResponse
{
public required DateTime MatchDate { get; set; }
/// <summary>
/// The 1-based matchday this match belongs to; null for stages with no round-robin matchday.
/// </summary>
public int? Round { get; set; }
/// <summary>
/// The type of the match, regular or playoff.
/// </summary>
public required string MatchType { get; set; }
/// <summary>
/// The unique, URL-friendly identifier used in public match links.
/// </summary>
public required string Slug { get; set; }
public TeamDetailedMatchResponse? HomeTeam { get; set; }
public TeamDetailedMatchResponse? VisitorTeam { get; set; }
public VenueResponse? Venue { get; set; }
public required bool IsFinished { get; set; }
/// <summary>
/// The result lifecycle state: Scheduled, Played, Suspended, or WalkOver.
/// </summary>
public string? Status { get; set; }
public bool WentToOvertime { get; set; }
public string? WinningTeamName { get; set; }
public Guid? WinningTeamId { get; set; }
public Guid? StageId { get; set; }
/// <summary>
/// The id of the tournament this match belongs to, letting the public page link back to it directly.
/// </summary>
public Guid? TournamentId { get; set; }
}
@@ -0,0 +1,40 @@
using Application.DTOs.Abstract.Response;
using System;
namespace Application.DTOs.Match.Response;
/// <summary>
/// Represents the minimal response data for a match, tailored for divisions.
/// </summary>
public class MinimalMatchResponse : BaseEntityResponse
{
public required DateTime MatchDate { get; set; }
/// <summary>
/// The 1-based matchday this match belongs to; null for stages with no round-robin matchday.
/// </summary>
public int? Round { get; set; }
public required string HomeTeamName { get; set; }
public required string VisitorTeamName { get; set; }
public int? HomeScore { get; set; }
public int? VisitorScore { get; set; }
public string? WinningTeamName { get; set; }
public required bool IsFinished { get; set; }
/// <summary>
/// The result lifecycle state: Scheduled, Played, Suspended, or WalkOver.
/// </summary>
public string? Status { get; set; }
/// <summary>
/// The type of the match, regular or playoff.
/// </summary>
public required string MatchType { get; set; }
}
@@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace Application.DTOs.Match.Response;
/// <summary>
/// A single matchday and its matches, letting the frontend group the fixture by round instead of date.
/// </summary>
public class RoundMatchesResponse
{
/// <summary>
/// The 1-based round number; null groups matches with no round-robin matchday.
/// </summary>
public int? Round { get; set; }
/// <summary>
/// The matches played in this round, in a stable order.
/// </summary>
public List<DetailedMatchResponse> Matches { get; set; } = [];
}
@@ -0,0 +1,15 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.MatchSeries.Request;
/// <summary>
/// Payload for scheduling the next game of an existing series; GameNumber and teams are derived server-side.
/// </summary>
public class AddGameToSeriesRequest
{
[Required(ErrorMessage = "Match date field is required.")]
public required DateTime MatchDate { get; set; }
public Guid? VenueId { get; set; }
}
@@ -0,0 +1,22 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.MatchSeries.Request;
/// <summary>
/// Payload for creating a new best-of-N playoff series between two teams at one bracket round.
/// </summary>
public class CreateMatchSeriesRequest
{
/// <summary>
/// The stage this series belongs to; its BestOf value is copied onto the series at creation time.
/// </summary>
[Required(ErrorMessage = "Stage ID field is required.")]
public required Guid StageId { get; set; }
[Required(ErrorMessage = "Home team ID field is required.")]
public required Guid HomeTeamId { get; set; }
[Required(ErrorMessage = "Visitor team ID field is required.")]
public required Guid VisitorTeamId { get; set; }
}
@@ -0,0 +1,13 @@
using Application.DTOs.Abstract.Request;
using System;
namespace Application.DTOs.MatchSeries.Request;
/// <summary>
/// Represents a request to filter and paginate playoff series.
/// </summary>
public class GetMatchSeriesFilteredRequest : PaginatedFilterRequest
{
public Guid? StageId { get; set; }
}
@@ -0,0 +1,42 @@
using Application.DTOs.Abstract.Response;
using System;
using System.Collections.Generic;
namespace Application.DTOs.MatchSeries.Response;
/// <summary>
/// A best-of-N playoff series between two teams at one bracket round, including its individual games.
/// </summary>
public class MatchSeriesResponse : BaseEntityResponse
{
public required Guid StageId { get; set; }
public required Guid HomeTeamId { get; set; }
public required string HomeTeamName { get; set; }
public required Guid VisitorTeamId { get; set; }
public required string VisitorTeamName { get; set; }
/// <summary>
/// Number of games in this series, one of 1, 3, 5, or 7.
/// </summary>
public required int BestOf { get; set; }
/// <summary>
/// The id of the winning team, set once one team has won the majority of the series' games.
/// </summary>
public Guid? WinningTeamId { get; set; }
/// <summary>
/// The name of the winning team, if the series has been decided.
/// </summary>
public string? WinningTeamName { get; set; }
/// <summary>
/// The individual games played so far in this series.
/// </summary>
public List<SeriesGameResponse> Games { get; set; } = [];
}
@@ -0,0 +1,14 @@
using Application.DTOs.Match.Response;
namespace Application.DTOs.MatchSeries.Response;
/// <summary>
/// A single game within a best-of-N series, with its position in the series alongside the match details.
/// </summary>
public class SeriesGameResponse : MinimalMatchResponse
{
/// <summary>
/// The game's 1-based position within the series.
/// </summary>
public required int GameNumber { get; set; }
}
@@ -0,0 +1,40 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.MedicalRecord.Request;
/// <summary>
/// Owner or admin request to approve or reject a player's medical record for a team and tournament.
/// </summary>
public class ReviewMedicalRecordRequest
{
/// <summary>
/// The player whose medical record is being reviewed.
/// </summary>
[Required]
public required Guid PlayerId { get; set; }
/// <summary>
/// The team the player is registered to for the season.
/// </summary>
[Required]
public required Guid TeamId { get; set; }
/// <summary>
/// The tournament the record applies to.
/// </summary>
[Required]
public required Guid TournamentId { get; set; }
/// <summary>
/// True to approve and make the player habilitado; false to reject.
/// </summary>
[Required]
public required bool Approve { get; set; }
/// <summary>
/// Optional reason, typically recorded when rejecting.
/// </summary>
[MaxLength(500)]
public string? Reason { get; set; }
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Http;
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.MedicalRecord.Request;
/// <summary>
/// Multipart request to upload a player's medical-record PDF for a specific team and tournament.
/// </summary>
public class UploadMedicalRecordRequest
{
/// <summary>
/// The player the medical record belongs to.
/// </summary>
[Required]
public required Guid PlayerId { get; set; }
/// <summary>
/// The team the player is registered to for the season.
/// </summary>
[Required]
public required Guid TeamId { get; set; }
/// <summary>
/// The tournament the record applies to.
/// </summary>
[Required]
public required Guid TournamentId { get; set; }
/// <summary>
/// The medical-record PDF to upload.
/// </summary>
[Required]
public required IFormFile File { get; set; }
}
@@ -0,0 +1,65 @@
using Domain.Entities.Models;
using Domain.Enums;
using System;
namespace Application.DTOs.MedicalRecord.Response;
/// <summary>
/// The medical-record eligibility state of a player's season registration, flagging not-habilitado players.
/// </summary>
public class MedicalRecordResponse
{
public required Guid PlayerId { get; set; }
public required Guid TeamId { get; set; }
public required Guid TournamentId { get; set; }
/// <summary>
/// The medical-record status: Pending, Approved, or Rejected.
/// </summary>
public required MedicalRecordStatus Status { get; set; }
/// <summary>
/// True only when the record is Approved.
/// </summary>
public required bool IsHabilitado { get; set; }
/// <summary>
/// Storage reference of the uploaded file, or null if none yet.
/// </summary>
public string? FileUrl { get; set; }
/// <summary>
/// Original uploaded file name, or null if none yet.
/// </summary>
public string? FileName { get; set; }
/// <summary>
/// Reason recorded on rejection, if any.
/// </summary>
public string? ReviewReason { get; set; }
/// <summary>
/// When the record was last approved or rejected, if ever.
/// </summary>
public DateTime? ReviewedAt { get; set; }
/// <summary>
/// Projects a PlayerTeamRegistration into its medical-record view.
/// </summary>
public static MedicalRecordResponse FromRegistration(PlayerTeamRegistration registration)
{
return new MedicalRecordResponse
{
PlayerId = registration.PlayerId,
TeamId = registration.TeamId,
TournamentId = registration.TournamentId,
Status = registration.MedicalRecordStatus,
IsHabilitado = registration.IsHabilitado,
FileUrl = registration.MedicalRecordFileUrl,
FileName = registration.MedicalRecordFileName,
ReviewReason = registration.MedicalRecordReviewReason,
ReviewedAt = registration.MedicalRecordReviewedAt,
};
}
}
@@ -0,0 +1,50 @@
using Application.Utils.Constants.Validation;
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Player.Request;
/// <summary>
/// Represents a request to create a Player.
/// </summary>
public class CreatePlayerRequest
{
[Required(ErrorMessage = "The First Name field is required.")]
[MaxLength(PlayerFieldLengths.NameMaxLength, ErrorMessage = "The Name field must not exceed 70 characters.")]
public required string FirstName { get; set; }
[MaxLength(PlayerFieldLengths.NameMaxLength, ErrorMessage = "The Second Name field must not exceed 70 characters.")]
public string? SecondName { get; set; } = null;
[Required(ErrorMessage = "The LastName field is required.")]
[MaxLength(PlayerFieldLengths.NameMaxLength, ErrorMessage = "The LastName field must not exceed 70 characters.")]
public required string LastName { get; set; }
[Required(ErrorMessage = "The DocumentNumber field is required.")]
[MaxLength(PlayerFieldLengths.DocumentNumberMaxLength, ErrorMessage = "The DocumentNumber field must not exceed 15 characters.")]
[RegularExpression(ValidationPatterns.DocumentNumber, ErrorMessage = ValidationPatterns.DocumentNumberError)]
public required string DocumentNumber { get; set; }
/// <summary>
/// The birthdate of the player. Must be at least 15 years ago.
/// </summary>
[Required(ErrorMessage = "The BirthDate field is required.")]
[MinimumAge(15)]
public required DateTime BirthDate { get; set; }
[Required(ErrorMessage = "The PhoneNumber field is required.")]
[MaxLength(PlayerFieldLengths.PhoneNumberMaxLength, ErrorMessage = "The PhoneNumber field must not exceed 15 characters.")]
[ArgentinePhoneNumber]
public required string PhoneNumber { get; set; }
/// <summary>
/// The player's health insurance provider or social work plan.
/// </summary>
[Required(ErrorMessage = "The SocialSecurity field is required.")]
[MaxLength(PlayerFieldLengths.SocialSecurityMaxLength, ErrorMessage = "The SocialSecurity field must not exceed 100 characters.")]
public required string SocialSecurity { get; set; }
[Required(ErrorMessage = "The TeamId field is required.")]
public required Guid TeamId { get; set; }
}
@@ -0,0 +1,22 @@
using System;
namespace Application.DTOs.Player.Request;
/// <summary>
/// Represents a request to get filtered players for admin access.
/// </summary>
public class GetPlayersFilteredRequest : PlayerFilterRequestBase
{
public string? DocumentNumber { get; set; }
public DateTime? BirthDate { get; set; }
public string? PhoneNumber { get; set; }
public string? SocialSecurity { get; set; }
public bool? IsFederated { get; set; }
public string? Club { get; set; }
public string? Category { get; set; }
}
@@ -0,0 +1,17 @@
using Application.DTOs.Abstract.Request;
using System;
namespace Application.DTOs.Player.Request;
/// <summary>
/// Base class for filtering players with common properties.
/// </summary>
public class PlayerFilterRequestBase : PaginatedFilterRequest
{
public string? Names { get; set; }
public string? LastName { get; set; }
public Guid? TeamId { get; set; }
}
@@ -0,0 +1,28 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Player.Request;
/// <summary>
/// Request to register a player onto a team's roster for a tournament season, optionally with a dorsal.
/// </summary>
public class RegisterPlayerToTeamRequest
{
/// <summary>
/// The team to register the player onto.
/// </summary>
[Required]
public required Guid TeamId { get; set; }
/// <summary>
/// The tournament the registration belongs to.
/// </summary>
[Required]
public required Guid TournamentId { get; set; }
/// <summary>
/// The player's jersey number for this team and season, from 0 to 99 inclusive, or null.
/// </summary>
[Range(0, 99, ErrorMessage = "El dorsal debe ser un número entre 0 y 99.")]
public int? JerseyNumber { get; set; }
}
@@ -0,0 +1,51 @@
using Application.Utils.Constants.Validation;
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.Player.Request;
/// <summary>
/// Represents a request to update a Player.
/// </summary>
public class UpdatePlayerRequest
{
[Required(ErrorMessage = "The First Name field is required.")]
[MaxLength(PlayerFieldLengths.NameMaxLength, ErrorMessage = "The Name field must not exceed 70 characters.")]
public required string FirstName { get; set; }
[MaxLength(PlayerFieldLengths.NameMaxLength, ErrorMessage = "The Second Name field must not exceed 70 characters.")]
public string? SecondName { get; set; } = null;
[MaxLength(PlayerFieldLengths.NameMaxLength, ErrorMessage = "The LastName field must not exceed 70 characters.")]
public string? LastName { get; set; }
[MaxLength(PlayerFieldLengths.DocumentNumberMaxLength, ErrorMessage = "The DocumentNumber field must not exceed 15 characters.")]
[RegularExpression(ValidationPatterns.DocumentNumber, ErrorMessage = ValidationPatterns.DocumentNumberError)]
public string? DocumentNumber { get; set; }
/// <summary>
/// The birthdate of the player. Must be at least 15 years ago.
/// </summary>
[MinimumAge(15)]
public DateTime? BirthDate { get; set; }
[MaxLength(PlayerFieldLengths.PhoneNumberMaxLength, ErrorMessage = "The PhoneNumber field must not exceed 15 characters.")]
[ArgentinePhoneNumber]
public string? PhoneNumber { get; set; }
/// <summary>
/// The player's health insurance provider or social work plan.
/// </summary>
[MaxLength(PlayerFieldLengths.SocialSecurityMaxLength, ErrorMessage = "The SocialSecurity field must not exceed 100 characters.")]
public string? SocialSecurity { get; set; }
public Guid? TeamId { get; set; }
public bool? IsFederated { get; set; }
[MaxLength(100)]
public string? Club { get; set; }
[MaxLength(50)]
public string? Category { get; set; }
}
@@ -0,0 +1,31 @@
using System;
namespace Application.DTOs.Player.Response;
/// <summary>
/// Detailed response for an admin to view player information, extending PublicPlayerResponse with sensitive fields.
/// </summary>
public class AdminPlayerResponse : PublicPlayerResponse
{
public required string DocumentNumber { get; set; }
public required DateTime BirthDate { get; set; }
public required string PhoneNumber { get; set; }
/// <summary>
/// The player's health insurance provider or social work plan.
/// </summary>
public required string SocialSecurity { get; set; }
public required bool IsFederated { get; set; }
/// <summary>
/// The club the player is affiliated with; only meaningful when IsFederated is true.
/// </summary>
public required string Club { get; set; }
/// <summary>
/// The player's federation category; only meaningful when IsFederated is true.
/// </summary>
public required string Category { get; set; }
}
@@ -0,0 +1,14 @@
using System;
namespace Application.DTOs.Player.Response;
/// <summary>
/// The outcome of registering a player onto a team's roster for a season.
/// </summary>
public class PlayerRegistrationResponse
{
public required Guid PlayerId { get; set; }
public required Guid TeamId { get; set; }
public required Guid TournamentId { get; set; }
public int? JerseyNumber { get; set; }
}
@@ -0,0 +1,42 @@
using Application.DTOs.Abstract.Response;
using Domain.Enums;
using System;
namespace Application.DTOs.Player.Response;
/// <summary>
/// Represents a response for a Player, inheriting from the base response.
/// </summary>
public class PublicPlayerResponse : BaseEntityResponse
{
public required string FirstName { get; set; }
public required string SecondName { get; set; }
public required string LastName { get; set; }
public required string FullName { get; set; }
/// <summary>
/// The unique, URL-friendly identifier used in public player links.
/// </summary>
public required string Slug { get; set; }
public required Guid TeamId { get; set; }
/// <summary>
/// The player's medical-record status for this season roster; null when not loaded for a specific season.
/// </summary>
public MedicalRecordStatus? MedicalRecordStatus { get; set; }
/// <summary>
/// Whether the player's medical record is Approved for this season roster, letting the frontend flag it.
/// </summary>
public bool IsHabilitado { get; set; }
/// <summary>
/// The player's jersey number for this season roster; null when not loaded or not assigned.
/// </summary>
public int? JerseyNumber { get; set; }
}
@@ -0,0 +1,14 @@
using Application.Utils.Constants.Validation;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.PlayerSanction.Request;
/// <summary>
/// Request DTO for submitting an appeal against a player sanction.
/// </summary>
public class AppealPlayerSanctionRequest
{
[Required]
[StringLength(SanctionFieldLengths.LongTextMaxLength, MinimumLength = SanctionFieldLengths.LongTextMinLength)]
public required string Reason { get; set; }
}
@@ -0,0 +1,76 @@
using Application.Utils.Constants.Validation;
using Domain.Enums;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.PlayerSanction.Request;
/// <summary>
/// Represents a request to create a Player Sanction.
/// </summary>
public class CreatePlayerSanctionRequest : IValidatableObject
{
/// <summary>
/// The duration in fixtures, or fechas, of the sanction.
/// </summary>
[Required(ErrorMessage = "The Duration field is required.")]
public required int Duration { get; set; }
[Required(ErrorMessage = "The IssuedDate field is required.")]
public required DateTime IssuedDate { get; set; }
[Required(ErrorMessage = "The Description field is required.")]
[MaxLength(SanctionFieldLengths.DescriptionMaxLength)]
public required string Description { get; set; }
/// <summary>
/// The kind of subject the sanction targets; defaults to Player so existing clients keep working.
/// </summary>
public SanctionSubjectType SubjectType { get; set; } = SanctionSubjectType.Player;
/// <summary>
/// The sanctioned player's identifier; required when SubjectType is Player.
/// </summary>
public Guid? PlayerId { get; set; }
/// <summary>
/// The sanctioned team's identifier; required when SubjectType is Team.
/// </summary>
public Guid? TeamId { get; set; }
/// <summary>
/// The sanctioned staff member's name; required when SubjectType is Staff.
/// </summary>
[MaxLength(SanctionFieldLengths.DescriptionMaxLength)]
public string? StaffName { get; set; }
public required Guid MatchId { get; set; }
/// <summary>
/// Ensures the identity that matches the chosen subject type is supplied.
/// </summary>
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
switch (SubjectType)
{
case SanctionSubjectType.Player when !PlayerId.HasValue:
yield return new ValidationResult(
"The PlayerId field is required for a player sanction.",
[nameof(PlayerId)]);
break;
case SanctionSubjectType.Team when !TeamId.HasValue:
yield return new ValidationResult(
"The TeamId field is required for a team sanction.",
[nameof(TeamId)]);
break;
case SanctionSubjectType.Staff when string.IsNullOrWhiteSpace(StaffName):
yield return new ValidationResult(
"The StaffName field is required for a staff sanction.",
[nameof(StaffName)]);
break;
}
}
}
@@ -0,0 +1,35 @@
using Application.DTOs.Abstract.Request;
using System;
namespace Application.DTOs.PlayerSanction.Request;
/// <summary>
/// Represents a request to get filtered player sanctions.
/// </summary>
public class GetPlayerSanctionsFilteredRequest : PaginatedFilterRequest
{
/// <summary>
/// Overrides the base PaginatedFilterRequest default to sort newest-issued-first.
/// </summary>
public GetPlayerSanctionsFilteredRequest()
{
// String literal, not nameof, because the enclosing namespace Application.DTOs.PlayerSanction shadows the entity type; QueryableExtensions.SortBy resolves this name against the entity by reflection and matches PlayerSanction.IssuedDate.
OrderBy = "IssuedDate";
Order = SortOrder.Descending;
}
public Guid? PlayerId { get; set; }
public Guid? MatchId { get; set; }
public Guid? TournamentId { get; set; }
public Guid? DivisionId { get; set; }
public Guid? StageId { get; set; }
public Guid? TeamId { get; set; }
public string? Description { get; set; }
public DateTime? IssuedDate { get; set; }
public int? Duration { get; set; }
}
@@ -0,0 +1,17 @@
using Application.Utils.Constants.Validation;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.PlayerSanction.Request;
/// <summary>
/// Request DTO for resolving a player sanction appeal.
/// </summary>
public class ResolveAppealRequest
{
[Required]
public required bool Accepted { get; set; }
[Required]
[StringLength(SanctionFieldLengths.LongTextMaxLength, MinimumLength = SanctionFieldLengths.LongTextMinLength)]
public required string Resolution { get; set; }
}
@@ -0,0 +1,18 @@
using Application.Utils.Constants.Validation;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.PlayerSanction.Request;
/// <summary>
/// Represents a request to update a Player Sanction.
/// </summary>
public class UpdatePlayerSanctionRequest
{
/// <summary>
/// The duration in fixtures of the sanction.
/// </summary>
public int? Duration { get; set; }
[MaxLength(SanctionFieldLengths.DescriptionMaxLength)]
public string? Description { get; set; }
}
@@ -0,0 +1,78 @@
using Application.DTOs.Abstract.Response;
using Domain.Enums;
using System;
namespace Application.DTOs.PlayerSanction.Response;
/// <summary>
/// Represents a response for a Player Sanction.
/// </summary>
public class PlayerSanctionResponse : BaseEntityResponse
{
/// <summary>
/// The duration in fixtures, or fechas, of the sanction.
/// </summary>
public required int Duration { get; set; }
/// <summary>
/// The number of fechas still to be served; zero means fully served, null means it cannot be computed.
/// </summary>
public int? FechasRemaining { get; set; }
/// <summary>
/// Whether the sanction is still active, meaning fechas remain to be served.
/// </summary>
public bool IsActive { get; set; }
public required DateTime IssuedDate { get; set; }
public required string Description { get; set; }
/// <summary>
/// The unique, URL-friendly identifier used in sanction links.
/// </summary>
public required string Slug { get; set; }
/// <summary>
/// The kind of subject the sanction targets: Player, Team, or Staff.
/// </summary>
public SanctionSubjectType SubjectType { get; set; }
/// <summary>
/// The sanctioned player's identifier; null for team or staff sanctions.
/// </summary>
public Guid? PlayerId { get; set; }
/// <summary>
/// The sanctioned player's full name. Null for team or staff sanctions.
/// </summary>
public string? PlayerFullName { get; set; }
/// <summary>
/// The sanctioned team's identifier; null unless this is a team sanction.
/// </summary>
public Guid? TeamId { get; set; }
/// <summary>
/// The sanctioned team's name. Null unless this is a team sanction.
/// </summary>
public string? TeamName { get; set; }
/// <summary>
/// The sanctioned staff member's name. Null unless this is a staff sanction.
/// </summary>
public string? StaffName { get; set; }
public required Guid MatchId { get; set; }
public SanctionAppealStatus AppealStatus { get; set; }
public string? AppealReason { get; set; }
public DateTime? AppealDate { get; set; }
public string? AppealResolution { get; set; }
public DateTime? AppealResolvedDate { get; set; }
}
@@ -0,0 +1,26 @@
using Domain.Enums;
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.PlayerStatistic.Request;
/// <summary>
/// Request DTO for creating a player statistic.
/// </summary>
public class CreatePlayerStatisticRequest
{
[Required]
[Range(1, int.MaxValue, ErrorMessage = "Value must be a non-negative number.")]
public int Value { get; set; }
/// <summary>
/// The type of statistic, Points or Assists, defaulting to Points.
/// </summary>
public StatisticType Type { get; set; } = StatisticType.Points;
[Required]
public Guid MatchId { get; set; }
[Required]
public Guid PlayerId { get; set; }
}
@@ -0,0 +1,26 @@
using Application.DTOs.Abstract.Request;
using Domain.Enums;
using System;
namespace Application.DTOs.PlayerStatistic.Request;
/// <summary>
/// Represents a request to get filtered player statistics.
/// </summary>
public class GetPlayerStatisticsFilteredRequest : PaginatedFilterRequest
{
public Guid? MatchId { get; set; }
public Guid? PlayerId { get; set; }
/// <summary>
/// The team to filter statistics by, matching the statistic's player's team.
/// </summary>
public Guid? TeamId { get; set; }
/// <summary>
/// The type of statistic to filter by, Points or Assists.
/// </summary>
public StatisticType? Type { get; set; }
}
@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.PlayerStatistic.Request;
/// <summary>
/// Request to finish a match by loading both teams' scoring sheets in one coherent operation.
/// </summary>
public class LoadMatchResultFromSheetsRequest
{
/// <summary>
/// The match being finished, assigned server-side from the route rather than sent by the client.
/// </summary>
public Guid MatchId { get; set; }
/// <summary>
/// The home team's per-player points; a player may score 0 and not every roster player must be listed.
/// </summary>
[Required]
public required List<PlayerScoreEntry> HomeScores { get; set; } = [];
/// <summary>
/// The visitor team's per-player points.
/// </summary>
[Required]
public required List<PlayerScoreEntry> VisitorScores { get; set; } = [];
/// <summary>
/// Whether the match was decided in overtime; purely informational and defaults to false.
/// </summary>
public bool WentToOvertime { get; set; }
}
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.PlayerStatistic.Request;
/// <summary>
/// Request to load a whole team's scoring sheet for a match in one coherent operation.
/// </summary>
public class LoadMatchSheetRequest
{
/// <summary>
/// The match whose sheet is being loaded.
/// </summary>
[Required]
public required Guid MatchId { get; set; }
/// <summary>
/// The home or visitor team whose players are being loaded.
/// </summary>
[Required]
public required Guid TeamId { get; set; }
/// <summary>
/// The per-player points, which must add up to the team's score; a player may score 0 and need not be listed.
/// </summary>
[Required]
public required List<PlayerScoreEntry> Scores { get; set; } = [];
}
@@ -0,0 +1,22 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.PlayerStatistic.Request;
/// <summary>
/// A single player's points within a team's match sheet.
/// </summary>
public class PlayerScoreEntry
{
/// <summary>
/// The player who scored.
/// </summary>
[Required]
public required Guid PlayerId { get; set; }
/// <summary>
/// The points the player scored in the match; may be zero.
/// </summary>
[Range(0, int.MaxValue, ErrorMessage = "Points must be a non-negative number.")]
public required int Points { get; set; }
}
@@ -0,0 +1,13 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.PlayerStatistic.Request;
/// <summary>
/// Represents a request to update a player statistic.
/// </summary>
public class UpdatePlayerStatisticRequest
{
[Required]
[Range(0, int.MaxValue, ErrorMessage = "Value must be a non-negative integer.")]
public required int Value { get; set; }
}
@@ -0,0 +1,28 @@
using Application.DTOs.Abstract.Response;
using Domain.Enums;
using System;
namespace Application.DTOs.PlayerStatistic.Response;
/// <summary>
/// Response DTO for player statistic.
/// </summary>
public class PlayerStatisticResponse : BaseEntityResponse
{
public Guid PlayerId { get; set; }
public int Value { get; set; }
/// <summary>
/// The type of statistic, Points or Assists.
/// </summary>
public StatisticType Type { get; set; }
public Guid MatchId { get; set; }
/// <summary>
/// The date of the associated match, for display without a separate lookup.
/// </summary>
public DateTime? MatchDate { get; set; }
}
@@ -0,0 +1,26 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Application.DTOs.PointDeductions.Request;
/// <summary>
/// Request to apply a disciplinary point deduction to a team within a division taken from the route.
/// </summary>
public class CreatePointDeductionRequest
{
[Required(ErrorMessage = "The TeamId field is required.")]
public required Guid TeamId { get; set; }
/// <summary>
/// The positive amount of table points to subtract from the team's total.
/// </summary>
[Range(1, int.MaxValue, ErrorMessage = "Points must be at least 1.")]
public required int Points { get; set; }
/// <summary>
/// The disciplinary reason for the deduction.
/// </summary>
[Required(ErrorMessage = "The Reason field is required.")]
[MaxLength(300, ErrorMessage = "Reason cannot exceed 300 characters.")]
public required string Reason { get; set; }
}
@@ -0,0 +1,17 @@
namespace Application.DTOs.PointDeductions.Response;
/// <summary>
/// The point-deduction summary carried on a standings position row for a penalised team.
/// </summary>
public class AppliedPointDeductionResponse
{
/// <summary>
/// The total table points subtracted from the team.
/// </summary>
public int Points { get; set; }
/// <summary>
/// The combined disciplinary reasons.
/// </summary>
public string Reason { get; set; } = string.Empty;
}

Some files were not shown because too many files have changed in this diff Show More