Sube carpetas API, API.Tests, Application y Domain del Backend
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
namespace API.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized configuration key paths read at startup, so a typo in a key name is caught by the compiler instead of silently returning null or a default value at runtime.
|
||||
/// </summary>
|
||||
public static class ConfigurationKeys
|
||||
{
|
||||
public const string DbConnection = "DbConnection";
|
||||
public const string AllowedOrigins = "AllowedOrigins";
|
||||
public const string UseLoggingMiddleware = "UseLoggingMiddleware";
|
||||
|
||||
public static class Jwt
|
||||
{
|
||||
public const string Key = "JWT:Key";
|
||||
public const string Issuer = "JWT:Issuer";
|
||||
public const string Audience = "JWT:Audience";
|
||||
}
|
||||
|
||||
public static class Swagger
|
||||
{
|
||||
public const string Title = "Swagger:Title";
|
||||
public const string Version = "Swagger:Version";
|
||||
}
|
||||
|
||||
public static class Smtp
|
||||
{
|
||||
public const string Host = "Smtp:Host";
|
||||
public const string Port = "Smtp:Port";
|
||||
public const string Username = "Smtp:Username";
|
||||
public const string Password = "Smtp:Password";
|
||||
public const string UseSsl = "Smtp:UseSsl";
|
||||
public const string FromEmail = "Smtp:FromEmail";
|
||||
public const string FromName = "Smtp:FromName";
|
||||
}
|
||||
|
||||
public static class Backup
|
||||
{
|
||||
public const string Section = "Backup";
|
||||
public const string Enabled = "Backup:Enabled";
|
||||
}
|
||||
|
||||
public static class Seed
|
||||
{
|
||||
public const string Enabled = "Seed:Enabled";
|
||||
|
||||
/// <summary>
|
||||
/// When true, the startup DataSeeder deletes existing sample domain data before seeding, forcing a clean, FK-safe reseed; defaults to false.
|
||||
/// </summary>
|
||||
public const string Reset = "Seed:Reset";
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem folder the startup DataSeeder reads team crest PNGs from to upload as real team logos, falling back to placeholders when absent.
|
||||
/// </summary>
|
||||
public const string LogosPath = "Seed:LogosPath";
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem path to the medical PDF the startup DataSeeder uploads for every should-be-habilitado seeded registration, skipping the backfill step without failing the seed when absent.
|
||||
/// </summary>
|
||||
public const string MedicalRecordPath = "Seed:MedicalRecordPath";
|
||||
|
||||
/// <summary>
|
||||
/// How many consecutive seasons of history the startup DataSeeder builds, counting backwards from the most recent one and clamped to a safe range by the seeder.
|
||||
/// </summary>
|
||||
public const string Seasons = "Seed:Seasons";
|
||||
|
||||
/// <summary>
|
||||
/// Roster size for every seeded team, defaulting to SampleTournamentBuilder.DefaultPlayersPerTeam and clamped to a safe range by the seeder.
|
||||
/// </summary>
|
||||
public const string PlayersPerTeam = "Seed:PlayersPerTeam";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Shared ControllerBase extensions for consistent HTTP responses across controllers.
|
||||
/// </summary>
|
||||
public static class ControllerBaseExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a 404 response whose ProblemDetails body matches the shape emitted by GlobalExceptionHandler for unhandled exceptions.
|
||||
/// </summary>
|
||||
/// <param name="controller">The controller issuing the response.</param>
|
||||
/// <param name="entity">The entity type that could not be found.</param>
|
||||
/// <param name="id">The identifier that was looked up.</param>
|
||||
/// <returns>An ObjectResult with a 404 status and ProblemDetails body.</returns>
|
||||
public static ObjectResult NotFoundProblem(this ControllerBase controller, string entity, object id)
|
||||
{
|
||||
return controller.Problem(
|
||||
detail: $"{entity} with id {id} not found.",
|
||||
statusCode: StatusCodes.Status404NotFound,
|
||||
title: "Not Found: The specified resource could not be found.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a 400 response whose ProblemDetails body matches the shape emitted by GlobalExceptionHandler for unhandled exceptions, so the frontend's error handler (which reads the detail field) surfaces this message instead of a generic fallback.
|
||||
/// </summary>
|
||||
/// <param name="controller">The controller issuing the response.</param>
|
||||
/// <param name="detail">The specific, user-facing reason the request was rejected.</param>
|
||||
/// <returns>An ObjectResult with a 400 status and ProblemDetails body.</returns>
|
||||
public static ObjectResult BadRequestProblem(this ControllerBase controller, string detail)
|
||||
{
|
||||
return controller.Problem(
|
||||
detail: detail,
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Bad Request: The request could not be completed.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace API.Utils.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// A custom JSON converter for DateTime that accepts many common input formats when reading but always writes a single canonical format.
|
||||
/// </summary>
|
||||
public class DateOnlyJsonConverter : JsonConverter<DateTime>
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// The formats accepted when parsing a date from JSON, tried in order; only used for reading, since Write always emits a single canonical format.
|
||||
/// </summary>
|
||||
private static readonly string[] _formats =
|
||||
[
|
||||
"dd/MM/yyyy",
|
||||
"MM/dd/yyyy",
|
||||
"yyyy-MM-dd",
|
||||
"yyyy/MM/dd",
|
||||
"yyyyMMdd",
|
||||
"dd-MM-yyyy",
|
||||
"MM-dd-yyyy",
|
||||
"dd.MM.yyyy",
|
||||
"yyyy-MM-ddTHH:mm:ss",
|
||||
"yyyy-MM-ddTHH:mm:ss.fff",
|
||||
"yyyy-MM-ddTHH:mm:ssZ",
|
||||
"yyyy-MM-ddTHH:mm:ss.fffZ",
|
||||
"o",
|
||||
"s",
|
||||
"yyyy-MM-ddTHH:mm"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Reads and converts the JSON string to a DateTime, trying each of the accepted formats in order until one matches.
|
||||
/// </summary>
|
||||
/// <param name="reader">The Utf8JsonReader to read from.</param>
|
||||
/// <param name="typeToConvert">The type to convert, expected to be DateTime.</param>
|
||||
/// <param name="options">The JsonSerializerOptions used during deserialization.</param>
|
||||
/// <returns>A DateTime parsed from the JSON string.</returns>
|
||||
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
string? dateString = reader.GetString();
|
||||
|
||||
return DateTime.TryParseExact(
|
||||
dateString,
|
||||
_formats,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out DateTime parsedDate)
|
||||
? DateTime.SpecifyKind(parsedDate, DateTimeKind.Unspecified)
|
||||
: throw new JsonException(ErrorMessages.Serialization.InvalidDate(dateString));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a DateTime value as a JSON string using the canonical yyyy-MM-ddTHH:mm:ss.fffZ format, regardless of which accepted format it was originally read from.
|
||||
/// </summary>
|
||||
/// <param name="writer">The Utf8JsonWriter to write to.</param>
|
||||
/// <param name="value">The DateTime value to serialize.</param>
|
||||
/// <param name="options">The JsonSerializerOptions used during serialization.</param>
|
||||
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace API.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Displays the display name of an enum value in the Swagger UI.
|
||||
/// </summary>
|
||||
public class DisplayEnumSchemaFilter : ISchemaFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Replaces an enum's raw member names in the generated schema with each value's DisplayAttribute.Name, falling back to the member name when absent, so Swagger shows the same friendly text as the UI.
|
||||
/// </summary>
|
||||
/// <param name="schema">The OpenAPI schema being generated for context's type.</param>
|
||||
/// <param name="context">The schema generation context; used to check Type for enum-ness.</param>
|
||||
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
|
||||
{
|
||||
if (context.Type.IsEnum)
|
||||
{
|
||||
Type enumType = context.Type;
|
||||
schema.Enum = [.. Enum.GetNames(enumType)
|
||||
.Select(name =>
|
||||
{
|
||||
MemberInfo[] memberInfo = enumType.GetMember(name);
|
||||
DisplayAttribute? displayAttribute = memberInfo[0].GetCustomAttribute<DisplayAttribute>();
|
||||
|
||||
string displayName = displayAttribute?.Name ?? name;
|
||||
return new OpenApiString(displayName) as IOpenApiAny;
|
||||
})];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace API.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Maps file-extension enums to the actual dotted extension string, so ImageFileExtension.Jpg maps to ".jpg".
|
||||
/// </summary>
|
||||
public static class FileExtensionMappings
|
||||
{
|
||||
public static string ToExtensionString(this ImageFileExtension extension)
|
||||
{
|
||||
return $".{extension.ToString().ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public static string ToExtensionString(this SpreadsheetFileExtension extension)
|
||||
{
|
||||
return $".{extension.ToString().ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace API.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for file validation.
|
||||
/// </summary>
|
||||
public static class FileExtensions
|
||||
{
|
||||
private static readonly ImageFileExtension[] _validImageExtensions =
|
||||
[
|
||||
ImageFileExtension.Jpg,
|
||||
ImageFileExtension.Jpeg,
|
||||
ImageFileExtension.Png,
|
||||
ImageFileExtension.Webp,
|
||||
];
|
||||
|
||||
private static readonly SpreadsheetFileExtension[] _validSpreadsheetExtensions =
|
||||
[
|
||||
SpreadsheetFileExtension.Xls,
|
||||
SpreadsheetFileExtension.Xlsx,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the provided file is a valid JPEG, PNG, or WEBP image.
|
||||
/// </summary>
|
||||
/// <param name="file">The uploaded file to check.</param>
|
||||
/// <returns>True if the file is a valid image, otherwise false.</returns>
|
||||
public static bool IsValidImageFile(this IFormFile file)
|
||||
{
|
||||
if (file is null || file.Length is 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
|
||||
return Array.Exists(_validImageExtensions, extension => extension.ToExtensionString() == fileExtension);
|
||||
}
|
||||
|
||||
private const string PdfExtension = ".pdf";
|
||||
private const string PdfContentType = "application/pdf";
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the uploaded file is a valid PDF by extension and, when present, content type. Used to gate medical-record uploads.
|
||||
/// </summary>
|
||||
/// <param name="file">The uploaded file to check.</param>
|
||||
/// <returns>True if the file is a non-empty PDF, otherwise false.</returns>
|
||||
public static bool IsValidPdfFile(this IFormFile file)
|
||||
{
|
||||
if (file is null || file.Length is 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
bool extensionOk = fileExtension == PdfExtension;
|
||||
bool contentTypeOk = string.IsNullOrEmpty(file.ContentType)
|
||||
|| string.Equals(file.ContentType, PdfContentType, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return extensionOk && contentTypeOk;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the provided file is a valid XLSX or XLS Excel file.
|
||||
/// </summary>
|
||||
/// <param name="file">The uploaded file to check.</param>
|
||||
/// <returns>True if the file is a valid Excel file, otherwise false.</returns>
|
||||
public static bool IsValidExcelFile(this IFormFile file)
|
||||
{
|
||||
if (file is null || file.Length is 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
|
||||
return Array.Exists(_validSpreadsheetExtensions, extension => extension.ToExtensionString() == fileExtension);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Handles global exceptions and returns standardized ProblemDetails responses.
|
||||
/// </summary>
|
||||
public class GlobalExceptionHandler : IExceptionHandler
|
||||
{
|
||||
private readonly ILogger<GlobalExceptionHandler> _logger;
|
||||
|
||||
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_logger.LogInformation("GlobalHandlerException initialized.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to handle exceptions globally and returns standardized ProblemDetails.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The current HTTP context.</param>
|
||||
/// <param name="exception">The exception to handle.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation of handling the exception.</returns>
|
||||
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
|
||||
{
|
||||
IExceptionHandlerFeature? exceptionHandlerFeature = httpContext.Features.Get<IExceptionHandlerFeature>();
|
||||
if (exceptionHandlerFeature is not null)
|
||||
{
|
||||
string traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;
|
||||
Exception exceptionDetails = exceptionHandlerFeature.Error;
|
||||
|
||||
_logger.LogError(exceptionDetails,
|
||||
"An unhandled exception occurred on the {MachineName}. TraceId: {TraceId}.",
|
||||
Environment.MachineName,
|
||||
traceId);
|
||||
|
||||
(int statusCode, string title, bool exposeMessage) = MapException(exceptionDetails);
|
||||
|
||||
IResult result = Results.Problem(
|
||||
title: title,
|
||||
detail: exposeMessage
|
||||
? exceptionDetails.Message
|
||||
: "Ocurrió un error inesperado. Contactá a soporte con el ID de rastreo a continuación.",
|
||||
statusCode: statusCode,
|
||||
extensions: new Dictionary<string, object?> { ["traceId"] = traceId }
|
||||
);
|
||||
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps known exceptions to HTTP status codes and response titles, and decides whether the exception's own message is safe to expose to the client.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception to map.</param>
|
||||
/// <returns>
|
||||
/// A tuple containing the HTTP status code, a corresponding title for the response,
|
||||
/// and whether the exception's message is safe to expose to the client.
|
||||
/// </returns>
|
||||
private static (int StatusCode, string Title, bool ExposeMessage) MapException(Exception exception)
|
||||
{
|
||||
return exception switch
|
||||
{
|
||||
ArgumentNullException => (StatusCodes.Status400BadRequest, "Bad Request: Required argument is missing.", true),
|
||||
UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "Forbidden: You do not have permission to access this resource.", true),
|
||||
KeyNotFoundException => (StatusCodes.Status404NotFound, "Not Found: The specified resource could not be found.", true),
|
||||
InvalidOperationException => (StatusCodes.Status409Conflict, "Conflict: The operation is invalid in the current state.", true),
|
||||
TimeoutException => (StatusCodes.Status408RequestTimeout, "Request Timeout: The operation took too long to complete.", false),
|
||||
FormatException => (StatusCodes.Status400BadRequest, "Bad Request: Invalid format encountered.", true),
|
||||
NotImplementedException => (StatusCodes.Status501NotImplemented, "Not Implemented: The requested functionality is not available.", true),
|
||||
StackOverflowException => (StatusCodes.Status500InternalServerError, "Internal Server Error: Stack overflow occurred.", false),
|
||||
OperationCanceledException => (StatusCodes.Status499ClientClosedRequest, "Client Closed Request: Operation was cancelled by the client.", true),
|
||||
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.", false)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace API.Utils.Helpers;
|
||||
|
||||
public static class AuthHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts the caller's role and id from the Role and NameIdentifier claims, letting Guid.Parse throw a FormatException on an unauthenticated or malformed principal rather than returning a default value.
|
||||
/// </summary>
|
||||
public static (string role, Guid id) GetCallerClaims(this ClaimsPrincipal principal)
|
||||
{
|
||||
string role = principal.FindFirstValue(ClaimTypes.Role) ?? string.Empty;
|
||||
string sub = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty;
|
||||
return (role, Guid.Parse(sub));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Constants;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace API.Utils.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the current caller's identity from the HTTP context for the audit trail, falling back to AuditConstants.SystemUser when there is no authenticated request.
|
||||
/// </summary>
|
||||
public sealed class HttpCurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUserAccessor
|
||||
{
|
||||
public string Actor
|
||||
{
|
||||
get
|
||||
{
|
||||
ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
|
||||
|
||||
string? identifier = user?.FindFirstValue(ClaimTypes.Email)
|
||||
?? user?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
return string.IsNullOrWhiteSpace(identifier) ? AuditConstants.SystemUser : identifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace API.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// The image formats accepted for photo/logo uploads.
|
||||
/// </summary>
|
||||
public enum ImageFileExtension
|
||||
{
|
||||
Jpg,
|
||||
Jpeg,
|
||||
Png,
|
||||
Webp,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace API.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized log message text used at application startup and shutdown.
|
||||
/// </summary>
|
||||
public static class LogMessages
|
||||
{
|
||||
public const string StartingUp = "----- Starting up -----";
|
||||
public const string Started = "----- Started -----";
|
||||
public const string TerminatedUnexpectedly = "Application terminated unexpectedly";
|
||||
|
||||
public const string Banner = @"
|
||||
|
||||
#### ## ## ## ##### ## ####
|
||||
## ## ## ## ## ## ## ### ## ##
|
||||
## ## ## ## ##### ## ##
|
||||
## ## ## ## ## ## ## ##
|
||||
## ## ## ## ## ## ## ## ##
|
||||
#### ###### ###### ##### ###### ######
|
||||
|
||||
";
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Utils.Middlewares;
|
||||
|
||||
/// <summary>
|
||||
/// Returns 503 for every request outside the allow-listed paths while maintenance mode is active.
|
||||
/// </summary>
|
||||
public class MaintenanceModeMiddleware(RequestDelegate next, IMaintenanceModeState maintenanceModeState)
|
||||
{
|
||||
private static readonly string[] AllowedPaths =
|
||||
[
|
||||
"/health",
|
||||
"/api/maintenance",
|
||||
];
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (maintenanceModeState.IsActive && !Array.Exists(AllowedPaths, p => context.Request.Path.StartsWithSegments(p)))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
context.Response.Headers.RetryAfter = "30";
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "The application is temporarily in maintenance mode.",
|
||||
reason = maintenanceModeState.Reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Application.Utils.Constants.Auth;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Utils.Middlewares;
|
||||
|
||||
/// <summary>
|
||||
/// Rejects every request except the user-update and logout endpoints with 403 once CustomClaimTypes.MustChangePassword is set on the caller's token, until the password is changed.
|
||||
/// </summary>
|
||||
public class MustChangePasswordMiddleware(RequestDelegate next)
|
||||
{
|
||||
private static readonly string[] AllowedPaths =
|
||||
[
|
||||
"/api/users/",
|
||||
"/api/auth/logout",
|
||||
];
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
bool mustChange = context.User.FindFirstValue(CustomClaimTypes.MustChangePassword) == "true";
|
||||
|
||||
if (mustChange && !Array.Exists(AllowedPaths, p => context.Request.Path.StartsWithSegments(p)))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Password change required.",
|
||||
action = "PUT /api/users/{your-id}/password"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Utils.Middlewares;
|
||||
|
||||
/// <summary>
|
||||
/// Logs one compact, structured line per HTTP request: method, path, status code, elapsed time, and correlation id.
|
||||
/// </summary>
|
||||
public class RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
|
||||
{
|
||||
private const string LogTemplate =
|
||||
"HTTP {Method} {PathAndQuery} responded {StatusCode} in {ElapsedMs} ms [{CorrelationId}]";
|
||||
private const string HttpMethodOptions = "OPTIONS";
|
||||
|
||||
/// <summary>
|
||||
/// Times the request, invokes the pipeline, and logs the outcome at a level matched to the response status code.
|
||||
/// </summary>
|
||||
/// <param name="context">The current HTTP context.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
|
||||
await next(context);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
bool isPreflight = context.Request.Method == HttpMethodOptions;
|
||||
int statusCode = context.Response.StatusCode;
|
||||
LogLevel level = statusCode switch
|
||||
{
|
||||
_ when isPreflight => LogLevel.Debug,
|
||||
>= 500 => LogLevel.Error,
|
||||
>= 400 => LogLevel.Warning,
|
||||
_ => LogLevel.Information,
|
||||
};
|
||||
|
||||
logger.Log(
|
||||
level,
|
||||
LogTemplate,
|
||||
context.Request.Method,
|
||||
context.Request.Path + context.Request.QueryString,
|
||||
statusCode,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
context.TraceIdentifier);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace API.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// The spreadsheet formats accepted for bulk-import uploads.
|
||||
/// </summary>
|
||||
public enum SpreadsheetFileExtension
|
||||
{
|
||||
Xls,
|
||||
Xlsx,
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
using API.BackgroundServices;
|
||||
using API.Utils.Converters;
|
||||
using API.Utils.Middlewares;
|
||||
|
||||
using Application.Backup;
|
||||
using Application.Interfaces.Backup;
|
||||
using Application.Interfaces.Mappers;
|
||||
using Application.Interfaces.Repositories;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Interfaces.Storage;
|
||||
using Application.Services;
|
||||
using Application.Utils.Constants;
|
||||
using Application.Utils.Helper.SupabaseHelper;
|
||||
using Application.Utils.Mappers;
|
||||
|
||||
using Domain.Enums;
|
||||
|
||||
using FluentEmail.MailKitSmtp;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
using Infrastructure.Email;
|
||||
using Infrastructure.Identity;
|
||||
using Infrastructure.Persistance;
|
||||
using Infrastructure.Repositories;
|
||||
using Infrastructure.Storage;
|
||||
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
|
||||
|
||||
using Serilog;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for configuring application startup, including logging, database context, CORS, authentication, authorization, Swagger, JSON options, and dependency injection.
|
||||
/// </summary>
|
||||
public static class StartupExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures Serilog logging for the application host.
|
||||
/// </summary>
|
||||
public static void AddSerilogConfig(this IHostBuilder hostBuilder, IConfiguration configuration)
|
||||
{
|
||||
hostBuilder.UseSerilog((context, config) => config.ReadFrom.Configuration(configuration));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds and configures the application's database context and dependency injection for the database.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDbContextConfig(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
string? connectionString = configuration.GetConnectionString(ConfigurationKeys.DbConnection);
|
||||
if (connectionString is null)
|
||||
{
|
||||
Log.Fatal("Connection string is missing. Using default or fallback connection string.");
|
||||
throw new ArgumentException(ErrorMessages.Configuration.ConnectionStringMissing);
|
||||
}
|
||||
services.AddDbContext<ApplicationDBContext>(options => options.UseNpgsql(connectionString, ConfigureNpgsql));
|
||||
services.AddScoped<IClub12DBContext, ApplicationDBContext>();
|
||||
services.AddScoped<MedicalRecordSeedBackfiller>();
|
||||
services.AddScoped<DataSeeder>();
|
||||
services.AddScoped<IDataMaintenanceService, DataMaintenanceService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared Npgsql options for every DbContext so both stay in sync, with a bounded command timeout that turns a pathological query into a fast failure instead of a hung request.
|
||||
/// </summary>
|
||||
private static void ConfigureNpgsql(NpgsqlDbContextOptionsBuilder npgsql)
|
||||
{
|
||||
npgsql.CommandTimeout(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds and configures CORS policies for the application.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCorsConfig(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.WithOrigins(configuration.GetSection(ConfigurationKeys.AllowedOrigins).Get<string[]>()!)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
});
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the self health check tagged live, which always reports Healthy, and the db health check tagged ready, which probes ApplicationDBContext connectivity so a misconfigured or unreachable connection string surfaces as not-ready instead of silently healthy.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddHealthChecksConfig(this IServiceCollection services)
|
||||
{
|
||||
services.AddHealthChecks()
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
|
||||
.AddDbContextCheck<ApplicationDBContext>("db", tags: ["ready"]);
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps /health as the liveness endpoint using only the live-tagged self check, and /health/ready as the readiness endpoint using only the ready-tagged database check; both are anonymous since no fallback authorization policy exists.
|
||||
/// </summary>
|
||||
public static WebApplication MapHealthCheckEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("live"),
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("ready"),
|
||||
}).AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs EF migrations for both ApplicationDBContext and IdentityAppDbContext, then seeds the initial admin user.
|
||||
/// </summary>
|
||||
public static async Task ExecuteMigrationsAndSeedAsync(this WebApplication app)
|
||||
{
|
||||
await using AsyncServiceScope scope = app.Services.CreateAsyncScope();
|
||||
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
// The migration only adds the schema, so the data backfill runs here as an idempotent step to give every team a stable cross-season club identity regardless of which migration or seed path created it; a re-run is a cheap no-op once every team is already linked.
|
||||
IClubService clubService = scope.ServiceProvider.GetRequiredService<IClubService>();
|
||||
await clubService.BackfillClubsAsync();
|
||||
|
||||
IdentityAppDbContext identityDb = scope.ServiceProvider.GetRequiredService<IdentityAppDbContext>();
|
||||
await identityDb.Database.MigrateAsync();
|
||||
|
||||
IdentitySeeder seeder = scope.ServiceProvider.GetRequiredService<IdentitySeeder>();
|
||||
await seeder.SeedAsync();
|
||||
|
||||
IConfiguration configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
if (configuration.GetValue<bool>(ConfigurationKeys.Seed.Enabled))
|
||||
{
|
||||
bool reset = configuration.GetValue<bool>(ConfigurationKeys.Seed.Reset);
|
||||
string? logosPath = configuration[ConfigurationKeys.Seed.LogosPath];
|
||||
string? medicalRecordPath = configuration[ConfigurationKeys.Seed.MedicalRecordPath];
|
||||
int seasons = configuration.GetValue(ConfigurationKeys.Seed.Seasons, 1);
|
||||
int playersPerTeam = configuration.GetValue(
|
||||
ConfigurationKeys.Seed.PlayersPerTeam, SampleTournamentBuilder.DefaultPlayersPerTeam);
|
||||
DataSeeder dataSeeder = scope.ServiceProvider.GetRequiredService<DataSeeder>();
|
||||
await dataSeeder.SeedAsync(reset, logosPath, medicalRecordPath, seasons, playersPerTeam);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs the startup banner once the app is fully configured and about to start listening for requests.
|
||||
/// </summary>
|
||||
public static void LogStartupBanner(this WebApplication app)
|
||||
{
|
||||
Log.Information(LogMessages.StartingUp);
|
||||
Log.Information(LogMessages.Banner);
|
||||
Log.Information(LogMessages.Started);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures Swagger middleware for API documentation in non-production environments.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseSwaggerConfig(this IApplicationBuilder app, IHostEnvironment env)
|
||||
{
|
||||
if (!env.IsProduction())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures exception handling and status code pages middleware.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseExceptionHandlerConfig(this IApplicationBuilder app)
|
||||
{
|
||||
app.UseStatusCodePages();
|
||||
app.UseExceptionHandler();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One policy per domain role — single source of truth via UserRoleType enum.
|
||||
/// </summary>
|
||||
private static readonly string[] _roleNames =
|
||||
[
|
||||
UserRoleType.ADMIN.ToRoleName(),
|
||||
UserRoleType.OWNER.ToRoleName(),
|
||||
UserRoleType.GUEST.ToRoleName(),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Adds custom authorization policies based on UserRoleType domain roles.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCustomAuthorization(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
foreach (string role in _roleNames)
|
||||
{
|
||||
options.AddPolicy(role, policy => policy.RequireRole(role));
|
||||
}
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds and configures JWT authentication for the application.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCustomAuthentication(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
string? jwtSecret = configuration.GetSection(ConfigurationKeys.Jwt.Key)?.Value;
|
||||
if (string.IsNullOrEmpty(jwtSecret))
|
||||
{
|
||||
throw new ArgumentException(ErrorMessages.Configuration.JwtMissing);
|
||||
}
|
||||
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
}).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = configuration[ConfigurationKeys.Jwt.Issuer],
|
||||
ValidAudience = configuration[ConfigurationKeys.Jwt.Audience],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret))
|
||||
};
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds middleware for logging requests to the request context if enabled in configuration.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseLoggingToRequestContextMiddleware(this IApplicationBuilder app, IConfiguration configuration)
|
||||
{
|
||||
bool useLoggingMiddleware = configuration.GetValue(ConfigurationKeys.UseLoggingMiddleware, false);
|
||||
if (useLoggingMiddleware)
|
||||
{
|
||||
app.UseMiddleware<RequestLoggingMiddleware>();
|
||||
Log.Information("Request logging middleware enabled.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Information("Request logging middleware disabled.");
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds custom JSON serialization options for MVC, including enum and date converters.
|
||||
/// </summary>
|
||||
public static void AddCustomJsonOptions(this IMvcBuilder builder)
|
||||
{
|
||||
builder.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.JsonSerializerOptions.Converters.Add(new DateOnlyJsonConverter());
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds and configures Swagger for API documentation, including security definitions and schema filters.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCustomSwagger(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
const string bearerScheme = "Bearer";
|
||||
const string swaggerDocVersion = "v1";
|
||||
|
||||
services.AddSwaggerGen(context =>
|
||||
{
|
||||
context.SwaggerDoc(swaggerDocVersion, new OpenApiInfo
|
||||
{
|
||||
Title = configuration[ConfigurationKeys.Swagger.Title],
|
||||
Version = configuration[ConfigurationKeys.Swagger.Version],
|
||||
});
|
||||
|
||||
// Only API's own xml file was ever wired in here, so DTO/enum summaries defined outside the API project never reached Swagger's schema view even though the compiled xml exists for each project.
|
||||
string[] xmlDocAssemblyNames = ["API", "Application", "Domain", "Infrastructure"];
|
||||
foreach (string assemblyName in xmlDocAssemblyNames)
|
||||
{
|
||||
string xmlPath = Path.Combine(AppContext.BaseDirectory, $"{assemblyName}.xml");
|
||||
if (File.Exists(xmlPath))
|
||||
{
|
||||
context.IncludeXmlComments(xmlPath);
|
||||
}
|
||||
}
|
||||
|
||||
context.ResolveConflictingActions(apiDescriptions => apiDescriptions.First());
|
||||
|
||||
context.AddSecurityDefinition(bearerScheme, new OpenApiSecurityScheme
|
||||
{
|
||||
Description = "JWT Authorization header using the Bearer scheme. Enter 'Bearer' [space] and then your token in the text input below.",
|
||||
Name = "Authorization",
|
||||
In = ParameterLocation.Header,
|
||||
Type = SecuritySchemeType.ApiKey,
|
||||
Scheme = bearerScheme
|
||||
});
|
||||
|
||||
context.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = bearerScheme },
|
||||
Scheme = "oauth2",
|
||||
Name = bearerScheme,
|
||||
In = ParameterLocation.Header
|
||||
},
|
||||
new List<string>()
|
||||
}
|
||||
});
|
||||
|
||||
context.SchemaFilter<DisplayEnumSchemaFilter>();
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers singleton services for dependency injection.
|
||||
/// </summary>
|
||||
public static IServiceCollection RegisterSingletons(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<SupabaseHelper>();
|
||||
|
||||
// Medical-record file storage reuses the shared Supabase client via ISupabaseRawStorage and confines files to their own medical-records area, separate from backups, registered here unconditionally because medical-record uploads always target Supabase.
|
||||
services.AddSingleton<ISupabaseRawStorage>(sp => sp.GetRequiredService<SupabaseHelper>());
|
||||
services.AddSingleton<IMedicalRecordStorage, SupabaseMedicalRecordStorage>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Identity DbContext, ASP.NET Core Identity services, IAuthenticationService, IUserManagementService, and IdentitySeeder.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddIdentityConfig(
|
||||
this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
string? connectionString = configuration.GetConnectionString(ConfigurationKeys.DbConnection);
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new ArgumentException(ErrorMessages.Configuration.ConnectionStringMissing);
|
||||
}
|
||||
|
||||
services.AddDbContext<IdentityAppDbContext>(options => options.UseNpgsql(connectionString, ConfigureNpgsql));
|
||||
|
||||
services.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
options.User.RequireUniqueEmail = true;
|
||||
})
|
||||
.AddRoles<IdentityRole<Guid>>()
|
||||
.AddEntityFrameworkStores<IdentityAppDbContext>()
|
||||
.AddSignInManager()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
services.AddScoped<IAuthenticationService, IdentityAuthenticationService>();
|
||||
services.AddScoped<IUserManagementService, IdentityUserManagementService>();
|
||||
services.AddScoped<IdentitySeeder>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers scoped services and repositories for dependency injection using reflection.
|
||||
/// </summary>
|
||||
public static IServiceCollection RegisterScoped(this IServiceCollection services)
|
||||
{
|
||||
string? serviceInterfaceNamespace = typeof(IDivisionService).Namespace;
|
||||
string? serviceImplNamespace = typeof(DivisionService).Namespace;
|
||||
string? repositoryInterfaceNamespace = typeof(IBlogPostRepository).Namespace;
|
||||
string? repositoryImplNamespace = typeof(BlogPostRepository).Namespace;
|
||||
string? mapperInterfaceNamespace = typeof(IScorerMapper).Namespace;
|
||||
string? mapperImplNamespace = typeof(ScorerMapper).Namespace;
|
||||
string serviceSuffix = "Service";
|
||||
string repositorySuffix = "Repository";
|
||||
string mapperSuffix = "Mapper";
|
||||
|
||||
ArgumentNullException.ThrowIfNull(serviceInterfaceNamespace);
|
||||
ArgumentNullException.ThrowIfNull(serviceImplNamespace);
|
||||
ArgumentNullException.ThrowIfNull(repositoryInterfaceNamespace);
|
||||
ArgumentNullException.ThrowIfNull(repositoryImplNamespace);
|
||||
ArgumentNullException.ThrowIfNull(mapperInterfaceNamespace);
|
||||
ArgumentNullException.ThrowIfNull(mapperImplNamespace);
|
||||
|
||||
Assembly serviceAssembly = typeof(AuthService).Assembly;
|
||||
Assembly IServiceAssembly = typeof(IAuthService).Assembly;
|
||||
Assembly repoAssembly = typeof(BlogPostRepository).Assembly;
|
||||
Assembly IRepoAssembly = typeof(IBlogPostRepository).Assembly;
|
||||
Assembly mapperAssembly = typeof(ScorerMapper).Assembly;
|
||||
Assembly IMapperAssembly = typeof(IScorerMapper).Assembly;
|
||||
|
||||
services.HelperRegisterScoped(serviceAssembly, IServiceAssembly, serviceSuffix, serviceInterfaceNamespace, serviceImplNamespace);
|
||||
services.HelperRegisterScoped(repoAssembly, IRepoAssembly, repositorySuffix, repositoryInterfaceNamespace, repositoryImplNamespace);
|
||||
services.HelperRegisterScoped(mapperAssembly, IMapperAssembly, mapperSuffix, mapperInterfaceNamespace, mapperImplNamespace);
|
||||
|
||||
|
||||
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
|
||||
// Resolves the current caller's identity from the HTTP context so application and infrastructure services can record who without depending on the web layer.
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<ICurrentUserAccessor, API.Utils.Helpers.HttpCurrentUserAccessor>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void HelperRegisterScoped(this IServiceCollection services, Assembly implementationAssembly, Assembly interfaceAssembly, string suffix, string iNamespace, string implNamespace)
|
||||
{
|
||||
string interfacePrefix = "I";
|
||||
|
||||
IEnumerable<Type> interfaces = interfaceAssembly.GetTypes()
|
||||
.Where(t => t.IsInterface && t.Namespace == iNamespace
|
||||
&& t.Name.StartsWith(interfacePrefix) && t.Name.EndsWith(suffix));
|
||||
|
||||
foreach (Type iface in interfaces)
|
||||
{
|
||||
Type? implementation = Array.Find(implementationAssembly.GetTypes(),
|
||||
t => t.IsClass && !t.IsAbstract
|
||||
&& t.Namespace == implNamespace
|
||||
&& t.Name == iface.Name[1..]);
|
||||
|
||||
if (implementation is not null)
|
||||
{
|
||||
services.AddScoped(iface, implementation);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Registers FluentEmail with Mailgun and binds IEmailService to FluentEmailHelper.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddEmailConfig(
|
||||
this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
string smtpHost = configuration[ConfigurationKeys.Smtp.Host]
|
||||
?? throw new ArgumentException(ErrorMessages.Configuration.SmtpKeyMissing(ConfigurationKeys.Smtp.Host));
|
||||
int smtpPort = int.Parse(configuration[ConfigurationKeys.Smtp.Port]
|
||||
?? throw new ArgumentException(ErrorMessages.Configuration.SmtpKeyMissing(ConfigurationKeys.Smtp.Port)));
|
||||
string username = configuration[ConfigurationKeys.Smtp.Username]
|
||||
?? throw new ArgumentException(ErrorMessages.Configuration.SmtpKeyMissing(ConfigurationKeys.Smtp.Username));
|
||||
string password = configuration[ConfigurationKeys.Smtp.Password]
|
||||
?? throw new ArgumentException(ErrorMessages.Configuration.SmtpKeyMissing(ConfigurationKeys.Smtp.Password));
|
||||
bool useSsl = configuration.GetValue(ConfigurationKeys.Smtp.UseSsl, true);
|
||||
string fromEmail = configuration[ConfigurationKeys.Smtp.FromEmail] ?? "noreply@club12.com";
|
||||
string fromName = configuration[ConfigurationKeys.Smtp.FromName] ?? "Club12";
|
||||
|
||||
services
|
||||
.AddFluentEmail(fromEmail, fromName)
|
||||
.AddMailKitSender(new SmtpClientOptions
|
||||
{
|
||||
Server = smtpHost,
|
||||
Port = smtpPort,
|
||||
UseSsl = useSsl,
|
||||
User = username,
|
||||
Password = password,
|
||||
RequiresAuthentication = true
|
||||
});
|
||||
|
||||
services.AddScoped<IEmailService, FluentEmailHelper>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the database backup feature by binding the Backup config section into a BackupOptions singleton and registering its ports and adapters.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddBackupConfig(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
BackupOptions options = configuration.GetSection(ConfigurationKeys.Backup.Section).Get<BackupOptions>() ?? new BackupOptions();
|
||||
services.AddSingleton(options);
|
||||
|
||||
services.AddSingleton<IBackupRetentionPolicy, KeepLastNRetentionPolicy>();
|
||||
services.AddSingleton<IProcessRunner, ProcessRunner>();
|
||||
services.AddSingleton<IDatabaseBackupService, PgDumpBackupService>();
|
||||
services.AddSingleton<IDatabaseRestoreService, PsqlDatabaseRestoreService>();
|
||||
services.AddSingleton<IMaintenanceModeState, MaintenanceModeState>();
|
||||
services.AddSingleton<BackupOperationLock>();
|
||||
|
||||
if (string.Equals(options.StorageTarget, BackupStorageTargets.Supabase, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
services.AddSingleton<ISupabaseRawStorage>(sp => sp.GetRequiredService<SupabaseHelper>());
|
||||
services.AddSingleton<IBackupStorage, SupabaseBackupStorage>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IBackupStorage>(sp => new LocalDirectoryBackupStorage(
|
||||
options.LocalStoragePath, sp.GetRequiredService<ILogger<LocalDirectoryBackupStorage>>()));
|
||||
}
|
||||
|
||||
services.AddScoped<IBackupCatalog, EfBackupCatalog>();
|
||||
services.AddScoped<IBackupOperationsService, BackupOperationsService>();
|
||||
|
||||
services.AddSingleton<DatabaseBackupHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user