Sube carpetas API, API.Tests, Application y Domain del Backend
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.AuditLogs.Request;
|
||||
using Application.DTOs.AuditLogs.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to the sensitive-action audit trail, restricted to Admin or Owner so those shared accounts stay accountable.
|
||||
/// </summary>
|
||||
[Route("api/audit-logs")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class AuditLogController(IAuditService auditService, IMapper mapper) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists audit entries newest first, with pagination and optional actor or action filters.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<AuditLogResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> GetAuditLogs(
|
||||
[FromQuery] AuditLogFilteredRequest filter)
|
||||
{
|
||||
PaginatedResponse<AuditLog> entries = await auditService.GetAuditLogsAsync(filter);
|
||||
PaginatedResponse<AuditLogResponse> response = mapper.Map<PaginatedResponse<AuditLogResponse>>(entries);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using API.Utils.Helpers;
|
||||
|
||||
using Application.DTOs.Auth.Request;
|
||||
using Application.DTOs.Auth.Response;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication and account-lifecycle endpoints; login, magic links, guest sessions, and password reset are anonymous, while registering or inviting a user requires Admin or Owner.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController(IAuthenticationService authenticationService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a fully activated account with a caller-set password; requires Admin or Owner rather than being a public self-registration endpoint.
|
||||
/// </summary>
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
[HttpPost("register")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(RegisterUserResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<RegisterUserResponse>> Register(
|
||||
[FromBody] RegisterUserRequest request, CancellationToken ct)
|
||||
{
|
||||
string callerRole = User.FindFirst(ClaimTypes.Role)?.Value
|
||||
?? throw new UnauthorizedAccessException(ErrorMessages.Auth.RoleClaimMissing);
|
||||
|
||||
Guid callerId = Guid.Parse(
|
||||
User.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? throw new UnauthorizedAccessException(ErrorMessages.Auth.IdClaimMissing));
|
||||
|
||||
RegisterUserResponse response =
|
||||
await authenticationService.RegisterAsync(request, callerRole, callerId, ct);
|
||||
|
||||
return CreatedAtAction(nameof(Register), new { userId = response.UserId }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user by email only, with no password, and emails a magic activation link; requires Admin or Owner.
|
||||
/// </summary>
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
[HttpPost("invite")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(InviteUserResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<InviteUserResponse>> Invite(
|
||||
[FromBody] InviteUserRequest request, CancellationToken ct)
|
||||
{
|
||||
string callerRole = User.FindFirst(ClaimTypes.Role)?.Value
|
||||
?? throw new UnauthorizedAccessException(ErrorMessages.Auth.RoleClaimMissing);
|
||||
|
||||
Guid callerId = Guid.Parse(
|
||||
User.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? throw new UnauthorizedAccessException(ErrorMessages.Auth.IdClaimMissing));
|
||||
|
||||
InviteUserResponse response =
|
||||
await authenticationService.InviteUserAsync(request, callerRole, callerId, ct);
|
||||
|
||||
return CreatedAtAction(nameof(Invite), new { userId = response.UserId }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consumes the activation token from the invitation email, sets the user's first password, and returns a ready-to-use JWT.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("activate")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<TokenResponse>> Activate(
|
||||
[FromBody] ActivateAccountRequest request, CancellationToken ct)
|
||||
{
|
||||
return Ok(await authenticationService.ActivateAccountAsync(request, ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emails a password-reset magic link for the given email and always returns 200 so it never reveals whether the email has an account.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("password-reset/request")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> RequestPasswordReset(
|
||||
[FromBody] RequestPasswordResetRequest request, CancellationToken ct)
|
||||
{
|
||||
await authenticationService.RequestPasswordResetAsync(request, ct);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("login")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<TokenResponse>> Login(
|
||||
[FromBody] LogInUserRequest request, CancellationToken ct)
|
||||
{
|
||||
return Ok(await authenticationService.LoginAsync(request, ct));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("magic-link/request")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MagicLinkResponse))]
|
||||
public async Task<ActionResult<MagicLinkResponse>> RequestMagicLink(
|
||||
[FromBody] MagicLinkRequest request, CancellationToken ct)
|
||||
{
|
||||
return Ok(await authenticationService.RequestMagicLinkAsync(request, ct));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("magic-link/login")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<TokenResponse>> MagicLinkLogin(
|
||||
[FromBody] MagicLinkLoginRequest request, CancellationToken ct)
|
||||
{
|
||||
return Ok(await authenticationService.MagicLinkLoginAsync(request, ct));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("guest")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))]
|
||||
public async Task<ActionResult<TokenResponse>> Guest(CancellationToken ct)
|
||||
{
|
||||
return Ok(await authenticationService.GuestAsync(ct));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refresh")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<TokenResponse>> Refresh(
|
||||
[FromBody] RefreshTokenRequest request, CancellationToken ct)
|
||||
{
|
||||
return Ok(await authenticationService.RefreshAsync(request, ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the password-reset token from the email link, sets the new password, and returns a ready-to-use JWT, logging the user in automatically.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("password-reset/confirm")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<TokenResponse>> ConfirmPasswordReset(
|
||||
[FromBody] PasswordResetConfirmRequest request, CancellationToken ct)
|
||||
{
|
||||
return Ok(await authenticationService.ConfirmPasswordResetAsync(request, ct));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("logout")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Logout(CancellationToken ct)
|
||||
{
|
||||
(string _, Guid id) = User.GetCallerClaims();
|
||||
await authenticationService.LogoutAsync(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Application.DTOs.Backup.Response;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-only management of database backups: lists the catalog, triggers a manual backup, deletes a backup, and restores from one, with outcomes mapped explicitly to status codes.
|
||||
/// </summary>
|
||||
[Route("api/backups")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class BackupController(IBackupOperationsService operations) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IReadOnlyList<BackupRecordResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<IReadOnlyList<BackupRecordResponse>>> GetAll(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<BackupRecord> records = await operations.ListNewestFirstAsync(ct);
|
||||
IReadOnlyList<BackupRecordResponse> response = records.Select(BackupRecordResponse.FromEntity).ToList();
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers a manual backup, returning 409 if a backup or restore is already in progress and 500 if the backup itself fails.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BackupRecordResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> Create(CancellationToken ct)
|
||||
{
|
||||
BackupOperationResult result = await operations.CreateBackupAsync(BackupOrigin.Manual, ct);
|
||||
|
||||
return result.Outcome switch
|
||||
{
|
||||
BackupOperationOutcome.Completed => Ok(result.Record),
|
||||
BackupOperationOutcome.Busy => Conflict(result.Message),
|
||||
_ => StatusCode(StatusCodes.Status500InternalServerError, result.Message),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a catalogued backup, returning 404 if no backup matches the id and 409 if a backup or restore is currently in progress.
|
||||
/// </summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
BackupOperationResult result = await operations.DeleteBackupAsync(id, ct);
|
||||
|
||||
return result.Outcome switch
|
||||
{
|
||||
BackupOperationOutcome.Completed => NoContent(),
|
||||
BackupOperationOutcome.NotFound => NotFound(),
|
||||
BackupOperationOutcome.Busy => Conflict(result.Message),
|
||||
_ => StatusCode(StatusCodes.Status500InternalServerError, result.Message),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores the database from a catalogued backup, returning on success the automatic safety backup record taken just before the restore, not the restored backup itself; returns 404 if no backup matches the id and 409 if a backup or restore is already in progress.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/restore")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BackupRecordResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> Restore(Guid id, CancellationToken ct)
|
||||
{
|
||||
BackupOperationResult result = await operations.RestoreBackupAsync(id, ct);
|
||||
|
||||
return result.Outcome switch
|
||||
{
|
||||
BackupOperationOutcome.Completed => Ok(result.Record),
|
||||
BackupOperationOutcome.NotFound => NotFound(),
|
||||
BackupOperationOutcome.Busy => Conflict(result.Message),
|
||||
_ => StatusCode(StatusCodes.Status500InternalServerError, result.Message),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.BlogPosts.Request;
|
||||
using Application.DTOs.BlogPosts.Response;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Storage;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Manages blog posts; reads are public while writes require Admin or Owner, with individual actions opting back out via AllowAnonymous where the whole club should be able to read.
|
||||
/// </summary>
|
||||
/// <param name="blogPostService">The blog post service.</param>
|
||||
/// <param name="supabaseHelper">The Supabase helper for storage operations.</param>
|
||||
/// <param name="mapper">The AutoMapper instance.</param>
|
||||
[Route("api/blogposts/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class BlogPostController(
|
||||
IBlogPostService blogPostService,
|
||||
SupabaseHelper supabaseHelper,
|
||||
IMapper mapper
|
||||
) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a blog post from multipart form data, validating and uploading an included photo file to storage before the post is persisted.
|
||||
/// </summary>
|
||||
/// <param name="blogPostRequest">The blog post creation request object containing the post details.</param>
|
||||
/// <returns>The created blog post response with details of the new blog post.</returns>
|
||||
[HttpPost()]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(BlogPostResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<BlogPostResponse>> CreateBlogPost([FromForm] CreateBlogPostRequest blogPostRequest)
|
||||
{
|
||||
if (blogPostRequest.PhotoFile is not null && !blogPostRequest.PhotoFile.IsValidImageFile())
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile);
|
||||
}
|
||||
|
||||
string? photoUrl = blogPostRequest.PhotoFile is null
|
||||
? null
|
||||
: await supabaseHelper.UploadImageAsync<BlogPost>(
|
||||
blogPostRequest.PhotoFile.OpenReadStream(),
|
||||
blogPostRequest.PhotoFile.FileName);
|
||||
|
||||
BlogPost blogPost = mapper.Map<BlogPost>(blogPostRequest);
|
||||
blogPost.PhotoUrl = photoUrl;
|
||||
|
||||
BlogPost createdBlogPost = await blogPostService.CreateBlogPostAsync(blogPost);
|
||||
BlogPostResponse blogPostResponse = mapper.Map<BlogPostResponse>(createdBlogPost);
|
||||
|
||||
return new ObjectResult(blogPostResponse) { StatusCode = StatusCodes.Status201Created };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a blog post resolved by id or by its slug, the same lookup the GET endpoint uses, including unpublished drafts so a draft can be edited before it's published.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The GUID id or slug of the blog post to update.</param>
|
||||
/// <param name="blogPostRequest">The blog post request with updated content.</param>
|
||||
/// <returns>Returns 200 OK with the updated blog post response if the update was successful, or 400 Bad Request if the blog post was not found.</returns>
|
||||
[HttpPut("{idOrSlug}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPostResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> UpdateBlogPost(string idOrSlug, [FromForm] UpdateBlogPostRequest blogPostRequest)
|
||||
{
|
||||
// Resolves by id or slug, matching the GET, so the admin can save a post the same way it was opened instead of forcing a GUID URL.
|
||||
// includeUnpublished is true so a draft can still be edited.
|
||||
BlogPost? existingPost = await blogPostService.GetBlogPostByIdOrSlugAsync(idOrSlug, includeUnpublished: true);
|
||||
|
||||
if (existingPost is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(BlogPost), idOrSlug);
|
||||
}
|
||||
|
||||
mapper.Map(blogPostRequest, existingPost);
|
||||
await blogPostService.UpdateBlogPostAsync(existingPost);
|
||||
|
||||
BlogPostResponse blogPostResponse = mapper.Map<BlogPostResponse>(existingPost);
|
||||
return Ok(blogPostResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces a blog post's photo, validating the new file as an image and uploading it to storage before updating the post's PhotoUrl.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The GUID id or slug of the blog post to update the photo.</param>
|
||||
/// <param name="photoRequest">The update blog post photo request.</param>
|
||||
/// <returns>Returns 200 OK if the photo was successfully updated.</returns>
|
||||
[HttpPut("{idOrSlug}/photo")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> UpdateBlogPostPhoto(string idOrSlug, [FromForm] UpdateBlogPostPhotoRequest photoRequest)
|
||||
{
|
||||
if (!photoRequest.PhotoFile.IsValidImageFile())
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile);
|
||||
}
|
||||
|
||||
BlogPost? blogPost = await blogPostService.GetBlogPostByIdOrSlugAsync(idOrSlug, includeUnpublished: true);
|
||||
if (blogPost is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(BlogPost), idOrSlug);
|
||||
}
|
||||
|
||||
blogPost.PhotoUrl = await supabaseHelper.UploadImageAsync<BlogPost>(
|
||||
photoRequest.PhotoFile.OpenReadStream(),
|
||||
photoRequest.PhotoFile.FileName);
|
||||
|
||||
await blogPostService.UpdateBlogPostAsync(blogPost);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a blog post by id or slug; Admin or Owner can also resolve unpublished drafts, and a public non-draft read increments the view counter while an Admin/Owner preview does not.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">Blog post identifier as a GUID, or slug.</param>
|
||||
/// <returns>The blog post with the specified id or slug.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPostResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BlogPostResponse>> GetBlogPostById(string idOrSlug)
|
||||
{
|
||||
// Only Admin/Owner may resolve drafts; anonymous or public callers get 404 for an unpublished post.
|
||||
bool includeUnpublished = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Owner);
|
||||
BlogPost? blogPost = await blogPostService.GetBlogPostByIdOrSlugAsync(idOrSlug, includeUnpublished);
|
||||
|
||||
if (blogPost is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(BlogPost), idOrSlug);
|
||||
}
|
||||
|
||||
// The view counter reflects genuine public reads only, since an Admin/Owner opening the post to preview or edit it must not inflate the count.
|
||||
if (!includeUnpublished)
|
||||
{
|
||||
blogPost.Views++;
|
||||
await blogPostService.UpdateBlogPostAsync(blogPost);
|
||||
}
|
||||
|
||||
BlogPostResponse blogPostResponse = mapper.Map<BlogPostResponse>(blogPost);
|
||||
return Ok(blogPostResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a blog post by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the blog post to delete.</param>
|
||||
/// <returns>Returns 204 No Content if the blog post was successfully deleted, or 400 Bad Request if not found.</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> DeleteBlogPostById(Guid id)
|
||||
{
|
||||
await blogPostService.DeleteBlogPostAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves filtered, paginated blog posts; public and anonymous callers only see published posts while Admin or Owner also see drafts.
|
||||
/// </summary>
|
||||
/// <param name="filterRequest">The filtering and pagination parameters.</param>
|
||||
/// <returns>A paginated response containing the filtered blog posts.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet()]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<BlogPostResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PaginatedResponse<BlogPostResponse>>> GetFilteredBlogPosts([FromQuery] GetBlogPostsFilteredRequest filterRequest)
|
||||
{
|
||||
// Admin/Owner also see drafts in this listing; public callers only see published posts.
|
||||
bool includeUnpublished = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Owner);
|
||||
PaginatedResponse<BlogPost> paginatedPosts = await blogPostService.GetAllBlogPostsAsync(filterRequest, includeUnpublished);
|
||||
PaginatedResponse<BlogPostResponse> response = mapper.Map<PaginatedResponse<BlogPostResponse>>(paginatedPosts);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Champions.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the club's champions history across finished tournaments; reads are public.
|
||||
/// </summary>
|
||||
/// <param name="championService">The champion service.</param>
|
||||
[Route("api/champions")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class ChampionController(IChampionService championService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists each division's first-place champion across every finished tournament, optionally scoped to one season, omitting divisions without a decided champion.
|
||||
/// </summary>
|
||||
/// <param name="seasonId">Optional season filter as a GUID; when omitted, spans all seasons.</param>
|
||||
/// <returns>
|
||||
/// Returns 200 OK with the champions history.
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<ChampionHistoryResponse>))]
|
||||
public async Task<ActionResult<List<ChampionHistoryResponse>>> GetChampionsHistory([FromQuery] Guid? seasonId)
|
||||
{
|
||||
List<ChampionHistoryResponse> history = await championService.GetChampionsHistoryAsync(seasonId);
|
||||
return Ok(history);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Club.Request;
|
||||
using Application.DTOs.Club.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the stable cross-season club identity; reading a club's history is public while the idempotent backfill is a staff maintenance action.
|
||||
/// </summary>
|
||||
/// <param name="clubService">The club service.</param>
|
||||
[Route("api/clubs/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class ClubController(IClubService clubService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a club and its trajectory across seasons, including the per-season teams that belong to it and the tournaments each was registered in.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The club's GUID id or its slug.</param>
|
||||
/// <returns>
|
||||
/// <para>Returns 200 OK with the club history when found.</para>
|
||||
/// <para>Returns 404 Not Found when no club matches.</para>
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubHistoryResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ClubHistoryResponse>> GetClubHistory(string idOrSlug)
|
||||
{
|
||||
ClubHistoryResponse? history = await clubService.GetClubHistoryAsync(idOrSlug);
|
||||
|
||||
if (history is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Club), idOrSlug);
|
||||
}
|
||||
|
||||
return Ok(history);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently links every unlinked team to a stable club, creating clubs as needed; safe to re-run since a second call reports zeros.
|
||||
/// </summary>
|
||||
/// <returns>Returns 200 OK with how many clubs were created and teams linked.</returns>
|
||||
[HttpPost("backfill")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubBackfillResult))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<ClubBackfillResult>> BackfillClubs()
|
||||
{
|
||||
ClubBackfillResult result = await clubService.BackfillClubsAsync();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves every club's stable identity summary, used to populate the "link to parent club" picker.
|
||||
/// </summary>
|
||||
/// <returns>Returns 200 OK with every club, ordered by name.</returns>
|
||||
[HttpGet()]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<ClubSummaryResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<IEnumerable<ClubSummaryResponse>>> GetAllClubs()
|
||||
{
|
||||
IEnumerable<ClubSummaryResponse> clubs = await clubService.GetAllClubsAsync();
|
||||
return Ok(clubs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames a club. The club's slug never changes, so its public URL stays stable.
|
||||
/// </summary>
|
||||
/// <param name="id">The club to rename.</param>
|
||||
/// <param name="request">The new display name.</param>
|
||||
/// <returns>
|
||||
/// <para>Returns 200 OK with the club's updated history when renamed.</para>
|
||||
/// <para>Returns 404 Not Found when the club doesn't exist.</para>
|
||||
/// <para>Returns 409 Conflict when the name is blank.</para>
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubHistoryResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ClubHistoryResponse>> RenameClub(Guid id, [FromBody] RenameClubRequest request)
|
||||
{
|
||||
ClubHistoryResponse history = await clubService.RenameClubAsync(id, request.Name);
|
||||
return Ok(history);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Links a club as a squad of a parent institution club, so both are shown together as one institution.
|
||||
/// </summary>
|
||||
/// <param name="id">The squad club to link.</param>
|
||||
/// <param name="request">The parent institution club it becomes a squad of.</param>
|
||||
/// <returns>
|
||||
/// <para>Returns 200 OK with the club's updated history when linked.</para>
|
||||
/// <para>Returns 404 Not Found when either club doesn't exist.</para>
|
||||
/// <para>Returns 409 Conflict when the link would create a chain deeper than institution -> squads.</para>
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}/parent")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubHistoryResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ClubHistoryResponse>> LinkClubParent(Guid id, [FromBody] LinkClubParentRequest request)
|
||||
{
|
||||
ClubHistoryResponse history = await clubService.LinkClubToParentAsync(id, request.ParentClubId);
|
||||
return Ok(history);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears a club's parent institution link, if any.
|
||||
/// </summary>
|
||||
/// <param name="id">The squad club to unlink.</param>
|
||||
/// <returns>
|
||||
/// <para>Returns 200 OK with the club's updated history.</para>
|
||||
/// <para>Returns 404 Not Found when the club doesn't exist.</para>
|
||||
/// </returns>
|
||||
[HttpDelete("{id:guid}/parent")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubHistoryResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ClubHistoryResponse>> UnlinkClubParent(Guid id)
|
||||
{
|
||||
ClubHistoryResponse history = await clubService.UnlinkClubParentAsync(id);
|
||||
return Ok(history);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a club, blocking the delete while it still has teams or squad clubs linked to it.
|
||||
/// </summary>
|
||||
/// <param name="id">The club to delete.</param>
|
||||
/// <returns>
|
||||
/// <para>Returns 204 No Content when deleted.</para>
|
||||
/// <para>Returns 404 Not Found when the club doesn't exist.</para>
|
||||
/// <para>Returns 409 Conflict when it still has teams or squad clubs linked to it.</para>
|
||||
/// </returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult> DeleteClub(Guid id)
|
||||
{
|
||||
await clubService.DeleteClubAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Application.DTOs.DataMaintenance.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-only tools for resetting tournament-domain data to a clean, realistic sample state, never touching identity.
|
||||
/// </summary>
|
||||
[Route("api/data-maintenance")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class DataMaintenanceController(IDataMaintenanceService dataMaintenanceService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Deletes every tournament-domain row. Identity is untouched.
|
||||
/// </summary>
|
||||
[HttpPost("wipe")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DataWipeResult))]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<DataWipeResult>> Wipe(CancellationToken ct)
|
||||
{
|
||||
DataWipeResult result = await dataMaintenanceService.WipeSampleDataAsync(ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds 2 complete sample tournaments, returning 409 if the database already has tournament data since it must be wiped first.
|
||||
/// </summary>
|
||||
[HttpPost("seed")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DataSeedResult))]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<DataSeedResult>> Seed(CancellationToken ct)
|
||||
{
|
||||
DataSeedResult result = await dataMaintenanceService.SeedSampleDataAsync(ct);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.Divisions.Request;
|
||||
using Application.DTOs.Divisions.Response;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Helper.Standings;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages divisions; reads are public while writes require Owner or Admin.
|
||||
/// </summary>
|
||||
/// <param name="divisionService">The division service.</param>
|
||||
/// <param name="mapper">The AutoMapper instance.</param>
|
||||
[Route("api/divisions/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class DivisionController(
|
||||
IDivisionService divisionService,
|
||||
IMapper mapper
|
||||
) : ControllerBase
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new division.
|
||||
/// </summary>
|
||||
/// <param name="divisionRequest">The division request.</param>
|
||||
/// <returns>The created division response.
|
||||
/// <para>Returns 201 Created with the division response if the creation was successful.</para>
|
||||
/// <para>Returns 403 Forbidden if the user is not authenticated.</para>
|
||||
/// </returns>
|
||||
[HttpPost()]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(DetailedDivisionResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<DivisionResponse>> CreateDivision(CreateDivisionRequest divisionRequest)
|
||||
{
|
||||
Division mappedDivision = mapper.Map<Division>(divisionRequest);
|
||||
Division createdDivision = await divisionService.CreateDivisionAsync(mappedDivision);
|
||||
DivisionResponse divisionResponse = mapper.Map<DivisionResponse>(createdDivision);
|
||||
|
||||
return CreatedAtAction(nameof(GetDivisionById), new { idOrSlug = divisionResponse.Id }, divisionResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a division by its id or its public slug.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The GUID id or slug of the division to retrieve.</param>
|
||||
/// <returns>The division with the specified id or slug.
|
||||
/// <para>Returns 200 Ok with the division response if it was found.</para>
|
||||
/// <para>Returns 404 Not Found if the division with the provided id or slug was not found.</para>
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}/detail")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedDivisionResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<DivisionResponse>> GetDivisionById(string idOrSlug)
|
||||
{
|
||||
Division? division = await divisionService.GetSimpleDivisionByIdOrSlugAsync(idOrSlug);
|
||||
|
||||
if (division is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Division), idOrSlug);
|
||||
}
|
||||
|
||||
DivisionResponse divisionResponse = mapper.Map<DivisionResponse>(division);
|
||||
await PopulateStandingsAsync(divisionResponse);
|
||||
|
||||
return Ok(divisionResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a division by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the division to delete.</param>
|
||||
/// <returns>
|
||||
/// Returns 200 Ok if the division was successfully deleted.
|
||||
/// Returns 400 Bad Request if the division with the provided id was not found.
|
||||
/// Returns 403 Forbidden if the user is not authenticated.
|
||||
/// </returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> DeleteDivisionById(Guid id)
|
||||
{
|
||||
|
||||
await divisionService.DeleteDivisionAsync(id);
|
||||
return NoContent();
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a division by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the division to update.</param>
|
||||
/// <param name="divisionRequest">
|
||||
/// The updated division information. If TournamentId is set, the
|
||||
/// division, and everything under it, is moved to that tournament.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// Returns 200 Ok with the updated division response if the update was successful.
|
||||
/// Returns 404 Not Found if the division, or the target tournament when reassigning, was not found.
|
||||
/// Returns 403 Forbidden if the user is not authenticated.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DivisionResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> UpdateDivisionById(Guid id, UpdateDivisionRequest divisionRequest)
|
||||
{
|
||||
Division? existingDivision = await divisionService.GetFullDivisionByIdAsync(id);
|
||||
|
||||
if (existingDivision is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Division), id);
|
||||
}
|
||||
|
||||
mapper.Map(divisionRequest, existingDivision);
|
||||
|
||||
if (divisionRequest.TournamentId is Guid tournamentId)
|
||||
{
|
||||
bool tournamentAssigned = await divisionService.TryAssignTournamentAsync(existingDivision, tournamentId);
|
||||
if (!tournamentAssigned)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Tournament), tournamentId);
|
||||
}
|
||||
}
|
||||
|
||||
await divisionService.UpdateDivisionAsync(existingDivision);
|
||||
|
||||
DivisionResponse divisionResponse = mapper.Map<DivisionResponse>(existingDivision);
|
||||
return Ok(divisionResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves filtered divisions with pagination.
|
||||
/// </summary>
|
||||
/// <param name="filterRequest">The filtering and pagination parameters.</param>
|
||||
/// <returns>A paginated response containing the filtered divisions.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet()]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<DetailedDivisionResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PaginatedResponse<DetailedDivisionResponse>>> GetFilteredDivisions([FromQuery] GetDivisionsFilteredRequest filterRequest)
|
||||
{
|
||||
PaginatedResponse<Division> paginatedDivisions = await divisionService.GetAllDivisionsAsync(filterRequest);
|
||||
|
||||
PaginatedResponse<DivisionResponse> response = mapper.Map<PaginatedResponse<DivisionResponse>>(paginatedDivisions);
|
||||
|
||||
// Each division's standings must be populated here too, since the divisions table's team counter reads Positions.Length, and leaving it null made every row show 0 teams even when the division was fully populated.
|
||||
foreach (DivisionResponse divisionResponse in response.Items)
|
||||
{
|
||||
await PopulateStandingsAsync(divisionResponse);
|
||||
}
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills a division response's standings from its Group stages, setting GroupStandings to one table per group and Positions to the pooled union across all groups so the team counter reflects every group's teams.
|
||||
/// </summary>
|
||||
private async Task PopulateStandingsAsync(DivisionResponse divisionResponse)
|
||||
{
|
||||
List<GroupStandings> groups = await divisionService.GetGroupStandingsByDivisionIdAsync(divisionResponse.Id);
|
||||
|
||||
divisionResponse.GroupStandings = mapper.Map<List<GroupStandingsResponse>>(groups);
|
||||
|
||||
List<Position> pooledPositions = [.. groups
|
||||
.SelectMany(group => group.Positions)
|
||||
.GroupBy(position => position.TeamId)
|
||||
.Select(group => group.First())];
|
||||
|
||||
divisionResponse.Positions = mapper.Map<List<PositionResponse>>(pooledPositions);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Application.DTOs.Divisions.Request;
|
||||
using Application.DTOs.Stage.Response;
|
||||
using Application.DTOs.Team.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages a division's team roster and its sub-group structure; every route requires Owner or Admin.
|
||||
/// </summary>
|
||||
/// <param name="divisionRosterService">Service for the division-level team roster and its sub-group structure.</param>
|
||||
/// <param name="mapper">AutoMapper instance for mapping between entities and DTOs.</param>
|
||||
[Route("api/divisions/{divisionId:guid}/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class DivisionRosterController(
|
||||
IDivisionRosterService divisionRosterService,
|
||||
IMapper mapper) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists every team currently enrolled in the division, independent of any stage placement.
|
||||
/// </summary>
|
||||
[HttpGet("roster")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<TeamResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<List<TeamResponse>>> GetRoster(Guid divisionId)
|
||||
{
|
||||
List<Team> roster = await divisionRosterService.GetRosterAsync(divisionId);
|
||||
return Ok(mapper.Map<List<TeamResponse>>(roster));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrolls one or more teams in the division's roster, returning the roster as it stands afterward.
|
||||
/// </summary>
|
||||
[HttpPost("roster")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<TeamResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<List<TeamResponse>>> EnrollTeams(Guid divisionId, EnrollTeamsRequest request)
|
||||
{
|
||||
await divisionRosterService.EnrollTeamsAsync(divisionId, request.TeamIds);
|
||||
List<Team> roster = await divisionRosterService.GetRosterAsync(divisionId);
|
||||
return Ok(mapper.Map<List<TeamResponse>>(roster));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes one or more teams from the division's roster, cascading to their current stage placements first.
|
||||
/// </summary>
|
||||
[HttpDelete("roster")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult> UnenrollTeams(Guid divisionId, UnenrollTeamsRequest request)
|
||||
{
|
||||
await divisionRosterService.UnenrollTeamsAsync(divisionId, request.TeamIds);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the division's sub-group stages with a new count, re-balancing the untouched roster across them.
|
||||
/// </summary>
|
||||
[HttpPost("sub-groups/rebuild")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<StageResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<List<StageResponse>>> RebuildSubGroups(Guid divisionId, RebuildSubGroupsRequest request)
|
||||
{
|
||||
List<Stage> stages = await divisionRosterService.RebuildSubGroupsAsync(divisionId, request.SubGroupCount);
|
||||
return Ok(mapper.Map<List<StageResponse>>(stages));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears every current sub-group placement and re-deals the whole roster in a fresh balanced distribution.
|
||||
/// </summary>
|
||||
[HttpPost("roster/auto-distribute")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult> AutoDistributeRoster(Guid divisionId)
|
||||
{
|
||||
await divisionRosterService.AutoDistributeRosterAsync(divisionId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually moves one enrolled team from one sub-group to another, re-validating only the minimum sub-group size.
|
||||
/// </summary>
|
||||
[HttpPost("sub-groups/reassign")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult> ReassignTeamToSubGroup(Guid divisionId, ReassignTeamToSubGroupRequest request)
|
||||
{
|
||||
await divisionRosterService.ReassignTeamToSubGroupAsync(request.TeamId, request.FromStageId, request.ToStageId);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Application.DTOs.Backup.Response;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Reports maintenance-mode status and lets an admin force-clear a stuck maintenance window.
|
||||
/// </summary>
|
||||
[Route("api/maintenance")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class MaintenanceController(IMaintenanceModeState maintenanceModeState) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MaintenanceStatusResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public ActionResult<MaintenanceStatusResponse> GetStatus()
|
||||
{
|
||||
MaintenanceStatusResponse response = new(
|
||||
maintenanceModeState.IsActive, maintenanceModeState.Reason, maintenanceModeState.EnteredAtUtc);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
[HttpDelete]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult Exit()
|
||||
{
|
||||
maintenanceModeState.Exit();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.Match.Request;
|
||||
using Application.DTOs.Match.Response;
|
||||
using Application.DTOs.PlayerStatistic.Request;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages matches; reads are public but writes require Owner or Admin.
|
||||
/// </summary>
|
||||
/// <param name="matchService">The Match service.</param>
|
||||
/// <param name="stageTeamMatchService">The stage-team match service.</param>
|
||||
/// <param name="matchSeriesService">The playoff series service.</param>
|
||||
/// <param name="playerStatisticService">The player-statistic service.</param>
|
||||
/// <param name="stageService">The stage service, used for bracket-round advancement.</param>
|
||||
/// <param name="mapper">The AutoMapper instance.</param>
|
||||
[Route("api/matches/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class MatchController(
|
||||
IMatchService matchService,
|
||||
IStageTeamMatchService stageTeamMatchService,
|
||||
IMatchSeriesService matchSeriesService,
|
||||
IPlayerStatisticService playerStatisticService,
|
||||
IStageService stageService,
|
||||
IMapper mapper) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new match.
|
||||
/// </summary>
|
||||
/// <param name="matchRequest">The match request DTO.</param>
|
||||
/// <returns>The created match response.</returns>
|
||||
[HttpPost()]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(MinimalMatchResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<MinimalMatchResponse>> CreateMatch(CreateMatchRequest matchRequest)
|
||||
{
|
||||
Match mappedMatch = mapper.Map<Match>(matchRequest);
|
||||
|
||||
// A court cannot host two matches less than 2 hours apart.
|
||||
if (mappedMatch.VenueId.HasValue
|
||||
&& await matchService.HasVenueScheduleConflictAsync(
|
||||
mappedMatch.VenueId.Value, mappedMatch.MatchDate, Guid.Empty))
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Match.VenueScheduleConflict);
|
||||
}
|
||||
|
||||
Match createdMatch = await matchService.CreateMatchAsync(mappedMatch);
|
||||
MinimalMatchResponse matchResponse = mapper.Map<MinimalMatchResponse>(createdMatch);
|
||||
|
||||
return new ObjectResult(matchResponse) { StatusCode = StatusCodes.Status201Created };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates automated matches for a given stage.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the stage for which matches will be generated.</param>
|
||||
/// <returns>
|
||||
/// Returns a list of DetailedMatchResponse objects representing the generated matches.
|
||||
/// </returns>
|
||||
[HttpPost("generate/{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<DetailedMatchResponse>))]
|
||||
public async Task<ActionResult> GenerateMatches(Guid id)
|
||||
{
|
||||
List<Match> response = await matchService.CreateAutomatedMatchesAsync(stageId: id);
|
||||
return Ok(mapper.Map<List<DetailedMatchResponse>>(response));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a match by its id with detailed values.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the match.</param>
|
||||
/// <returns>The match response DTO.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{id:guid}/detail")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<DetailedMatchResponse>> GetMatchByIdWithScorers(Guid id)
|
||||
{
|
||||
Match? match = await matchService.GetMatchByIdWithScorersAsync(id);
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Match), id);
|
||||
}
|
||||
|
||||
DetailedMatchResponse matchResponse = mapper.Map<DetailedMatchResponse>(match);
|
||||
return Ok(matchResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a match by its id or its public slug.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">Match identifier as a GUID or slug.</param>
|
||||
/// <returns>The match response DTO.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<DetailedMatchResponse>> GetMatchById(string idOrSlug)
|
||||
{
|
||||
Match? match = await matchService.GetMatchByIdOrSlugAsync(idOrSlug);
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Match), idOrSlug);
|
||||
}
|
||||
|
||||
DetailedMatchResponse matchResponse = mapper.Map<DetailedMatchResponse>(match);
|
||||
return Ok(matchResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a match's scheduled date or venue, rejecting the change if the match already started or finished, its teams aren't assigned to the stage, or the new slot conflicts with another match.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the match to update.</param>
|
||||
/// <param name="updateRequest">The request containing the new match date and/or venue.</param>
|
||||
/// <returns>Returns 200 OK with the updated match, 400 Bad Request if the change is rejected, or 404 Not Found if no match matches the id.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> UpdateMatchDate(Guid id, UpdateMatchRequest updateRequest)
|
||||
{
|
||||
Match? existingMatch = await matchService.GetMatchByIdAsync(id);
|
||||
|
||||
if (existingMatch is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Match), id);
|
||||
}
|
||||
|
||||
if (existingMatch.IsFinished || existingMatch.MatchDate <= DateTime.Now)
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Match.CannotUpdateStartedOrFinished);
|
||||
}
|
||||
|
||||
|
||||
List<Guid> teamsId = [];
|
||||
|
||||
if (existingMatch.HomeTeamId.HasValue)
|
||||
{
|
||||
teamsId.Add(existingMatch.HomeTeamId.Value);
|
||||
}
|
||||
|
||||
if (existingMatch.VisitorTeamId.HasValue)
|
||||
{
|
||||
teamsId.Add(existingMatch.VisitorTeamId.Value);
|
||||
}
|
||||
|
||||
bool canUpdate = await stageTeamMatchService.AllTeamsAssignedToStage(stageId: existingMatch.StageId, TeamIds: [.. teamsId.Distinct()]);
|
||||
|
||||
if (!canUpdate)
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Match.TeamsNotAssignedToStage);
|
||||
}
|
||||
|
||||
mapper.Map(updateRequest, existingMatch);
|
||||
|
||||
// A court cannot host two matches less than 2 hours apart.
|
||||
if (existingMatch.VenueId.HasValue
|
||||
&& await matchService.HasVenueScheduleConflictAsync(
|
||||
existingMatch.VenueId.Value, existingMatch.MatchDate, existingMatch.Id))
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Match.VenueScheduleConflict);
|
||||
}
|
||||
|
||||
await matchService.UpdateMatchAsync(existingMatch);
|
||||
DetailedMatchResponse detailedMatch = mapper.Map<DetailedMatchResponse>(existingMatch);
|
||||
return Ok(detailedMatch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a stage's matches grouped by matchday, using the round as the grouping key rather than the calendar date.
|
||||
/// </summary>
|
||||
/// <param name="stageId">The id of the stage whose fixture is requested.</param>
|
||||
/// <returns>The matches grouped and ordered by round.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("stage/{stageId:guid}/by-round")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<RoundMatchesResponse>))]
|
||||
public async Task<ActionResult<List<RoundMatchesResponse>>> GetStageMatchesByRound(Guid stageId)
|
||||
{
|
||||
List<Match> matches = await matchService.GetStageMatchesByRoundAsync(stageId);
|
||||
|
||||
List<RoundMatchesResponse> rounds = [.. matches
|
||||
.GroupBy(match => match.Round)
|
||||
.OrderBy(group => group.Key ?? int.MaxValue)
|
||||
.Select(group => new RoundMatchesResponse
|
||||
{
|
||||
Round = group.Key,
|
||||
Matches = mapper.Map<List<DetailedMatchResponse>>(group.ToList()),
|
||||
})];
|
||||
|
||||
return Ok(rounds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reprograms or suspends a match, marking it suspended and optionally moving it to a new date without changing its round or the rest of the fixture.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the match to suspend/reprogram.</param>
|
||||
/// <param name="suspendRequest">The request with an optional new date.</param>
|
||||
/// <returns>The updated match response.</returns>
|
||||
[HttpPut("{id:guid}/suspend")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> SuspendMatch(Guid id, SuspendMatchRequest suspendRequest)
|
||||
{
|
||||
Match? existingMatch = await matchService.GetMatchByIdAsync(id);
|
||||
|
||||
if (existingMatch is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Match), id);
|
||||
}
|
||||
|
||||
DateTime effectiveDate = suspendRequest.MatchDate ?? existingMatch.MatchDate;
|
||||
|
||||
// A court cannot host two matches less than 2 hours apart.
|
||||
if (existingMatch.VenueId.HasValue
|
||||
&& await matchService.HasVenueScheduleConflictAsync(
|
||||
existingMatch.VenueId.Value, effectiveDate, existingMatch.Id))
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Match.VenueScheduleConflict);
|
||||
}
|
||||
|
||||
Match? updatedMatch = await matchService.SuspendMatchAsync(id, suspendRequest.MatchDate);
|
||||
|
||||
if (updatedMatch is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Match), id);
|
||||
}
|
||||
|
||||
DetailedMatchResponse detailedMatch = mapper.Map<DetailedMatchResponse>(updatedMatch);
|
||||
return Ok(detailedMatch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a match by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the match to delete.</param>
|
||||
/// <returns>Returns 204 No Content on success.</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> DeleteMatchById(Guid id)
|
||||
{
|
||||
|
||||
await matchService.DeleteMatchAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves filtered matches with pagination.
|
||||
/// </summary>
|
||||
/// <param name="filterRequest">The filtering and pagination parameters.</param>
|
||||
/// <returns>A paginated response containing the filtered matches.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet()]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<DetailedMatchResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PaginatedResponse<DetailedMatchResponse>>> GetFilteredMatches([FromQuery] GetMatchesFilteredRequest filterRequest)
|
||||
{
|
||||
PaginatedResponse<Match> paginatedMatches = await matchService.GetAllMatchesAsync(filterRequest);
|
||||
|
||||
PaginatedResponse<DetailedMatchResponse> response = mapper.Map<PaginatedResponse<DetailedMatchResponse>>(paginatedMatches);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a match's final score, which may decide a playoff series and auto-advance a bracket winner into the next round.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the match to update.</param>
|
||||
/// <param name="scoreRequest">The request with updated scores.</param>
|
||||
/// <returns>Returns 200 OK with the updated match, or 404 Not Found if no match matches the id.</returns>
|
||||
[HttpPut("{id:guid}/score")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> UpdateMatchScore(Guid id, UpdateMatchScoreRequest scoreRequest)
|
||||
{
|
||||
Match? updatedMatch = await matchService.LoadMatchResultAsync(id, scoreRequest.HomeScore, scoreRequest.VisitorScore);
|
||||
if (updatedMatch is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Match), id);
|
||||
}
|
||||
|
||||
if (updatedMatch.SeriesId.HasValue)
|
||||
{
|
||||
await matchSeriesService.RecalculateSeriesWinnerAsync(updatedMatch.SeriesId.Value);
|
||||
}
|
||||
|
||||
// Pushes a newly-decided bracket slot's winner into the next round, a no-op for group-stage matches, an undecided mid-series game, or the Final; runs after the series recalculation above so it sees the up-to-date decision.
|
||||
await stageService.TryAdvanceStageWinnerAsync(updatedMatch.StageId);
|
||||
|
||||
DetailedMatchResponse detailedMatch = mapper.Map<DetailedMatchResponse>(updatedMatch);
|
||||
return Ok(detailedMatch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finishes a match by loading both teams' scoring sheets in one operation, deriving the final score as the sum of each team's listed player points instead of accepting a typed score directly.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the match to finish.</param>
|
||||
/// <param name="request">Both teams' per-player points.</param>
|
||||
/// <returns>The finalized match.</returns>
|
||||
[HttpPut("{id:guid}/result-from-sheets")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult> LoadMatchResultFromSheets(Guid id, LoadMatchResultFromSheetsRequest request)
|
||||
{
|
||||
request.MatchId = id;
|
||||
Match? updatedMatch = await playerStatisticService.LoadMatchResultFromSheetsAsync(request);
|
||||
if (updatedMatch is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Match), id);
|
||||
}
|
||||
|
||||
if (updatedMatch.SeriesId.HasValue)
|
||||
{
|
||||
await matchSeriesService.RecalculateSeriesWinnerAsync(updatedMatch.SeriesId.Value);
|
||||
}
|
||||
|
||||
// Pushes a newly-decided bracket slot's winner into the next round, a no-op for group-stage matches, an undecided mid-series game, or the Final; runs after the series recalculation above so it sees the up-to-date decision.
|
||||
await stageService.TryAdvanceStageWinnerAsync(updatedMatch.StageId);
|
||||
|
||||
DetailedMatchResponse detailedMatch = mapper.Map<DetailedMatchResponse>(updatedMatch);
|
||||
return Ok(detailedMatch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a match as a walkover, awarding the regulation default result to the present team.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the match to mark as a walkover.</param>
|
||||
/// <param name="walkOverRequest">The request identifying the present team.</param>
|
||||
/// <returns>The updated match response.</returns>
|
||||
[HttpPut("{id:guid}/walkover")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult> LoadWalkOver(Guid id, LoadWalkOverRequest walkOverRequest)
|
||||
{
|
||||
Match? updatedMatch = await matchService.LoadWalkOverAsync(id, walkOverRequest.PresentTeamId, walkOverRequest.PresentTeamScore);
|
||||
if (updatedMatch is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Match), id);
|
||||
}
|
||||
|
||||
if (updatedMatch.SeriesId.HasValue)
|
||||
{
|
||||
await matchSeriesService.RecalculateSeriesWinnerAsync(updatedMatch.SeriesId.Value);
|
||||
}
|
||||
|
||||
// Pushes a newly-decided bracket slot's winner into the next round, a no-op for group-stage matches, an undecided mid-series game, or the Final; runs after the series recalculation above so it sees the up-to-date decision.
|
||||
await stageService.TryAdvanceStageWinnerAsync(updatedMatch.StageId);
|
||||
|
||||
DetailedMatchResponse detailedMatch = mapper.Map<DetailedMatchResponse>(updatedMatch);
|
||||
return Ok(detailedMatch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.MatchSeries.Request;
|
||||
using Application.DTOs.MatchSeries.Response;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages best-of-N playoff series between two teams; reads are public but writes require Owner or Admin.
|
||||
/// </summary>
|
||||
/// <param name="matchSeriesService">Service for series business logic and persistence operations.</param>
|
||||
/// <param name="mapper">AutoMapper instance for mapping between entities and DTOs.</param>
|
||||
[Route("api/match-series/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class MatchSeriesController(IMatchSeriesService matchSeriesService, IMapper mapper) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new best-of-N series between two teams at a stage.
|
||||
/// </summary>
|
||||
/// <param name="request">The series creation request DTO.</param>
|
||||
/// <returns>The created series response.</returns>
|
||||
[HttpPost()]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(MatchSeriesResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<MatchSeriesResponse>> CreateSeries(CreateMatchSeriesRequest request)
|
||||
{
|
||||
MatchSeries createdSeries = await matchSeriesService.CreateSeriesAsync(request.StageId, request.HomeTeamId, request.VisitorTeamId);
|
||||
MatchSeriesResponse response = mapper.Map<MatchSeriesResponse>(createdSeries);
|
||||
return CreatedAtAction(nameof(GetSeriesById), new { id = response.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a series by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the series.</param>
|
||||
/// <returns>The series entity, including its games.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MatchSeriesResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MatchSeriesResponse>> GetSeriesById(Guid id)
|
||||
{
|
||||
MatchSeries? series = await matchSeriesService.GetSeriesByIdAsync(id);
|
||||
|
||||
return series is null ? (ActionResult<MatchSeriesResponse>) NotFound(ErrorMessages.MatchSeries.NotFoundById(id)) : (ActionResult<MatchSeriesResponse>) Ok(mapper.Map<MatchSeriesResponse>(series));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves filtered and paginated series.
|
||||
/// </summary>
|
||||
/// <param name="filterRequest">The filter and pagination parameters.</param>
|
||||
/// <returns>Paginated list of series.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<MatchSeriesResponse>))]
|
||||
public async Task<ActionResult<PaginatedResponse<MatchSeriesResponse>>> GetFilteredSeries([FromQuery] GetMatchSeriesFilteredRequest filterRequest)
|
||||
{
|
||||
PaginatedResponse<MatchSeries> paginatedSeries = await matchSeriesService.GetAllSeriesAsync(filterRequest);
|
||||
PaginatedResponse<MatchSeriesResponse> response = mapper.Map<PaginatedResponse<MatchSeriesResponse>>(paginatedSeries);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedules the next game of an existing series.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the series to add a game to.</param>
|
||||
/// <param name="request">The game scheduling request.</param>
|
||||
/// <returns>The created game.</returns>
|
||||
[HttpPost("{id:guid}/games")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SeriesGameResponse>> AddGameToSeries(Guid id, AddGameToSeriesRequest request)
|
||||
{
|
||||
Match game = await matchSeriesService.AddGameToSeriesAsync(id, request.MatchDate, request.VenueId);
|
||||
return CreatedAtAction(nameof(GetSeriesById), new { id }, mapper.Map<SeriesGameResponse>(game));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.MedicalRecord.Request;
|
||||
using Application.DTOs.MedicalRecord.Response;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Interfaces.Storage;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using Domain.Constants;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages player medical records and per-season eligibility, scoped to a player, team, and tournament rather than to the player globally.
|
||||
/// </summary>
|
||||
/// <param name="medicalRecordService">The medical-record service.</param>
|
||||
/// <param name="medicalRecordStorage">The medical-record file storage boundary.</param>
|
||||
[Route("api/medical-records/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class MedicalRecordController(
|
||||
IMedicalRecordService medicalRecordService,
|
||||
IMedicalRecordStorage medicalRecordStorage) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads a player's medical-record PDF for a team and tournament, starting the record in Pending status until it is approved.
|
||||
/// </summary>
|
||||
/// <param name="request">The player, team, tournament, and PDF file.</param>
|
||||
/// <returns>The resulting medical-record state, with status Pending.</returns>
|
||||
[HttpPost()]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(MedicalRecordResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<MedicalRecordResponse>> UploadMedicalRecord([FromForm] UploadMedicalRecordRequest request)
|
||||
{
|
||||
if (!request.File.IsValidPdfFile())
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.MedicalRecord.InvalidPdfFile);
|
||||
}
|
||||
|
||||
// Rejects the upload before touching storage when the ficha is already Approved, since an approved record is view or download only.
|
||||
MedicalRecordResponse? current = await medicalRecordService.GetAsync(
|
||||
request.PlayerId, request.TeamId, request.TournamentId);
|
||||
|
||||
if (current?.Status == MedicalRecordStatus.Approved)
|
||||
{
|
||||
return Conflict(ErrorMessages.MedicalRecord.AlreadyApproved);
|
||||
}
|
||||
|
||||
string fileReference = await medicalRecordStorage.StoreAsync(
|
||||
request.TeamId,
|
||||
request.PlayerId,
|
||||
request.File.FileName,
|
||||
request.File.OpenReadStream());
|
||||
|
||||
MedicalRecordResponse response = await medicalRecordService.RecordUploadAsync(
|
||||
request.PlayerId, request.TeamId, request.TournamentId,
|
||||
fileReference, request.File.FileName, GetActor());
|
||||
|
||||
return new ObjectResult(response) { StatusCode = StatusCodes.Status201Created };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approves or rejects a player's medical record for a team and tournament, where approval grants eligibility and rejection can include an optional reason.
|
||||
/// </summary>
|
||||
/// <param name="request">The player, team, tournament, decision, and reason.</param>
|
||||
/// <returns>The resulting medical-record state.</returns>
|
||||
[HttpPut("review")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MedicalRecordResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<MedicalRecordResponse>> ReviewMedicalRecord(ReviewMedicalRecordRequest request)
|
||||
{
|
||||
MedicalRecordResponse response = await medicalRecordService.ReviewAsync(
|
||||
request.PlayerId, request.TeamId, request.TournamentId,
|
||||
request.Approve, request.Reason, GetActor());
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current medical-record and eligibility state of a player's season registration.
|
||||
/// </summary>
|
||||
/// <param name="playerId">The player.</param>
|
||||
/// <param name="teamId">The team the player is registered to.</param>
|
||||
/// <param name="tournamentId">The tournament, i.e. the season.</param>
|
||||
/// <returns>The medical-record state, or 404 when none exists.</returns>
|
||||
[HttpGet()]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MedicalRecordResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MedicalRecordResponse>> GetMedicalRecord(
|
||||
[FromQuery] Guid playerId, [FromQuery] Guid teamId, [FromQuery] Guid tournamentId)
|
||||
{
|
||||
MedicalRecordResponse? response = await medicalRecordService.GetAsync(playerId, teamId, tournamentId);
|
||||
|
||||
return response is null
|
||||
? this.NotFoundProblem(nameof(MedicalRecordResponse), $"{playerId}/{teamId}/{tournamentId}")
|
||||
: Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a player's medical-record PDF by streaming it through the API rather than via a public URL, since the storage area is private.
|
||||
/// </summary>
|
||||
/// <param name="playerId">The player.</param>
|
||||
/// <param name="teamId">The team the player is registered to.</param>
|
||||
/// <param name="tournamentId">The tournament, i.e. the season.</param>
|
||||
/// <returns>The stored PDF, or 404 when no record/file exists.</returns>
|
||||
[HttpGet("download")]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DownloadMedicalRecord(
|
||||
[FromQuery] Guid playerId, [FromQuery] Guid teamId, [FromQuery] Guid tournamentId)
|
||||
{
|
||||
MedicalRecordResponse? record = await medicalRecordService.GetAsync(playerId, teamId, tournamentId);
|
||||
|
||||
if (record?.FileUrl is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(MedicalRecordResponse), $"{playerId}/{teamId}/{tournamentId}");
|
||||
}
|
||||
|
||||
byte[] content = await medicalRecordStorage.DownloadAsync(record.FileUrl);
|
||||
string fileName = string.IsNullOrWhiteSpace(record.FileName) ? "medical-record.pdf" : record.FileName;
|
||||
|
||||
return File(content, "application/pdf", fileName);
|
||||
}
|
||||
|
||||
private string GetActor()
|
||||
{
|
||||
return User?.Identity?.Name
|
||||
?? User?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? AuditConstants.SystemUser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.Player.Request;
|
||||
using Application.DTOs.Player.Response;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages players; public reads return minimal data, while full details and every write require staff roles.
|
||||
/// </summary>
|
||||
/// <param name="playerService">The Player service.</param>
|
||||
/// <param name="teamService">The Team service.</param>
|
||||
/// <param name="mapper">The AutoMapper instance.</param>
|
||||
[Route("api/players/")]
|
||||
[ApiController]
|
||||
public class PlayerController(
|
||||
IPlayerService playerService,
|
||||
ITeamService teamService,
|
||||
IMapper mapper
|
||||
) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a player under the specified team, returning the full admin-facing player response rather than the trimmed public shape.
|
||||
/// </summary>
|
||||
/// <param name="playerRequest">The player request.</param>
|
||||
/// <returns>The created Player response.
|
||||
/// <para>Returns 201 Created with the Player response if the creation was successful.</para>
|
||||
/// <para>Returns 400 Bad Request if the team was not found or is not linked to a tournament.</para>
|
||||
/// <para>Returns 403 Forbidden if the user lacks the Admin or Owner role.</para>
|
||||
/// </returns>
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
[HttpPost()]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(AdminPlayerResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<AdminPlayerResponse>> CreatePlayerAsync(CreatePlayerRequest playerRequest)
|
||||
{
|
||||
Guid teamId = playerRequest.TeamId;
|
||||
Team? existingTeam = await teamService.GetTeamByIdAsync(teamId);
|
||||
|
||||
if (existingTeam is null)
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Team.NotFound(teamId));
|
||||
}
|
||||
|
||||
if (existingTeam.TournamentId is null)
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Team.NotInTournament(teamId));
|
||||
}
|
||||
|
||||
Player mappedPlayer = mapper.Map<Player>(playerRequest);
|
||||
Player createdPlayer = await playerService.CreatePlayerAsync(mappedPlayer, existingTeam.TournamentId.Value);
|
||||
AdminPlayerResponse playerResponse = mapper.Map<AdminPlayerResponse>(createdPlayer);
|
||||
return CreatedAtRoute("GetPlayerById", new { idOrSlug = createdPlayer.Id }, playerResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a player onto a team's roster for a season, enforcing one-team-per-tournament, the roster-size cap, and unique dorsal numbers.
|
||||
/// </summary>
|
||||
/// <param name="playerId">The player to register.</param>
|
||||
/// <param name="request">The team, tournament and optional dorsal.</param>
|
||||
/// <returns>
|
||||
/// <para>Returns 200 OK with the registration outcome.</para>
|
||||
/// <para>Returns 409 Conflict if a roster invariant is violated.</para>
|
||||
/// <para>Returns 403 Forbidden if the user is not authorized.</para>
|
||||
/// </returns>
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
[HttpPost("{playerId:guid}/registration")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerRegistrationResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PlayerRegistrationResponse>> RegisterPlayerToTeam(
|
||||
Guid playerId, [FromBody] RegisterPlayerToTeamRequest request)
|
||||
{
|
||||
PlayerTeamRegistration registration = await playerService.RegisterPlayerToTeamAsync(
|
||||
playerId, request.TeamId, request.TournamentId, request.JerseyNumber);
|
||||
|
||||
return Ok(mapper.Map<PlayerRegistrationResponse>(registration));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a player by its id or its public slug.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The GUID id or slug of the player to retrieve.</param>
|
||||
/// <returns>The Player with the specified id or slug.
|
||||
/// <para>Returns 200 OK with the Player response if it was found.</para>
|
||||
/// <para>Returns 404 Not Found if the Player with the provided id or slug was not found.</para>
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}", Name = "GetPlayerById")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PublicPlayerResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PublicPlayerResponse>> GetPlayerByIdAsync(string idOrSlug)
|
||||
{
|
||||
Player? player = await playerService.GetPlayerByIdOrSlugAsync(idOrSlug);
|
||||
|
||||
if (player is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Player), idOrSlug);
|
||||
}
|
||||
|
||||
PublicPlayerResponse playerResponse = mapper.Map<PublicPlayerResponse>(player);
|
||||
return Ok(playerResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a player by its id or its slug, with complete data for the admin view.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The GUID id or exact slug of the player to retrieve.</param>
|
||||
/// <returns>The player's complete admin data.
|
||||
/// <para>Returns 200 OK with the player's admin data if the player was found.</para>
|
||||
/// <para>Returns 404 Not Found if no player matches the provided id or slug.</para>
|
||||
/// </returns>
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
[HttpGet("admin/{idOrSlug}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AdminPlayerResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AdminPlayerResponse>> GetPlayerByIdCompleteDataAsync(string idOrSlug)
|
||||
{
|
||||
Player? player = await playerService.GetPlayerByIdOrSlugAsync(idOrSlug);
|
||||
|
||||
if (player is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Player), idOrSlug);
|
||||
}
|
||||
|
||||
AdminPlayerResponse playerResponse = mapper.Map<AdminPlayerResponse>(player);
|
||||
return Ok(playerResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a player by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the player to update.</param>
|
||||
/// <param name="playerRequest">The player request.</param>
|
||||
/// <returns>
|
||||
/// Returns 200 OK with the updated player if the update was successful.
|
||||
/// Returns 400 Bad Request if the player's team was not found or is not linked to a tournament.
|
||||
/// Returns 404 Not Found if no player matches the provided id.
|
||||
/// Returns 403 Forbidden if the user lacks the Admin or Owner role.
|
||||
/// </returns>
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> UpdatePlayerAsync(Guid id, UpdatePlayerRequest playerRequest)
|
||||
{
|
||||
Player? existingPlayer = await playerService.GetPlayerByIdAsync(id);
|
||||
|
||||
if (existingPlayer is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Player), id);
|
||||
}
|
||||
|
||||
mapper.Map(playerRequest, existingPlayer);
|
||||
|
||||
Team? currentTeam = await teamService.GetTeamByIdAsync(existingPlayer.TeamId);
|
||||
|
||||
if (currentTeam is null)
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Team.NotFound(existingPlayer.TeamId));
|
||||
}
|
||||
|
||||
if (currentTeam.TournamentId is null)
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Team.NotInTournament(existingPlayer.TeamId));
|
||||
}
|
||||
|
||||
await playerService.UpdatePlayerAsync(existingPlayer, currentTeam.TournamentId.Value);
|
||||
|
||||
return Ok(existingPlayer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a player by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the Player to delete.</param>
|
||||
/// <returns>
|
||||
/// Returns 204 No Content if the Player was successfully deleted.
|
||||
/// Returns 403 Forbidden if the user lacks the Admin or Owner role.
|
||||
/// </returns>
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> DeletePlayerByIdAsync(Guid id)
|
||||
{
|
||||
await playerService.DeletePlayerAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves filtered players with pagination.
|
||||
/// </summary>
|
||||
/// <param name="filterRequest">The filtering and pagination parameters.</param>
|
||||
/// <returns>A paginated response containing the filtered players.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("public")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<PublicPlayerResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PaginatedResponse<PublicPlayerResponse>>> GetFilteredPlayersAsync([FromQuery] PlayerFilterRequestBase filterRequest)
|
||||
{
|
||||
PaginatedResponse<Player> paginatedPlayers = await playerService.GetAllPlayersAsync(filterRequest);
|
||||
|
||||
PaginatedResponse<PublicPlayerResponse> response = mapper.Map<PaginatedResponse<PublicPlayerResponse>>(paginatedPlayers);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves filtered players with pagination and detailed information, for admin use only.
|
||||
/// </summary>
|
||||
/// <param name="filterRequest">The filtering and pagination parameters.</param>
|
||||
/// <returns>A paginated response containing the filtered players.</returns>
|
||||
/// <response code="200">Returns a paginated list of filtered players</response>
|
||||
/// <response code="400">Returns 400 if there is an invalid filter parameter or the filter results in no data</response>
|
||||
/// <response code="403">Returns 403 if the user does not have the required admin permissions</response>
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
[HttpGet("")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<AdminPlayerResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<PaginatedResponse<AdminPlayerResponse>>> GetFilteredPlayersPrivateAsync([FromQuery] GetPlayersFilteredRequest filterRequest)
|
||||
{
|
||||
PaginatedResponse<Player> paginatedPlayers = await playerService.GetAllPlayersAsync(filterRequest);
|
||||
|
||||
PaginatedResponse<AdminPlayerResponse> response = mapper.Map<PaginatedResponse<AdminPlayerResponse>>(paginatedPlayers);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.PlayerSanction.Request;
|
||||
using Application.DTOs.PlayerSanction.Response;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages player sanctions; reads are public but writes require Owner or Admin.
|
||||
/// </summary>
|
||||
/// <param name="playerSanctionService">The Player Sanction service.</param>
|
||||
/// <param name="mapper">The AutoMapper instance.</param>
|
||||
[Route("api/player-sanctions/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class PlayerSanctionController(IPlayerSanctionService playerSanctionService, IMapper mapper) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a sanction against a player, team, or staff member, enriched with the subject's display name and fechas-based status.
|
||||
/// </summary>
|
||||
/// <param name="playerSanctionRequest">The player sanction request DTO.</param>
|
||||
/// <returns>The created player sanction response.</returns>
|
||||
[HttpPost()]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(PlayerSanctionResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<PlayerSanctionResponse>> CreatePlayerSanction(CreatePlayerSanctionRequest playerSanctionRequest)
|
||||
{
|
||||
PlayerSanction mappedSanction = mapper.Map<PlayerSanction>(playerSanctionRequest);
|
||||
PlayerSanction createdSanction = await playerSanctionService.CreatePlayerSanctionAsync(mappedSanction);
|
||||
PlayerSanctionResponse sanctionResponse = await ToResponseAsync(createdSanction);
|
||||
|
||||
return CreatedAtAction(nameof(GetPlayerSanctionById), new { idOrSlug = sanctionResponse.Id }, sanctionResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a player sanction by its id or its slug.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">Player sanction identifier as a GUID or slug.</param>
|
||||
/// <returns>The player sanction response DTO.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerSanctionResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PlayerSanctionResponse>> GetPlayerSanctionById(string idOrSlug)
|
||||
{
|
||||
PlayerSanction? sanction = await playerSanctionService.GetPlayerSanctionByIdOrSlugAsync(idOrSlug);
|
||||
|
||||
if (sanction is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(PlayerSanction), idOrSlug);
|
||||
}
|
||||
|
||||
PlayerSanctionResponse sanctionResponse = await ToResponseAsync(sanction);
|
||||
return Ok(sanctionResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a paginated list of player sanctions filtered by the specified criteria.
|
||||
/// </summary>
|
||||
/// <param name="filterRequest">The filtering parameters for player sanctions.</param>
|
||||
/// <returns>
|
||||
/// Returns a PaginatedResponse{PlayerSanctionResponse} containing the filtered sanctions.
|
||||
/// Possible HTTP responses:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>200 OK - The filtered sanctions were retrieved successfully.</description></item>
|
||||
/// <item><description>400 Bad Request - The request parameters were invalid.</description></item>
|
||||
/// </list>
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("find")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<PlayerSanctionResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<PaginatedResponse<PlayerSanctionResponse>>> GetFilteredPlayersPrivateAsync([FromQuery] GetPlayerSanctionsFilteredRequest filterRequest)
|
||||
{
|
||||
PaginatedResponse<PlayerSanction> paginatedPlayerSanctions = await playerSanctionService.GetPlayerSanctionsAsync(filterRequest);
|
||||
|
||||
PaginatedResponse<PlayerSanctionResponse> response = mapper.Map<PaginatedResponse<PlayerSanctionResponse>>(paginatedPlayerSanctions);
|
||||
|
||||
List<PlayerSanctionResponse> enrichedItems = [];
|
||||
foreach (PlayerSanction sanction in paginatedPlayerSanctions.Items)
|
||||
{
|
||||
enrichedItems.Add(await ToResponseAsync(sanction));
|
||||
}
|
||||
response.Items = enrichedItems;
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
/// <summary>
|
||||
/// Updates a player sanction's fields and re-enriches the response with the subject's display name and fechas-based status.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the sanction to update.</param>
|
||||
/// <param name="updateRequest">The request with updated sanction data.</param>
|
||||
/// <returns>Returns 200 OK with the updated sanction, or 404 Not Found if no sanction matches the id.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerSanctionResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> UpdatePlayerSanction(Guid id, UpdatePlayerSanctionRequest updateRequest)
|
||||
{
|
||||
PlayerSanction? existingSanction = await playerSanctionService.GetPlayerSanctionByIdAsync(id);
|
||||
|
||||
if (existingSanction is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(PlayerSanction), id);
|
||||
}
|
||||
|
||||
mapper.Map(updateRequest, existingSanction);
|
||||
await playerSanctionService.UpdatePlayerSanctionAsync(existingSanction);
|
||||
return Ok(await ToResponseAsync(existingSanction));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits an appeal against a sanction, moving it into pending review.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the sanction being appealed.</param>
|
||||
/// <param name="appealRequest">The appeal reason.</param>
|
||||
/// <returns>The updated sanction response. Returns 400 Bad Request if an appeal is already pending for this sanction.</returns>
|
||||
[HttpPut("{id:guid}/appeal")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerSanctionResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PlayerSanctionResponse>> AppealPlayerSanction(
|
||||
Guid id, AppealPlayerSanctionRequest appealRequest)
|
||||
{
|
||||
PlayerSanction? existingSanction = await playerSanctionService.GetPlayerSanctionByIdAsync(id);
|
||||
|
||||
if (existingSanction is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(PlayerSanction), id);
|
||||
}
|
||||
|
||||
if (existingSanction.AppealStatus == SanctionAppealStatus.Pending)
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.PlayerSanction.AppealAlreadyPending);
|
||||
}
|
||||
|
||||
existingSanction.AppealStatus = SanctionAppealStatus.Pending;
|
||||
existingSanction.AppealReason = appealRequest.Reason;
|
||||
existingSanction.AppealDate = DateTime.UtcNow;
|
||||
existingSanction.AppealResolution = null;
|
||||
existingSanction.AppealResolvedDate = null;
|
||||
|
||||
await playerSanctionService.UpdatePlayerSanctionAsync(existingSanction);
|
||||
return Ok(await ToResponseAsync(existingSanction));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a sanction's pending appeal as accepted or rejected, where accepting lifts the sanction immediately without deleting the sanction record.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the sanction whose appeal is resolved.</param>
|
||||
/// <param name="resolveRequest">The accepted or rejected decision and resolution notes.</param>
|
||||
/// <returns>The updated sanction response. Returns 400 Bad Request if the sanction has no pending appeal to resolve.</returns>
|
||||
[HttpPut("{id:guid}/appeal/resolve")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerSanctionResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PlayerSanctionResponse>> ResolvePlayerSanctionAppeal(
|
||||
Guid id, ResolveAppealRequest resolveRequest)
|
||||
{
|
||||
PlayerSanction? existingSanction = await playerSanctionService.GetPlayerSanctionByIdAsync(id);
|
||||
|
||||
if (existingSanction is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(PlayerSanction), id);
|
||||
}
|
||||
|
||||
if (existingSanction.AppealStatus != SanctionAppealStatus.Pending)
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.PlayerSanction.NoPendingAppealToResolve);
|
||||
}
|
||||
|
||||
existingSanction.AppealStatus = resolveRequest.Accepted
|
||||
? SanctionAppealStatus.Accepted
|
||||
: SanctionAppealStatus.Rejected;
|
||||
existingSanction.AppealResolution = resolveRequest.Resolution;
|
||||
existingSanction.AppealResolvedDate = DateTime.UtcNow;
|
||||
|
||||
await playerSanctionService.UpdatePlayerSanctionAsync(existingSanction);
|
||||
return Ok(await ToResponseAsync(existingSanction));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a player sanction by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the player sanction to delete.</param>
|
||||
/// <returns>Returns 204 No Content on success.</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> DeletePlayerSanctionById(Guid id)
|
||||
{
|
||||
await playerSanctionService.DeletePlayerSanctionAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a sanction to its response and enriches it with the fechas-based remaining/active status, labelled in fechas rather than calendar days.
|
||||
/// </summary>
|
||||
private async Task<PlayerSanctionResponse> ToResponseAsync(PlayerSanction sanction)
|
||||
{
|
||||
PlayerSanctionResponse response = mapper.Map<PlayerSanctionResponse>(sanction);
|
||||
|
||||
// Resolves who the sanction targets so the list or detail always shows the subject, regardless of which navigations the query happened to load.
|
||||
(string? playerFullName, string? teamName, string? staffName) =
|
||||
await playerSanctionService.ResolveSubjectAsync(sanction);
|
||||
response.PlayerFullName = playerFullName;
|
||||
response.TeamName = teamName;
|
||||
response.StaffName = staffName;
|
||||
|
||||
int? fechasRemaining = await playerSanctionService.GetFechasRemainingAsync(sanction);
|
||||
response.FechasRemaining = fechasRemaining;
|
||||
response.IsActive = fechasRemaining.HasValue
|
||||
? fechasRemaining.Value > 0
|
||||
: sanction.Duration > 0;
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.PlayerStatistic.Request;
|
||||
using Application.DTOs.PlayerStatistic.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages player statistics; reads are public but writes require Owner or Admin.
|
||||
/// </summary>
|
||||
/// <param name="playerStatisticService">The Player Statistic service.</param>
|
||||
/// <param name="mapper">The Auto_mapper instance.</param>
|
||||
[Route("api/player-statistics/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class PlayerStatisticController(IPlayerStatisticService playerStatisticService, IMapper mapper) : ControllerBase
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Records a single player-statistic entry directly, without the roster, eligibility, or score-total validation used by the match-sheet endpoint.
|
||||
/// </summary>
|
||||
/// <param name="playerStatisticRequest">The player statistic request DTO.</param>
|
||||
/// <returns>The created player statistic response.</returns>
|
||||
[HttpPost()]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(PlayerStatisticResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<PlayerStatisticResponse>> CreatePlayerStatistic(CreatePlayerStatisticRequest playerStatisticRequest)
|
||||
{
|
||||
PlayerStatistic mappedStatistic = mapper.Map<PlayerStatistic>(playerStatisticRequest);
|
||||
PlayerStatistic createdStatistic = await playerStatisticService.CreatePlayerStatisticAsync(mappedStatistic);
|
||||
PlayerStatisticResponse statisticResponse = mapper.Map<PlayerStatisticResponse>(createdStatistic);
|
||||
|
||||
return new ObjectResult(statisticResponse) { StatusCode = StatusCodes.Status201Created };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a whole team's scoring sheet for a match, validating that per-player points sum to the team's final score and every player is rostered and eligible.
|
||||
/// </summary>
|
||||
/// <param name="request">The match, team, and per-player points.</param>
|
||||
/// <returns>The persisted Points statistics for the team in this match.</returns>
|
||||
[HttpPost("match-sheet")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(PaginatedResponse<PlayerStatisticResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult> LoadMatchSheet(LoadMatchSheetRequest request)
|
||||
{
|
||||
List<PlayerStatistic> created = await playerStatisticService.LoadTeamMatchSheetAsync(request);
|
||||
|
||||
List<PlayerStatisticResponse> response = mapper.Map<List<PlayerStatisticResponse>>(created);
|
||||
|
||||
return new ObjectResult(response) { StatusCode = StatusCodes.Status201Created };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a paginated, filtered list of player statistics.
|
||||
/// </summary>
|
||||
/// <param name="filterRequest">The filtering and pagination parameters.</param>
|
||||
/// <returns>A paginated response containing the filtered player statistics.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet()]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<PlayerStatisticResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PaginatedResponse<PlayerStatisticResponse>>> GetFilteredPlayerStatistics(
|
||||
[FromQuery] GetPlayerStatisticsFilteredRequest filterRequest)
|
||||
{
|
||||
PaginatedResponse<PlayerStatistic> paginatedStatistics =
|
||||
await playerStatisticService.GetPlayerStatisticsAsync(filterRequest);
|
||||
|
||||
PaginatedResponse<PlayerStatisticResponse> response =
|
||||
mapper.Map<PaginatedResponse<PlayerStatisticResponse>>(paginatedStatistics);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a player statistic by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the player statistic.</param>
|
||||
/// <returns>The player statistic response DTO.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerStatisticResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PlayerStatisticResponse>> GetPlayerStatisticById(Guid id)
|
||||
{
|
||||
PlayerStatistic? statistic = await playerStatisticService.GetPlayerStatisticByIdAsync(id);
|
||||
|
||||
if (statistic is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(PlayerStatistic), id);
|
||||
}
|
||||
|
||||
PlayerStatisticResponse statisticResponse = mapper.Map<PlayerStatisticResponse>(statistic);
|
||||
return Ok(statisticResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a player statistic by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the statistic to update.</param>
|
||||
/// <param name="updateRequest">The request with updated statistics.</param>
|
||||
/// <returns>Returns 204 No Content on success, or 404 Not Found if no statistic matches the id.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> UpdatePlayerStatistic(Guid id, UpdatePlayerStatisticRequest updateRequest)
|
||||
{
|
||||
PlayerStatistic? existingStatistic = await playerStatisticService.GetPlayerStatisticByIdAsync(id);
|
||||
|
||||
if (existingStatistic is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(PlayerStatistic), id);
|
||||
}
|
||||
|
||||
mapper.Map(updateRequest, existingStatistic);
|
||||
await playerStatisticService.UpdatePlayerStatisticAsync(existingStatistic);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a player statistic by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the player statistic to delete.</param>
|
||||
/// <returns>Returns 204 No Content on success.</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> DeletePlayerStatisticById(Guid id)
|
||||
{
|
||||
await playerStatisticService.DeletePlayerStatisticAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.PointDeductions.Request;
|
||||
using Application.DTOs.PointDeductions.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages disciplinary point deductions applied to teams within a division; creating and deleting require Admin or Owner while listing is public.
|
||||
/// </summary>
|
||||
/// <param name="deductionService">The point-deduction service.</param>
|
||||
/// <param name="divisionService">The division service, used to validate the division.</param>
|
||||
/// <param name="teamService">The team service, used to validate the team.</param>
|
||||
/// <param name="mapper">The AutoMapper instance.</param>
|
||||
[Route("api/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class PointDeductionController(
|
||||
ITeamPointDeductionService deductionService,
|
||||
IDivisionService divisionService,
|
||||
ITeamService teamService,
|
||||
IMapper mapper) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies a point deduction to a team in a division.
|
||||
/// </summary>
|
||||
/// <param name="divisionId">The division whose standings the penalty affects.</param>
|
||||
/// <param name="request">The deduction, including team, points, and reason.</param>
|
||||
/// <returns>
|
||||
/// 201 Created with the deduction; 404 Not Found when the division or
|
||||
/// team does not exist; 403 Forbidden for non-staff callers.
|
||||
/// </returns>
|
||||
[HttpPost("divisions/{divisionId:guid}/point-deductions")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(PointDeductionResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<PointDeductionResponse>> CreatePointDeduction(
|
||||
Guid divisionId, CreatePointDeductionRequest request)
|
||||
{
|
||||
Division? division = await divisionService.GetSimpleDivisionByIdAsync(divisionId);
|
||||
if (division is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Division), divisionId);
|
||||
}
|
||||
|
||||
Team? team = await teamService.GetTeamByIdAsync(request.TeamId);
|
||||
if (team is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Team), request.TeamId);
|
||||
}
|
||||
|
||||
TeamPointDeduction deduction = mapper.Map<TeamPointDeduction>(request);
|
||||
deduction.DivisionId = divisionId;
|
||||
|
||||
TeamPointDeduction created = await deductionService.CreateAsync(deduction);
|
||||
PointDeductionResponse response = mapper.Map<PointDeductionResponse>(created);
|
||||
|
||||
return new ObjectResult(response) { StatusCode = StatusCodes.Status201Created };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists every point deduction applied in a division, public so standings can show each penalty.
|
||||
/// </summary>
|
||||
/// <param name="divisionId">The division whose deductions to list.</param>
|
||||
/// <returns>200 OK with the deductions, newest first.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("divisions/{divisionId:guid}/point-deductions")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<PointDeductionResponse>))]
|
||||
public async Task<ActionResult<List<PointDeductionResponse>>> GetPointDeductions(Guid divisionId)
|
||||
{
|
||||
List<TeamPointDeduction> deductions = await deductionService.GetByDivisionIdAsync(divisionId);
|
||||
return Ok(mapper.Map<List<PointDeductionResponse>>(deductions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a point deduction by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the deduction to remove.</param>
|
||||
/// <returns>204 No Content; 403 Forbidden for non-staff callers.</returns>
|
||||
[HttpDelete("point-deductions/{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> DeletePointDeduction(Guid id)
|
||||
{
|
||||
await deductionService.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Application.DTOs.Roster.Request;
|
||||
using Application.DTOs.Roster.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for team roster operations. Writes require Owner or Admin.
|
||||
/// </summary>
|
||||
/// <param name="rosterCopyService">The service that clones rosters across seasons.</param>
|
||||
[Route("api/teams/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class RosterController(
|
||||
IRosterCopyService rosterCopyService
|
||||
) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Clones a roster from a source team's season into this team's target season, creating a registration per player and skipping ones already registered.
|
||||
/// </summary>
|
||||
/// <param name="id">The target team to copy the roster into.</param>
|
||||
/// <param name="request">The source team and season and the target season.</param>
|
||||
/// <returns>
|
||||
/// <para>Returns 200 OK with how many registrations were created or skipped.</para>
|
||||
/// <para>Returns 403 Forbidden if the user is not authorized.</para>
|
||||
/// </returns>
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
[HttpPost("{id:guid}/roster/copy")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RosterCopyResult))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<RosterCopyResult>> CopyRoster(Guid id, [FromBody] CopyRosterRequest request)
|
||||
{
|
||||
RosterCopyResult result = await rosterCopyService.CopyRosterAsync(
|
||||
request.SourceTeamId, request.SourceTournamentId, id, request.TargetTournamentId);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.Match.Request;
|
||||
using Application.DTOs.Scorer.Request;
|
||||
using Application.DTOs.Scorer.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only scorer ranking aggregations, always public: goals by team and by player.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]/")]
|
||||
[AllowAnonymous]
|
||||
public class ScorerController(IScorerService scorerService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Ranks teams by goals scored across the filtered matches, the Goleadores-by-team view.
|
||||
/// </summary>
|
||||
/// <param name="filter">The match filtering and pagination parameters.</param>
|
||||
/// <returns>A paginated team scorer ranking.</returns>
|
||||
[HttpGet("by-team")]
|
||||
public async Task<ActionResult> GetFilteredScorerByTeam([FromQuery] GetMatchesFilteredRequest filter)
|
||||
{
|
||||
PaginatedResponse<ScorerByTeamResponse> response = await scorerService.GetAllScorersByTeamAsync(filter);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ranks individual players by goals scored, the Goleadores-by-player view.
|
||||
/// </summary>
|
||||
/// <param name="filter">The scorer filtering and pagination parameters.</param>
|
||||
/// <returns>A paginated player scorer ranking.</returns>
|
||||
[HttpGet("by-player")]
|
||||
public async Task<ActionResult> GetFilteredScorerByPlayer([FromQuery] GetScorerFilteredRequest filter)
|
||||
{
|
||||
PaginatedResponse<ScorerByPlayerResponse> response = await scorerService.GetAllScorersByPlayerAsync(filter);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Season.Request;
|
||||
using Application.DTOs.Season.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages Seasons, the top-level grouping of a period's tournaments. Reads are public; writes require Owner or Admin.
|
||||
/// </summary>
|
||||
/// <param name="seasonService">The Season service.</param>
|
||||
/// <param name="mapper">The AutoMapper instance.</param>
|
||||
[Route("api/seasons/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class SeasonController(ISeasonService seasonService, IMapper mapper) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new season asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="seasonRequest">The season creation request.</param>
|
||||
/// <returns>The created Season response.
|
||||
/// <para>Returns 201 Created with the Season response if the creation was successful.</para>
|
||||
/// <para>Returns 403 Forbidden if the user is not authorized.</para>
|
||||
/// </returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(SeasonResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<SeasonResponse>> CreateSeason(CreateSeasonRequest seasonRequest)
|
||||
{
|
||||
Season season = mapper.Map<Season>(seasonRequest);
|
||||
await seasonService.CreateSeasonAsync(season);
|
||||
SeasonResponse seasonResponse = mapper.Map<SeasonResponse>(season);
|
||||
return CreatedAtAction(nameof(GetSeasonById), new { idOrSlug = seasonResponse.Id }, seasonResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a season by its id or its public slug asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The season's GUID id or slug to retrieve.</param>
|
||||
/// <returns>The Season with the specified id or slug.
|
||||
/// <para>Returns 200 OK with the Season response if it was found.</para>
|
||||
/// <para>Returns 404 Not Found if the Season with the provided id or slug was not found.</para>
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(SeasonResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SeasonResponse>> GetSeasonById(string idOrSlug)
|
||||
{
|
||||
Season? season = await seasonService.GetSeasonByIdOrSlugAsync(idOrSlug);
|
||||
|
||||
if (season is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Season), idOrSlug);
|
||||
}
|
||||
|
||||
SeasonResponse seasonResponse = mapper.Map<SeasonResponse>(season);
|
||||
return Ok(seasonResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a season by its id asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the season to update.</param>
|
||||
/// <param name="seasonRequest">The season update request.</param>
|
||||
/// <returns>
|
||||
/// Returns 204 No Content if the update was successful.
|
||||
/// Returns 404 Not Found if the Season with the provided id was not found.
|
||||
/// Returns 403 Forbidden if the user is not authorized.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> UpdateSeason(Guid id, UpdateSeasonRequest seasonRequest)
|
||||
{
|
||||
Season? existingSeason = await seasonService.GetSeasonByIdAsync(id);
|
||||
|
||||
if (existingSeason is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Season), id);
|
||||
}
|
||||
|
||||
mapper.Map(seasonRequest, existingSeason);
|
||||
|
||||
await seasonService.UpdateSeasonAsync(existingSeason);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a season by its id asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the Season to delete.</param>
|
||||
/// <returns>
|
||||
/// Returns 204 No Content if the Season was successfully deleted.
|
||||
/// Returns 404 Not Found if the Season with the provided id was not found.
|
||||
/// Returns 403 Forbidden if the user is not authorized.
|
||||
/// </returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> DeleteSeasonById(Guid id)
|
||||
{
|
||||
Season? season = await seasonService.GetSeasonByIdAsync(id);
|
||||
|
||||
if (season is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Season), id);
|
||||
}
|
||||
|
||||
await seasonService.DeleteSeasonAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all seasons asynchronously.
|
||||
/// </summary>
|
||||
/// <returns>A list of all Season responses.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<SeasonResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<IEnumerable<SeasonResponse>>> GetAllSeasons()
|
||||
{
|
||||
IEnumerable<Season> seasons = await seasonService.GetAllSeasonsAsync();
|
||||
IEnumerable<SeasonResponse> response = mapper.Map<IEnumerable<SeasonResponse>>(seasons);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.Match.Response;
|
||||
using Application.DTOs.Stage.Request;
|
||||
using Application.DTOs.Stage.Response;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages Stage entities: creation, retrieval, update, deletion, and team assignment. Reads are public; writes require Owner or Admin.
|
||||
/// </summary>
|
||||
/// <param name="stageService">Service for Stage business logic and persistence operations.</param>
|
||||
/// <param name="mapper">AutoMapper instance for mapping between entities and DTOs.</param>
|
||||
[Route("api/stages/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class StageController(IStageService stageService, IMapper mapper) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new Stage.
|
||||
/// </summary>
|
||||
/// <param name="stageRequest">The Stage creation request DTO.</param>
|
||||
/// <returns>The created Stage response.</returns>
|
||||
[HttpPost()]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(StageResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<StageResponse>> CreateStage(CreateStageRequest stageRequest)
|
||||
{
|
||||
Stage mappedStage = mapper.Map<Stage>(stageRequest);
|
||||
Stage createdStage = await stageService.CreateStageAsync(mappedStage);
|
||||
StageResponse stageResponse = mapper.Map<StageResponse>(createdStage);
|
||||
return CreatedAtAction(nameof(GetStageById), new { idOrSlug = createdStage.Id }, stageResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a Stage by its id or its public slug.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The Stage's GUID id or slug.</param>
|
||||
/// <returns>The Stage entity.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Stage))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<StageResponse>> GetStageById(string idOrSlug)
|
||||
{
|
||||
Stage? stage = await stageService.GetStageByIdOrSlugAsync(idOrSlug);
|
||||
|
||||
if (stage == null)
|
||||
{
|
||||
return NotFound(ErrorMessages.Stage.NotFoundById(idOrSlug));
|
||||
}
|
||||
|
||||
StageResponse stageResponse = mapper.Map<StageResponse>(stage);
|
||||
return Ok(stageResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves filtered and paginated Stages.
|
||||
/// </summary>
|
||||
/// <param name="filterRequest">The filter and pagination parameters.</param>
|
||||
/// <returns>Paginated list of Stages.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<StageResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<PaginatedResponse<Stage>>> GetFilteredStages([FromQuery] GetStagesFilteredRequest filterRequest)
|
||||
{
|
||||
PaginatedResponse<Stage> paginatedStages = await stageService.GetAllStagesAsync(filterRequest);
|
||||
PaginatedResponse<StageResponse> paginatedResponse = mapper.Map<PaginatedResponse<StageResponse>>(paginatedStages);
|
||||
return Ok(paginatedResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing Stage.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the Stage to update.</param>
|
||||
/// <param name="stageRequest">The updated Stage data.</param>
|
||||
/// <returns>The updated Stage entity.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StageResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<StageResponse>> UpdateStage(Guid id, UpdateStageRequest stageRequest)
|
||||
{
|
||||
Stage? existingStage = await stageService.GetStageByIdAsync(id);
|
||||
if (existingStage == null)
|
||||
{
|
||||
return NotFound(ErrorMessages.Stage.NotFoundById(id));
|
||||
}
|
||||
|
||||
mapper.Map(stageRequest, existingStage);
|
||||
await stageService.UpdateStageAsync(existingStage);
|
||||
|
||||
StageResponse stageResponse = mapper.Map<StageResponse>(existingStage);
|
||||
return Ok(stageResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a Stage by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the Stage to delete.</param>
|
||||
/// <returns>Result of the delete operation.</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> DeleteStage(Guid id)
|
||||
{
|
||||
await stageService.DeleteStageAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns one or more teams to a specific stage.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the stage to which teams will be assigned.</param>
|
||||
/// <param name="request">The assignment request containing team IDs and assignment mode.</param>
|
||||
/// <returns>
|
||||
/// Returns HTTP 200 OK if the assignment is successful, or 404 Not Found if the stage does not exist.
|
||||
/// </returns>
|
||||
[HttpPost("{id:guid}/assign-team")]
|
||||
public async Task<ActionResult> AssignmentTeam(Guid id, AssignmentTeamRequest request)
|
||||
{
|
||||
Stage? stage = await stageService.GetStageByIdAsync(id);
|
||||
|
||||
if (stage == null)
|
||||
{
|
||||
return NotFound(ErrorMessages.Stage.NotFoundById(id));
|
||||
}
|
||||
|
||||
await stageService.AssignTeamsToStageAsync(stage, request.TeamIds, request.Auto);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unassigns one or more teams from a specific stage.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the stage from which teams will be unassigned.</param>
|
||||
/// <param name="request">The unassignment request containing team IDs.</param>
|
||||
/// <returns>
|
||||
/// Returns HTTP 200 OK if the unassignment is successful, or 404 Not Found if the stage does not exist.
|
||||
/// </returns>
|
||||
[HttpDelete("{id:guid}/unassign-team")]
|
||||
public async Task<ActionResult> UnassignmentTeam(Guid id, UnassignmentTeamRequest request)
|
||||
{
|
||||
Stage? stage = await stageService.GetStageByIdAsync(id);
|
||||
|
||||
if (stage == null)
|
||||
{
|
||||
return NotFound(ErrorMessages.Stage.NotFoundById(id));
|
||||
}
|
||||
|
||||
await stageService.UnassignTeamsFromStageAsync(stage, request.TeamIds);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an elimination stage's matches from the group-stage standings using the classic 1v8, 4v5, 2v7, 3v6 bracket seed order.
|
||||
/// </summary>
|
||||
/// <param name="id">The elimination stage to seed.</param>
|
||||
/// <returns>
|
||||
/// Returns HTTP 200 OK with the now-seeded matches, or 404 Not Found
|
||||
/// if the stage does not exist.
|
||||
/// </returns>
|
||||
[HttpPost("{id:guid}/seed")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<DetailedMatchResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> SeedKnockoutStage(Guid id)
|
||||
{
|
||||
Stage? stage = await stageService.GetStageByIdAsync(id);
|
||||
|
||||
if (stage == null)
|
||||
{
|
||||
return NotFound(ErrorMessages.Stage.NotFoundById(id));
|
||||
}
|
||||
|
||||
List<Match> seededMatches = await stageService.SeedKnockoutStageAsync(id);
|
||||
|
||||
return Ok(mapper.Map<List<DetailedMatchResponse>>(seededMatches));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a first-round pairing for a groupless bracket without persisting it, returning a signed token that a later commit can replay exactly.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/preview-draw")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DrawPreviewResult))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<DrawPreviewResult>> PreviewDraw(Guid id, DrawRequest request)
|
||||
{
|
||||
Stage? stage = await stageService.GetStageByIdAsync(id);
|
||||
|
||||
if (stage == null)
|
||||
{
|
||||
return NotFound(ErrorMessages.Stage.NotFoundById(id));
|
||||
}
|
||||
|
||||
DrawPreviewResult preview = await stageService.PreviewDrawAsync(id, request.Mode, request.ManualOrder);
|
||||
return Ok(preview);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a groupless bracket from a previewed token or a manual order, stamping DrawnAt and auditing the draw.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/draw")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<DetailedMatchResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<List<DetailedMatchResponse>>> CommitDraw(Guid id, DrawRequest request)
|
||||
{
|
||||
Stage? stage = await stageService.GetStageByIdAsync(id);
|
||||
|
||||
if (stage == null)
|
||||
{
|
||||
return NotFound(ErrorMessages.Stage.NotFoundById(id));
|
||||
}
|
||||
|
||||
List<Match> seededMatches = await stageService.CommitDrawAsync(id, request.Mode, request.DrawToken, request.ManualOrder);
|
||||
return Ok(mapper.Map<List<DetailedMatchResponse>>(seededMatches));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Statistics.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only historical player statistics; always public.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/statistics/")]
|
||||
[AllowAnonymous]
|
||||
public class StatisticsController(IStatisticsService statisticsService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// A player's statistic card: total and average points and games played, per season and overall.
|
||||
/// </summary>
|
||||
/// <param name="playerId">The player's id.</param>
|
||||
[HttpGet("players/{playerId:guid}/card")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerStatisticCardResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PlayerStatisticCardResponse>> GetPlayerCard(Guid playerId)
|
||||
{
|
||||
PlayerStatisticCardResponse? card = await statisticsService.GetPlayerCardAsync(playerId);
|
||||
|
||||
if (card is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Player), playerId);
|
||||
}
|
||||
|
||||
return Ok(card);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A player's trajectory across seasons: team, stats, and sanctions for each season.
|
||||
/// </summary>
|
||||
/// <param name="playerId">The player's id.</param>
|
||||
[HttpGet("players/{playerId:guid}/history")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerHistoryResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PlayerHistoryResponse>> GetPlayerHistory(Guid playerId)
|
||||
{
|
||||
PlayerHistoryResponse? history = await statisticsService.GetPlayerHistoryAsync(playerId);
|
||||
|
||||
if (history is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Player), playerId);
|
||||
}
|
||||
|
||||
return Ok(history);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.Team.Request;
|
||||
using Application.DTOs.Team.Response;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Storage;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages teams. Reads are public; writes require Owner or Admin.
|
||||
/// </summary>
|
||||
/// <param name="teamService">The team service for handling team-related operations.</param>
|
||||
/// <param name="supabaseHelper">The Supabase helper for storage operations.</param>
|
||||
/// <param name="mapper">The AutoMapper instance for mapping data models.</param>
|
||||
[Route("api/teams/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class TeamController(
|
||||
ITeamService teamService,
|
||||
SupabaseHelper supabaseHelper,
|
||||
IMapper mapper
|
||||
) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new team.
|
||||
/// </summary>
|
||||
/// <param name="teamRequest">The team creation request object containing the team details.</param>
|
||||
/// <returns>The created team response with details of the new team.</returns>
|
||||
[HttpPost()]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TeamResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<TeamResponse>> CreateTeam([FromForm] CreateTeamRequest teamRequest)
|
||||
{
|
||||
if (!teamRequest.LogoFile.IsValidImageFile())
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile);
|
||||
}
|
||||
|
||||
string logoUrl = await supabaseHelper.UploadImageAsync<Team>(teamRequest.LogoFile.OpenReadStream(), teamRequest.LogoFile.FileName);
|
||||
|
||||
Team team = mapper.Map<Team>(teamRequest);
|
||||
team.LogoUrl = logoUrl;
|
||||
|
||||
Team createdTeam = await teamService.CreateTeamAsync(team);
|
||||
TeamResponse teamResponse = mapper.Map<TeamResponse>(createdTeam);
|
||||
|
||||
return CreatedAtAction(nameof(GetTeamById), new { idOrSlug = createdTeam.Id }, teamResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a team by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the team to update.</param>
|
||||
/// <param name="teamRequest">The team request excluding logo update.</param>
|
||||
/// <returns>
|
||||
/// Returns 204 No Content if the update was successful.
|
||||
/// Returns 404 Not Found if the team with the provided id was not found.
|
||||
/// Returns 403 Forbidden if the user is not authorized.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> UpdateTeam(Guid id, UpdateTeamRequest teamRequest)
|
||||
{
|
||||
Team? existingTeam = await teamService.GetTeamByIdAsync(id);
|
||||
|
||||
if (existingTeam is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Team), id);
|
||||
}
|
||||
|
||||
// A team's name and three-letter code are frozen while it participates in an Ongoing tournament, so the check must run before the request is mapped over the entity, while the original identity is still available to compare.
|
||||
await teamService.EnsureTeamIdentityEditableAsync(existingTeam, teamRequest.Name, teamRequest.ThreeLetterCode);
|
||||
|
||||
mapper.Map(teamRequest, existingTeam);
|
||||
|
||||
await teamService.UpdateTeamAsync(existingTeam);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the logo of a team.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the team to update the logo.</param>
|
||||
/// <param name="logoRequest">The update team logo request.</param>
|
||||
/// <returns>Returns 200 OK if the logo was successfully updated.</returns>
|
||||
[HttpPut("{id:guid}/logo")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> UpdateTeamLogo(Guid id, [FromForm] UpdateTeamLogoRequest logoRequest)
|
||||
{
|
||||
if (!logoRequest.LogoFile.IsValidImageFile())
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile);
|
||||
}
|
||||
|
||||
Team? team = await teamService.GetTeamByIdAsync(id);
|
||||
if (team is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Team), id);
|
||||
}
|
||||
|
||||
team.LogoUrl = await supabaseHelper.UploadImageAsync<Team>(
|
||||
logoRequest.LogoFile.OpenReadStream(),
|
||||
logoRequest.LogoFile.FileName);
|
||||
|
||||
await teamService.UpdateTeamAsync(team);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a team by its id or slug; the embedded roster is scoped to one season.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The team's GUID id or slug to retrieve.</param>
|
||||
/// <param name="tournamentId">
|
||||
/// Optional: the season whose roster to embed. Defaults to the team's
|
||||
/// own current tournament, meaning today's roster, when omitted — pass this
|
||||
/// to look up the roster the team had during a past season instead.
|
||||
/// </param>
|
||||
/// <returns>The team with the specified id or slug.
|
||||
/// <para>Returns 200 OK with the team response if it was found.</para>
|
||||
/// <para>Returns 404 Not Found if the team with the provided id or slug was not found.</para>
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TeamResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TeamResponse>> GetTeamById(string idOrSlug, [FromQuery] Guid? tournamentId = null)
|
||||
{
|
||||
Team? team = await teamService.GetTeamByIdOrSlugAsync(idOrSlug, tournamentId);
|
||||
|
||||
if (team is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Team), idOrSlug);
|
||||
}
|
||||
|
||||
TeamResponse teamResponse = mapper.Map<TeamResponse>(team);
|
||||
return Ok(teamResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the team's current group-stage standing row for a tournament, powering the profile summary card.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The team's GUID id or slug.</param>
|
||||
/// <param name="tournamentId">
|
||||
/// Optional: the tournament to summarize. Defaults to the team's own current
|
||||
/// tournament when omitted.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <para>Returns 200 OK with the standing row when the team is in a
|
||||
/// group-stage table for the tournament.</para>
|
||||
/// <para>Returns 200 OK with a null body when the team is in no group-stage
|
||||
/// standing: playoff-only, unassigned, or with no finished matches yet.</para>
|
||||
/// <para>Returns 404 Not Found when the team does not exist.</para>
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}/summary")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TeamSummaryResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TeamSummaryResponse>> GetTeamSummary(string idOrSlug, [FromQuery] Guid? tournamentId = null)
|
||||
{
|
||||
Team? team = await teamService.GetTeamByIdOrSlugAsync(idOrSlug, tournamentId);
|
||||
|
||||
if (team is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Team), idOrSlug);
|
||||
}
|
||||
|
||||
TeamSummaryResponse? summary = await teamService.GetTeamSummaryAsync(team.Id, tournamentId ?? team.TournamentId);
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the team's home or visitor matches in a tournament, oriented from the team's perspective and ordered by date ascending.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The team's GUID id or slug.</param>
|
||||
/// <param name="tournamentId">
|
||||
/// Optional: the tournament to list matches for. Defaults to the team's own
|
||||
/// current tournament when omitted.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <para>Returns 200 OK with the team's matches, empty when there are none.</para>
|
||||
/// <para>Returns 404 Not Found when the team does not exist.</para>
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}/matches")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<TeamMatchResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<TeamMatchResponse>>> GetTeamMatches(string idOrSlug, [FromQuery] Guid? tournamentId = null)
|
||||
{
|
||||
Team? team = await teamService.GetTeamByIdOrSlugAsync(idOrSlug, tournamentId);
|
||||
|
||||
if (team is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Team), idOrSlug);
|
||||
}
|
||||
|
||||
List<TeamMatchResponse> matches = await teamService.GetTeamMatchesAsync(team.Id, tournamentId ?? team.TournamentId);
|
||||
return Ok(matches);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves every tournament the team has participated in, newest first, with season info.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The team's GUID id or slug.</param>
|
||||
/// <returns>
|
||||
/// <para>Returns 200 OK with the team's participations, empty when none.</para>
|
||||
/// <para>Returns 404 Not Found when the team does not exist.</para>
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}/participations")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<TeamParticipationResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<TeamParticipationResponse>>> GetTeamParticipations(string idOrSlug)
|
||||
{
|
||||
Team? team = await teamService.GetTeamByIdOrSlugAsync(idOrSlug);
|
||||
|
||||
if (team is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Team), idOrSlug);
|
||||
}
|
||||
|
||||
List<TeamParticipationResponse> participations = await teamService.GetTeamParticipationsAsync(team.Id, team.TournamentId);
|
||||
return Ok(participations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a team by its id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the team to delete.</param>
|
||||
/// <returns>
|
||||
/// Returns 204 No Content if the team was successfully deleted.
|
||||
/// Returns 404 Not Found if the team with the provided id was not found.
|
||||
/// Returns 403 Forbidden if the user is not authorized.
|
||||
/// </returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> DeleteTeamById(Guid id)
|
||||
{
|
||||
Team? team = await teamService.GetTeamByIdAsync(id);
|
||||
|
||||
if (team is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Team), id);
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(team.LogoUrl, UriKind.Absolute, out Uri? logoUri)
|
||||
&& (logoUri.Scheme == Uri.UriSchemeHttp || logoUri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
await supabaseHelper.DeleteImageAsync<Team>(logoUri.Segments[^1]);
|
||||
}
|
||||
await teamService.DeleteTeamAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves filtered teams with pagination.
|
||||
/// </summary>
|
||||
/// <param name="filterRequest">The filtering and pagination parameters.</param>
|
||||
/// <returns>A paginated response containing the filtered teams.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet()]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<TeamResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PaginatedResponse<TeamResponse>>> GetFilteredTeams([FromQuery] GetTeamsFilteredRequest filterRequest)
|
||||
{
|
||||
PaginatedResponse<Team> paginatedTeams = await teamService.GetAllTeamsAsync(filterRequest);
|
||||
|
||||
PaginatedResponse<TeamResponse> response = mapper.Map<PaginatedResponse<TeamResponse>>(paginatedTeams);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.TeamStaff.Request;
|
||||
using Application.DTOs.TeamStaff.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages a team's technical staff, scoped per team and tournament; creating and deleting require Admin or Owner, listing is public.
|
||||
/// </summary>
|
||||
/// <param name="staffService">The team-staff service.</param>
|
||||
/// <param name="teamService">The team service, used to validate the team.</param>
|
||||
/// <param name="tournamentService">The tournament service, used to validate the tournament.</param>
|
||||
/// <param name="mapper">The AutoMapper instance.</param>
|
||||
[Route("api/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class TeamStaffController(
|
||||
ITeamStaffService staffService,
|
||||
ITeamService teamService,
|
||||
ITournamentService tournamentService,
|
||||
IMapper mapper) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a member to a team's technical staff for a given tournament.
|
||||
/// </summary>
|
||||
/// <param name="teamId">The team the staff member belongs to.</param>
|
||||
/// <param name="request">The staff member's full name, role, and tournament.</param>
|
||||
/// <returns>
|
||||
/// Returns 201 Created with the staff member; 404 Not Found when the team or
|
||||
/// tournament does not exist; 403 Forbidden for non-staff callers.
|
||||
/// </returns>
|
||||
[HttpPost("teams/{teamId:guid}/staff")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TeamStaffResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<TeamStaffResponse>> CreateTeamStaff(
|
||||
Guid teamId, CreateTeamStaffRequest request)
|
||||
{
|
||||
Team? team = await teamService.GetTeamByIdAsync(teamId);
|
||||
if (team is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Team), teamId);
|
||||
}
|
||||
|
||||
Tournament? tournament = await tournamentService.GetTournamentByIdAsync(request.TournamentId);
|
||||
if (tournament is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Tournament), request.TournamentId);
|
||||
}
|
||||
|
||||
TeamStaff staff = mapper.Map<TeamStaff>(request);
|
||||
staff.TeamId = teamId;
|
||||
|
||||
TeamStaff created = await staffService.CreateAsync(staff);
|
||||
TeamStaffResponse response = mapper.Map<TeamStaffResponse>(created);
|
||||
|
||||
return new ObjectResult(response) { StatusCode = StatusCodes.Status201Created };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists a team's technical staff for a given tournament; public so a team's profile can show its staff.
|
||||
/// </summary>
|
||||
/// <param name="teamId">The team whose staff to list.</param>
|
||||
/// <param name="tournamentId">The tournament season to scope the staff to.</param>
|
||||
/// <returns>Returns 200 OK with the staff in the order they were added.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("teams/{teamId:guid}/staff")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<TeamStaffResponse>))]
|
||||
public async Task<ActionResult<List<TeamStaffResponse>>> GetTeamStaff(
|
||||
Guid teamId, [FromQuery] Guid tournamentId)
|
||||
{
|
||||
List<TeamStaff> staff =
|
||||
await staffService.GetByTeamAndTournamentAsync(teamId, tournamentId);
|
||||
return Ok(mapper.Map<List<TeamStaffResponse>>(staff));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a staff member by their id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the staff member to remove.</param>
|
||||
/// <returns>Returns 204 No Content; 403 Forbidden for non-staff callers.</returns>
|
||||
[HttpDelete("staff/{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> DeleteTeamStaff(Guid id)
|
||||
{
|
||||
await staffService.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.Champions.Response;
|
||||
using Application.DTOs.Divisions.Response;
|
||||
using Application.DTOs.Team.Response;
|
||||
using Application.DTOs.Tournament.Request;
|
||||
using Application.DTOs.Tournament.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages tournaments: create, retrieve, update, delete, filter, and register teams. Reads are public; writes require Owner or Admin.
|
||||
/// </summary>
|
||||
[Route("api/tournaments/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class TournamentController(
|
||||
ITournamentService tournamentService,
|
||||
ITeamService teamService,
|
||||
IChampionService championService,
|
||||
IMapper mapper) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new tournament.
|
||||
/// </summary>
|
||||
/// <param name="tournamentRequest">Tournament creation data.</param>
|
||||
/// <returns>
|
||||
/// Returns 201 Created with the created tournament details.
|
||||
/// Returns 400 Bad Request if the request is invalid.
|
||||
/// Returns 403 Forbidden if the user is not authorized.
|
||||
/// </returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TournamentResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<TournamentResponse>> CreateTournamentAsync(CreateTournamentRequest tournamentRequest)
|
||||
{
|
||||
Tournament mappedTournament = mapper.Map<Tournament>(tournamentRequest);
|
||||
Tournament createdTournament = await tournamentService.CreateTournamentAsync(mappedTournament);
|
||||
TournamentResponse tournamentResponse = mapper.Map<TournamentResponse>(createdTournament);
|
||||
|
||||
return new ObjectResult(tournamentResponse) { StatusCode = StatusCodes.Status201Created };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a whole tournament, including its divisions' cups, points, playoff mappings, and stages, in one atomic transaction.
|
||||
/// </summary>
|
||||
/// <param name="request">The full tournament-wizard payload.</param>
|
||||
/// <returns>
|
||||
/// Returns 201 Created with the created tournament, including its divisions.
|
||||
/// Returns 400 Bad Request if the payload is invalid or a rule, for
|
||||
/// example a division category mismatch, aborts the atomic create.
|
||||
/// Returns 403 Forbidden if the user is not authorized.
|
||||
/// </returns>
|
||||
[HttpPost("full")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TournamentResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<TournamentResponse>> CreateFullTournamentAsync(CreateFullTournamentRequest request)
|
||||
{
|
||||
Tournament createdTournament = await tournamentService.CreateFullTournamentAsync(request);
|
||||
TournamentResponse tournamentResponse = mapper.Map<TournamentResponse>(createdTournament);
|
||||
|
||||
return new ObjectResult(tournamentResponse) { StatusCode = StatusCodes.Status201Created };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds one division, with its group stage, cups, and playoff mappings, to an existing tournament in one atomic transaction, only while OpenForRegistration.
|
||||
/// </summary>
|
||||
/// <param name="tournamentId">The parent tournament's id.</param>
|
||||
/// <param name="request">The division's structure, zone or cross-cup.</param>
|
||||
/// <returns>
|
||||
/// Returns 201 Created with the created division.
|
||||
/// Returns 404 Not Found if the tournament does not exist.
|
||||
/// Returns 409 Conflict if the tournament is not OpenForRegistration or
|
||||
/// the division's category does not match the tournament's.
|
||||
/// </returns>
|
||||
[HttpPost("{tournamentId:guid}/divisions/full")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(DivisionResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<DivisionResponse>> AddFullDivision(
|
||||
Guid tournamentId, CreateFullDivisionRequest request)
|
||||
{
|
||||
Tournament? tournament = await tournamentService.GetTournamentByIdAsync(tournamentId);
|
||||
if (tournament is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Tournament), tournamentId);
|
||||
}
|
||||
|
||||
Division createdDivision = await tournamentService.AddFullDivisionAsync(tournament, request);
|
||||
DivisionResponse divisionResponse = mapper.Map<DivisionResponse>(createdDivision);
|
||||
|
||||
return new ObjectResult(divisionResponse) { StatusCode = StatusCodes.Status201Created };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a tournament by its unique identifier or its public slug.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">Tournament's GUID identifier or slug.</param>
|
||||
/// <returns>
|
||||
/// Returns 200 OK with tournament details if found.
|
||||
/// Returns 404 Not Found if not found.
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TournamentResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TournamentResponse>> GetTournamentById(string idOrSlug)
|
||||
{
|
||||
Tournament? tournament = await tournamentService.GetTournamentByIdOrSlugAsync(idOrSlug);
|
||||
|
||||
if (tournament is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Tournament), idOrSlug);
|
||||
}
|
||||
|
||||
TournamentResponse tournamentResponse = mapper.Map<TournamentResponse>(tournament);
|
||||
return Ok(tournamentResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a source tournament's full cloneable structure tree — every
|
||||
/// division's scoring/cup configuration, Stages, and PlayoffMappings — for
|
||||
/// the tournament-cloning wizard-prefill flow. Carries no instance data
|
||||
/// (teams, matches, rosters); additive, never replaces TournamentResponse
|
||||
/// or DivisionResponse.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">Tournament's GUID identifier or slug.</param>
|
||||
/// <returns>
|
||||
/// Returns 200 OK with the tournament's structure tree if found.
|
||||
/// Returns 404 Not Found if not found.
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}/structure")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TournamentStructureResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TournamentStructureResponse>> GetTournamentStructure(string idOrSlug)
|
||||
{
|
||||
Tournament? tournament = await tournamentService.GetTournamentByIdOrSlugAsync(idOrSlug);
|
||||
if (tournament is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Tournament), idOrSlug);
|
||||
}
|
||||
|
||||
Tournament? structure = await tournamentService.GetTournamentStructureAsync(tournament.Id);
|
||||
TournamentStructureResponse response = mapper.Map<TournamentStructureResponse>(structure);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves each division's podium: champion, runner-up, and third place, decided by playoff or group standings, with undecided places left null.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">Tournament's GUID identifier or slug.</param>
|
||||
/// <returns>
|
||||
/// Returns 200 OK with one podium per division.
|
||||
/// Returns 404 Not Found if the tournament does not exist.
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}/champions")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<PodiumResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<PodiumResponse>>> GetTournamentChampions(string idOrSlug)
|
||||
{
|
||||
Tournament? tournament = await tournamentService.GetTournamentByIdOrSlugAsync(idOrSlug);
|
||||
|
||||
if (tournament is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Tournament), idOrSlug);
|
||||
}
|
||||
|
||||
List<PodiumResponse> podiums = await championService.GetTournamentChampionsAsync(tournament.Id);
|
||||
return Ok(podiums);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a tournament's descriptive fields; a Status change is routed through a forward-only state machine instead of being written directly.
|
||||
/// </summary>
|
||||
/// <param name="id">Tournament's GUID identifier.</param>
|
||||
/// <param name="tournamentRequest">Tournament update data.</param>
|
||||
/// <returns>
|
||||
/// Returns 200 OK if updated successfully.
|
||||
/// Returns 404 Not Found if the tournament does not exist.
|
||||
/// Returns 403 Forbidden if unauthorized.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TournamentResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> UpdateTournamentAsync(Guid id, UpdateTournamentRequest tournamentRequest)
|
||||
{
|
||||
Tournament? existingTournament = await tournamentService.GetTournamentByIdAsync(id);
|
||||
|
||||
if (existingTournament is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Tournament), id);
|
||||
}
|
||||
|
||||
// Status is intentionally excluded from this mapping since descriptive fields update freely here, but a status change is a guarded state-machine transition handled below.
|
||||
mapper.Map(tournamentRequest, existingTournament);
|
||||
|
||||
await tournamentService.UpdateTournamentAsync(existingTournament);
|
||||
|
||||
// Routes any requested status change through the forward-only state machine; a no-op transition where the status is unchanged is ignored by the service, so re-sending the current status on a plain edit is harmless.
|
||||
if (tournamentRequest.Status is TournamentStatus requestedStatus)
|
||||
{
|
||||
await tournamentService.ChangeStatusAsync(id, requestedStatus);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a tournament by its identifier.
|
||||
/// </summary>
|
||||
/// <param name="id">Tournament's GUID identifier.</param>
|
||||
/// <returns>
|
||||
/// Returns 200 OK if deleted successfully.
|
||||
/// Returns 400 Bad Request if not found.
|
||||
/// Returns 403 Forbidden if unauthorized.
|
||||
/// </returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> DeleteTournamentById(Guid id)
|
||||
{
|
||||
await tournamentService.DeleteTournamentAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves tournaments filtered and paginated according to provided parameters.
|
||||
/// </summary>
|
||||
/// <param name="filterRequest">Filtering and pagination parameters.</param>
|
||||
/// <returns>
|
||||
/// Returns 200 OK with paginated tournament results.
|
||||
/// Returns 400 Bad Request if parameters are invalid.
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<TournamentResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PaginatedResponse<TournamentResponse>>> GetFilteredTournaments([FromQuery] GetTournamentsFilteredRequest filterRequest)
|
||||
{
|
||||
PaginatedResponse<Tournament> paginatedTournaments = await tournamentService.GetAllTournamentsAsync(filterRequest);
|
||||
PaginatedResponse<TournamentResponse> response = mapper.Map<PaginatedResponse<TournamentResponse>>(paginatedTournaments);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrolls a team into the tournament's registration phase, either as a new team or an existing club with an optional roster copy, in one transaction.
|
||||
/// </summary>
|
||||
/// <param name="tournamentId">Tournament's GUID identifier.</param>
|
||||
/// <param name="request">The enroll payload.</param>
|
||||
/// <returns>
|
||||
/// Returns 201 Created with the enrolled team, its roster scoped to this tournament.
|
||||
/// Returns 400 Bad Request when the payload shape is invalid: not exactly one of
|
||||
/// ExistingTeamId or NewTeamName, or CopyRosterFromTournamentId without ExistingTeamId.
|
||||
/// Returns 404 Not Found when the tournament or an existing team does not exist.
|
||||
/// Returns 409 Conflict when the tournament is not OpenForRegistration or the team
|
||||
/// is already enrolled.
|
||||
/// </returns>
|
||||
[HttpPost("{tournamentId:guid}/enroll-team")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TeamResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<TeamResponse>> EnrollTeam(Guid tournamentId, EnrollTeamRequest request)
|
||||
{
|
||||
bool hasExistingTeam = request.ExistingTeamId.HasValue;
|
||||
bool hasNewTeamName = !string.IsNullOrWhiteSpace(request.NewTeamName);
|
||||
|
||||
// Exactly one of ExistingTeamId / NewTeamName must be provided.
|
||||
if (hasExistingTeam == hasNewTeamName)
|
||||
{
|
||||
return BadRequest("Provide exactly one of ExistingTeamId or NewTeamName.");
|
||||
}
|
||||
|
||||
// CopyRosterFromTournamentId is only valid together with ExistingTeamId.
|
||||
if (request.CopyRosterFromTournamentId.HasValue && !hasExistingTeam)
|
||||
{
|
||||
return BadRequest("CopyRosterFromTournamentId is only allowed together with ExistingTeamId.");
|
||||
}
|
||||
|
||||
Tournament? tournament = await tournamentService.GetTournamentByIdAsync(tournamentId);
|
||||
if (tournament is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Tournament), tournamentId);
|
||||
}
|
||||
|
||||
Team enrolledTeam = await teamService.EnrollTeamAsync(
|
||||
tournament,
|
||||
request.ExistingTeamId,
|
||||
request.NewTeamName,
|
||||
request.CopyRosterFromTournamentId);
|
||||
|
||||
TeamResponse teamResponse = mapper.Map<TeamResponse>(enrolledTeam);
|
||||
return new ObjectResult(teamResponse) { StatusCode = StatusCodes.Status201Created };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports whether the tournament can be completed once started and the blocking issues when it cannot.
|
||||
/// </summary>
|
||||
/// <param name="tournamentId">Tournament's GUID identifier.</param>
|
||||
/// <returns>
|
||||
/// Returns 200 OK with the completability report, CanStart plus Issues.
|
||||
/// Returns 404 Not Found when the tournament does not exist.
|
||||
/// Returns 403 Forbidden if unauthorized.
|
||||
/// </returns>
|
||||
[HttpGet("{tournamentId:guid}/completability")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TournamentCompletabilityResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<TournamentCompletabilityResponse>> GetCompletability(Guid tournamentId)
|
||||
{
|
||||
TournamentCompletabilityResponse response = await tournamentService.GetCompletabilityAsync(tournamentId);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a team from the tournament during its registration or pre-start window, clearing only this tournament's footprint for the team.
|
||||
/// </summary>
|
||||
/// <param name="tournamentId">Tournament's GUID identifier.</param>
|
||||
/// <param name="teamId">Team's GUID identifier.</param>
|
||||
/// <returns>
|
||||
/// Returns 204 No Content when the team is removed.
|
||||
/// Returns 404 Not Found when the tournament does not exist or the team is not enrolled.
|
||||
/// Returns 409 Conflict when the tournament has already started and is not in a removable phase.
|
||||
/// Returns 403 Forbidden if unauthorized.
|
||||
/// </returns>
|
||||
[HttpDelete("{tournamentId:guid}/teams/{teamId:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> UnenrollTeam(Guid tournamentId, Guid teamId)
|
||||
{
|
||||
Tournament? tournament = await tournamentService.GetTournamentByIdAsync(tournamentId);
|
||||
if (tournament is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Tournament), tournamentId);
|
||||
}
|
||||
|
||||
await teamService.UnenrollTeamAsync(tournament, teamId);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using API.Utils.Helpers;
|
||||
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.User.Request;
|
||||
using Application.DTOs.User.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// User management endpoints for CRUD and logout; access rules are enforced in IUserManagementService.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[Authorize]
|
||||
public class UserController(
|
||||
IUserManagementService userManagementService,
|
||||
IAuditService auditService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists users paginated and filtered; Admins see every user, Owners only their subordinates, other roles get 403.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse<UserResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<PaginatedResponse<UserResponse>>> GetAll(
|
||||
[FromQuery] UserFilteredRequest filter, CancellationToken ct)
|
||||
{
|
||||
(string? role, Guid id) = User.GetCallerClaims();
|
||||
return Ok(await userManagementService.GetAllAsync(role, id, filter, ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a single user by id; Admins may view anyone, Owners themselves or their subordinates, other roles only themselves.
|
||||
/// </summary>
|
||||
[HttpGet("{userId:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserResponse>> GetById(Guid userId, CancellationToken ct)
|
||||
{
|
||||
(string? role, Guid id) = User.GetCallerClaims();
|
||||
return Ok(await userManagementService.GetByIdAsync(role, id, userId, ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user's profile fields and, if Role is set, reassigns the target's role; only Admins and Owners may reassign, and nobody may change their own.
|
||||
/// </summary>
|
||||
[HttpPut("{userId:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserResponse>> Update(
|
||||
Guid userId, [FromBody] UpdateUserRequest request, CancellationToken ct)
|
||||
{
|
||||
(string? role, Guid id) = User.GetCallerClaims();
|
||||
return Ok(await userManagementService.UpdateAsync(role, id, userId, request, ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes a user's password; CurrentPassword is required for a self-service change, optional when an Admin or Owner changes another's.
|
||||
/// </summary>
|
||||
[HttpPut("{userId:guid}/password")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ChangePassword(
|
||||
Guid userId, [FromBody] ChangePasswordRequest request, CancellationToken ct)
|
||||
{
|
||||
(string? role, Guid id) = User.GetCallerClaims();
|
||||
await userManagementService.ChangePasswordAsync(role, id, userId, request, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces a password reset, generating a new temporary password server-side and recording an audit log entry.
|
||||
/// </summary>
|
||||
[HttpPost("{userId:guid}/password/reset")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ResetPasswordResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ResetPasswordResponse>> ResetPassword(
|
||||
Guid userId, CancellationToken ct)
|
||||
{
|
||||
(string? role, Guid id) = User.GetCallerClaims();
|
||||
ResetPasswordResponse response = await userManagementService.ResetPasswordAsync(role, id, userId, ct);
|
||||
|
||||
// The target's email is looked up separately, since ResetPasswordResponse only carries the id and the audit trail needs to show who, not just an opaque guid.
|
||||
UserResponse resetUser = await userManagementService.GetByIdAsync(role, id, userId, ct);
|
||||
await auditService.LogAsync(
|
||||
AuditAction.PasswordReset,
|
||||
targetType: "User",
|
||||
targetId: userId.ToString(),
|
||||
targetName: resetUser.Email,
|
||||
ct: ct);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a user. Admins may delete any user; Owners only their own subordinates.
|
||||
/// </summary>
|
||||
[HttpDelete("{userId:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(Guid userId, CancellationToken ct)
|
||||
{
|
||||
(string? role, Guid id) = User.GetCallerClaims();
|
||||
await userManagementService.DeleteAsync(role, id, userId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates or deactivates a user account via Identity lockout; a deactivated account cannot log in.
|
||||
/// </summary>
|
||||
[HttpPut("{userId:guid}/active")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserResponse>> SetActive(
|
||||
Guid userId, [FromBody] SetUserActiveRequest request, CancellationToken ct)
|
||||
{
|
||||
(string? role, Guid id) = User.GetCallerClaims();
|
||||
return Ok(await userManagementService.SetActiveAsync(role, id, userId, request.IsActive, ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.DTOs.Venue.Request;
|
||||
using Application.DTOs.Venue.Response;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Storage;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages Venues. Reads are public; writes require Owner or Admin.
|
||||
/// </summary>
|
||||
/// <param name="venueService">The Venue service.</param>
|
||||
/// <param name="supabaseHelper">The Supabase helper for storage operations.</param>
|
||||
/// <param name="mapper">The AutoMapper instance.</param>
|
||||
[Route("api/venues/")]
|
||||
[ApiController]
|
||||
[Authorize(Roles = Roles.AdminOrOwner)]
|
||||
public class VenueController(IVenueService venueService, SupabaseHelper supabaseHelper, IMapper mapper) : ControllerBase
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new venue asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="venueRequest">The venue creation request.</param>
|
||||
/// <returns>The created Venue response.
|
||||
/// <para>Returns 201 Created with the Venue response if the creation was successful.</para>
|
||||
/// <para>Returns 403 Forbidden if the user is not authorized.</para>
|
||||
/// </returns>
|
||||
[HttpPost()]
|
||||
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(VenueResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<VenueResponse>> CreateVenue([FromForm] CreateVenueRequest venueRequest)
|
||||
{
|
||||
Venue venue = mapper.Map<Venue>(venueRequest);
|
||||
|
||||
if (venueRequest.ImageFile is not null)
|
||||
{
|
||||
venue.PhotoUrl = await supabaseHelper.UploadImageAsync<Venue>(
|
||||
venueRequest.ImageFile.OpenReadStream(),
|
||||
venueRequest.ImageFile.FileName);
|
||||
}
|
||||
|
||||
await venueService.CreateVenueAsync(venue);
|
||||
VenueResponse venueResponse = mapper.Map<VenueResponse>(venue);
|
||||
return CreatedAtAction(nameof(GetVenueById), new { idOrSlug = venueResponse.Id }, venueResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a venue by its id or its public slug asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="idOrSlug">The venue's GUID id or slug to retrieve.</param>
|
||||
/// <returns>The Venue with the specified id or slug.
|
||||
/// <para>Returns 200 OK with the Venue response if it was found.</para>
|
||||
/// <para>Returns 404 Not Found if the Venue with the provided id or slug was not found.</para>
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{idOrSlug}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(VenueResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<VenueResponse>> GetVenueById(string idOrSlug)
|
||||
{
|
||||
Venue? venue = await venueService.GetVenueByIdOrSlugAsync(idOrSlug);
|
||||
|
||||
if (venue is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Venue), idOrSlug);
|
||||
}
|
||||
|
||||
VenueResponse venueResponse = mapper.Map<VenueResponse>(venue);
|
||||
return Ok(venueResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a venue by its id asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the venue to update.</param>
|
||||
/// <param name="venueRequest">The venue update request.</param>
|
||||
/// <returns>
|
||||
/// Returns 200 OK if the update was successful.
|
||||
/// Returns 404 Not Found if the Venue with the provided id was not found.
|
||||
/// Returns 403 Forbidden if the user is not authorized.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> UpdateVenue(Guid id, UpdateVenueRequest venueRequest)
|
||||
{
|
||||
Venue? existingVenue = await venueService.GetVenueByIdAsync(id);
|
||||
|
||||
if (existingVenue is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Venue), id);
|
||||
}
|
||||
|
||||
mapper.Map(venueRequest, existingVenue);
|
||||
|
||||
await venueService.UpdateVenueAsync(existingVenue);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the photo of a venue.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the venue to update the photo.</param>
|
||||
/// <param name="photoRequest">The update venue photo request.</param>
|
||||
/// <returns>Returns 200 OK if the photo was successfully updated.</returns>
|
||||
[HttpPut("{id:guid}/photo")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> UpdateVenuePhoto(Guid id, [FromForm] UpdateVenuePhotoRequest photoRequest)
|
||||
{
|
||||
if (!photoRequest.ImageFile.IsValidImageFile())
|
||||
{
|
||||
return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile);
|
||||
}
|
||||
|
||||
Venue? venue = await venueService.GetVenueByIdAsync(id);
|
||||
if (venue is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Venue), id);
|
||||
}
|
||||
|
||||
venue.PhotoUrl = await supabaseHelper.UploadImageAsync<Venue>(
|
||||
photoRequest.ImageFile.OpenReadStream(),
|
||||
photoRequest.ImageFile.FileName);
|
||||
|
||||
await venueService.UpdateVenueAsync(venue);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a venue by its id asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the Venue to delete.</param>
|
||||
/// <returns>
|
||||
/// Returns 204 No Content if the Venue was successfully deleted.
|
||||
/// Returns 404 Not Found if the Venue with the provided id was not found.
|
||||
/// Returns 409 Conflict if the Venue is still referenced by one or more matches.
|
||||
/// Returns 403 Forbidden if the user is not authorized.
|
||||
/// </returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> DeleteVenueById(Guid id)
|
||||
{
|
||||
Venue? venue = await venueService.GetVenueByIdAsync(id);
|
||||
|
||||
if (venue is null)
|
||||
{
|
||||
return this.NotFoundProblem(nameof(Venue), id);
|
||||
}
|
||||
|
||||
// Runs the integrity guard first, which throws 409 when the venue is still referenced by matches, so the stored photo is only removed once the venue row is actually gone.
|
||||
await venueService.DeleteVenueAsync(id);
|
||||
|
||||
if (Uri.TryCreate(venue.PhotoUrl, UriKind.Absolute, out Uri? photoUri)
|
||||
&& (photoUri.Scheme == Uri.UriSchemeHttp || photoUri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
await supabaseHelper.DeleteImageAsync<Venue>(photoUri.Segments[^1]);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all venues asynchronously.
|
||||
/// </summary>
|
||||
/// <returns>A list of all Venue responses.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet()]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<VenueResponse>))]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<IEnumerable<VenueResponse>>> GetAllVenues()
|
||||
{
|
||||
IEnumerable<Venue> venues = await venueService.GetAllVenuesAsync();
|
||||
IEnumerable<VenueResponse> response = mapper.Map<IEnumerable<VenueResponse>>(venues);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user