diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a91ffed..68e56a7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,3 +22,5 @@ jobs: run: dotnet restore - name: Build run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build diff --git a/StudHub.Server.sln b/StudHub.Server.sln index ec49472..f1e5bae 100644 --- a/StudHub.Server.sln +++ b/StudHub.Server.sln @@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Platform.API", "src\Platfor EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Admin.API", "src\Admin.API\Admin.API.csproj", "{781F6E4E-FC3B-46E4-895A-F27A4310CF42}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unit", "test\Unit\Unit.csproj", "{3558A138-C00A-4857-93E5-3ECCB5CD873C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,5 +38,9 @@ Global {781F6E4E-FC3B-46E4-895A-F27A4310CF42}.Debug|Any CPU.Build.0 = Debug|Any CPU {781F6E4E-FC3B-46E4-895A-F27A4310CF42}.Release|Any CPU.ActiveCfg = Release|Any CPU {781F6E4E-FC3B-46E4-895A-F27A4310CF42}.Release|Any CPU.Build.0 = Release|Any CPU + {3558A138-C00A-4857-93E5-3ECCB5CD873C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3558A138-C00A-4857-93E5-3ECCB5CD873C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3558A138-C00A-4857-93E5-3ECCB5CD873C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3558A138-C00A-4857-93E5-3ECCB5CD873C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/Admin.API/Admin.API.csproj b/src/Admin.API/Admin.API.csproj index a16c169..baf4193 100644 --- a/src/Admin.API/Admin.API.csproj +++ b/src/Admin.API/Admin.API.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Admin.API/Controllers/ReportController.cs b/src/Admin.API/Controllers/ReportController.cs new file mode 100644 index 0000000..e96d329 --- /dev/null +++ b/src/Admin.API/Controllers/ReportController.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Application; +using Application.DTO; +using Application.Interfaces.Queries; +using Application.Interfaces.Services; +using Domain.Entities.Admin; + +namespace Admin.API.Controllers; + +[Authorize] +[ApiController] +[Route("reports")] +public class ReportController : ControllerBase +{ + private readonly IReportsService _reportsService; + private readonly IReportsQueryService _reportsQueryService; + + public ReportController(IReportsService reportsService, IReportsQueryService reportsQueryService) + { + _reportsService = reportsService; + _reportsQueryService = reportsQueryService; + } + + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Create(ReportDTO reportDTO) + { + await _reportsService.CreateReport(reportDTO); + return new StatusCodeResult(StatusCodes.Status201Created); + } + + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetAll() => Ok(await _reportsQueryService.GetAllAsync()); + + [HttpGet("{id:int}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int id) => Ok(await _reportsQueryService.GetByIdAsync(id)); + + [HttpGet("getByTargetEntityId")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GetByTargetEntityId(ReportTargetEntities entity) + => Ok(await _reportsQueryService.GetByTargetEntityIdAsync(entity)); +} \ No newline at end of file diff --git a/src/Admin.API/ExceptionHandlers/AuthorizationExceptionHandler.cs b/src/Admin.API/ExceptionHandlers/ApplicationAuthorizationExceptionHandler.cs similarity index 62% rename from src/Admin.API/ExceptionHandlers/AuthorizationExceptionHandler.cs rename to src/Admin.API/ExceptionHandlers/ApplicationAuthorizationExceptionHandler.cs index 9d294e2..9c1c74e 100644 --- a/src/Admin.API/ExceptionHandlers/AuthorizationExceptionHandler.cs +++ b/src/Admin.API/ExceptionHandlers/ApplicationAuthorizationExceptionHandler.cs @@ -1,16 +1,25 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; +using Application.Exceptions; +using ILogger = Serilog.ILogger; namespace Admin.API.ExceptionHandlers; -public class AuthorizationExceptionHandler : IExceptionHandler +public class ApplicationAuthorizationExceptionHandler : IExceptionHandler { private const string ResponseTitle = "Доступ запрещён"; private const int StatusCode = StatusCodes.Status403Forbidden; + private readonly ILogger _logger; + + public ApplicationAuthorizationExceptionHandler(ILogger logger) + { + _logger = logger; + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { - if (exception is not UnauthorizedAccessException) + if (exception is not ApplicationAuthorizationException) { return false; } @@ -23,6 +32,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = exception.Message }; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + _logger.Warning(exception, "Исключение обработано: {ResponseTitle}", ResponseTitle); return true; } } \ No newline at end of file diff --git a/src/Admin.API/ExceptionHandlers/AuthenticationExceptionHandler.cs b/src/Admin.API/ExceptionHandlers/AuthenticationExceptionHandler.cs index 24b67fa..55069a5 100644 --- a/src/Admin.API/ExceptionHandlers/AuthenticationExceptionHandler.cs +++ b/src/Admin.API/ExceptionHandlers/AuthenticationExceptionHandler.cs @@ -1,6 +1,7 @@ using System.Security.Authentication; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; +using ILogger = Serilog.ILogger; namespace Admin.API.ExceptionHandlers; @@ -8,6 +9,13 @@ public class AuthenticationExceptionHandler : IExceptionHandler { private const string ResponseTitle = "При аутентификации произошла ошибка"; private const int StatusCode = StatusCodes.Status401Unauthorized; + private readonly ILogger _logger; + + public AuthenticationExceptionHandler(ILogger logger) + { + _logger = logger; + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { @@ -24,6 +32,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = exception.Message }; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + _logger.Warning(exception, "Исключение обработано: {ResponseTitle}", ResponseTitle); return true; } } \ No newline at end of file diff --git a/src/Admin.API/ExceptionHandlers/BadRequestExceptionHandler.cs b/src/Admin.API/ExceptionHandlers/BadRequestExceptionHandler.cs index d850610..6417cfa 100644 --- a/src/Admin.API/ExceptionHandlers/BadRequestExceptionHandler.cs +++ b/src/Admin.API/ExceptionHandlers/BadRequestExceptionHandler.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; +using ILogger = Serilog.ILogger; namespace Admin.API.ExceptionHandlers; @@ -7,6 +8,13 @@ public class BadRequestExceptionHandler : IExceptionHandler { private const string ResponseTitle = "Некорректный запрос"; private const int StatusCode = StatusCodes.Status400BadRequest; + private readonly ILogger _logger; + + public BadRequestExceptionHandler(ILogger logger) + { + _logger = logger; + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { @@ -23,6 +31,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = exception.Message }; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + _logger.Warning(exception, "Исключение обработано: {ResponseTitle}", ResponseTitle); return true; } } \ No newline at end of file diff --git a/src/Admin.API/ExceptionHandlers/GlobalExceptionHandler.cs b/src/Admin.API/ExceptionHandlers/GlobalExceptionHandler.cs index 2cba8d8..eb4df35 100644 --- a/src/Admin.API/ExceptionHandlers/GlobalExceptionHandler.cs +++ b/src/Admin.API/ExceptionHandlers/GlobalExceptionHandler.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; +using ILogger = Serilog.ILogger; namespace Admin.API.ExceptionHandlers; @@ -7,6 +8,13 @@ public class GlobalExceptionHandler : IExceptionHandler { private const string ResponseTitle = "Произошла ошибка на стороне сервера"; private const int StatusCode = StatusCodes.Status500InternalServerError; + private readonly ILogger _logger; + + public GlobalExceptionHandler(ILogger logger) + { + _logger = logger; + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { @@ -19,6 +27,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = exception.Message }; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + _logger.Error(exception, ResponseTitle); return true; } } \ No newline at end of file diff --git a/src/Admin.API/ExceptionHandlers/InvalidOperationExceptionHandler.cs b/src/Admin.API/ExceptionHandlers/InvalidOperationExceptionHandler.cs index cfa7cb1..bbab133 100644 --- a/src/Admin.API/ExceptionHandlers/InvalidOperationExceptionHandler.cs +++ b/src/Admin.API/ExceptionHandlers/InvalidOperationExceptionHandler.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; +using ILogger = Serilog.ILogger; namespace Admin.API.ExceptionHandlers; @@ -8,6 +9,13 @@ public class InvalidOperationExceptionHandler : IExceptionHandler { private const string ResponseTitle = "Недопустимая операция"; private const int StatusCode = StatusCodes.Status409Conflict; + private readonly ILogger _logger; + + public InvalidOperationExceptionHandler(ILogger logger) + { + _logger = logger; + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { @@ -24,6 +32,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = exception.Message }; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + _logger.Warning(exception, "Исключение обработано: {ResponseTitle}", ResponseTitle); return true; } } \ No newline at end of file diff --git a/src/Admin.API/ExceptionHandlers/NotFoundExceptionHandler.cs b/src/Admin.API/ExceptionHandlers/NotFoundExceptionHandler.cs index 5a95095..ab2ee88 100644 --- a/src/Admin.API/ExceptionHandlers/NotFoundExceptionHandler.cs +++ b/src/Admin.API/ExceptionHandlers/NotFoundExceptionHandler.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; using Application.Exceptions; +using ILogger = Serilog.ILogger; namespace Admin.API.ExceptionHandlers; @@ -8,6 +9,13 @@ public class NotFoundExceptionHandler : IExceptionHandler { private const string ResponseTitle = "Запрашиваемый ресурс отсутствует на сервере"; private const int StatusCode = StatusCodes.Status404NotFound; + private readonly ILogger _logger; + + public NotFoundExceptionHandler(ILogger logger) + { + _logger = logger; + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { @@ -24,6 +32,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = exception.Message }; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + _logger.Warning(exception, "Исключение обработано: {ResponseTitle}", ResponseTitle); return true; } } \ No newline at end of file diff --git a/src/Admin.API/Extensions/ServiceCollectionExtensions.cs b/src/Admin.API/Extensions/ServiceCollectionExtensions.cs index b14f38a..ca65408 100644 --- a/src/Admin.API/Extensions/ServiceCollectionExtensions.cs +++ b/src/Admin.API/Extensions/ServiceCollectionExtensions.cs @@ -10,7 +10,7 @@ public static IServiceCollection AddExceptionHandlers(this IServiceCollection se services.AddExceptionHandler(); services.AddExceptionHandler(); services.AddExceptionHandler(); - services.AddExceptionHandler(); + services.AddExceptionHandler(); services.AddExceptionHandler(); return services; } diff --git a/src/Admin.API/Program.cs b/src/Admin.API/Program.cs index 8ee4202..b2ca618 100644 --- a/src/Admin.API/Program.cs +++ b/src/Admin.API/Program.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using Scalar.AspNetCore; +using Serilog; +using Serilog.Filters; using Application.Interfaces.Services; using Infrastructure.Extensions; using Admin.API.Extensions; @@ -12,8 +14,18 @@ public class Program public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); + builder.Host.UseSerilog((context, configuration) => + { + configuration.ReadFrom.Configuration(context.Configuration); + // Временное решение, дублирование логов при использовании ExceptionHandlerMiddleware исправлено в .NET 10 + configuration.Filter.ByExcluding(Matching + .FromSource()); + }); builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddOpenApiDocument(); + builder.Services.AddOpenApiDocument(options => + { + options.Title = builder.Environment.IsDevelopment() ? "StudHub Admin API [DEV]" : "StudHub Admin API"; + }); builder.Configuration.AddEnvironmentVariables(); builder.Services.AddStudHubServices(builder.Configuration.GetConnectionString("Default")!); builder.Services.AddAdminServices(); @@ -67,6 +79,7 @@ public static void Main(string[] args) }; }); var app = builder.Build(); + app.UseSerilogRequestLogging(); app.UseCors(); app.UseExceptionHandler(); app.UseOpenApi(options => @@ -75,10 +88,12 @@ public static void Main(string[] args) }); app.MapScalarApiReference(options => { - options.Title = "StudHub.Admin API"; + options.Title = app.Environment.IsDevelopment() + ? "StudHub Admin API [DEV]" + : "StudHub Admin API"; options.Servers = new List { - new ScalarServer("https://api.admin.setka-rtu.ru") + new ScalarServer("https://api-admin.setka-rtu.ru") }; if (app.Environment.IsDevelopment()) { @@ -90,4 +105,4 @@ public static void Main(string[] args) app.Run(); } -} \ No newline at end of file +} diff --git a/src/Admin.API/appsettings.Development.json b/src/Admin.API/appsettings.Development.json index 0c208ae..8fc50b9 100644 --- a/src/Admin.API/appsettings.Development.json +++ b/src/Admin.API/appsettings.Development.json @@ -1,8 +1,22 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft.EntityFrameworkCore": "Information", + "Microsoft.AspNetCore": "Information" + } + }, + "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ], + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "File", + "Args": { + "path": "/Logs/Admin.API/Development/DevLog-.txt", + "rollingInterval": "Day" + } + } + ] } } diff --git a/src/Admin.API/appsettings.json b/src/Admin.API/appsettings.json index 7b0e17a..1d121cd 100644 --- a/src/Admin.API/appsettings.json +++ b/src/Admin.API/appsettings.json @@ -1,14 +1,24 @@ { - "Logging": { - "LogLevel": { + "Serilog": { + "MinimumLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } + "Override": { + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ], + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "File", + "Args": { + "path": "/Logs/Admin.API/Production/Log-.txt", + "rollingInterval": "Day" + } + } + ] }, "Domain": "setka-rtu.ru", - "AllowedHosts": "*", - "JWTKey": "9>I4=o8{AG\\Nh{`I7m*={tP:Gs)dsXxNw.Hj92]dQSO", - "ConnectionStrings": { - "Default": "Host=31.128.38.159;Port=5432;User ID=admin-setka;Password=P1{Hg?:(~jLfDNrp!W9QkF?bO;Database=SetkaDB" - } + "AllowedHosts": "*" } diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index d1ca462..a262b2f 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -12,6 +12,9 @@ + + + diff --git a/src/Application/DTO/ClubCreationRequestDTO.cs b/src/Application/DTO/ClubCreationRequestDTO.cs index f94ba30..ab4c3e5 100644 --- a/src/Application/DTO/ClubCreationRequestDTO.cs +++ b/src/Application/DTO/ClubCreationRequestDTO.cs @@ -27,8 +27,8 @@ public record ClubCreationRequestDetailsDTO public string ClubName { get; init; } public string? ClubAbout { get; init; } [Required] - public int ImageId { get; init; } - public int? BannerId { get; init; } + public string ImagePath { get; init; } + public string? BannerPath { get; init; } [Required] public bool IsActive { get; init; } [Required] diff --git a/src/Application/DTO/PostDTO.cs b/src/Application/DTO/PostDTO.cs index de0bf82..74f7392 100644 --- a/src/Application/DTO/PostDTO.cs +++ b/src/Application/DTO/PostDTO.cs @@ -12,7 +12,9 @@ public record PostDTO [MaxLength(500)] public required string Content { get; init; } [Required] - public int? ClubId { get; set; } + public int? ClubId { get; init; } + [MaxLength(5)] + public List PostImagesIds { get; init; } = new List(); } public record PostDetailDTO diff --git a/src/Application/DTO/ReportDTO.cs b/src/Application/DTO/ReportDTO.cs new file mode 100644 index 0000000..65e3fdb --- /dev/null +++ b/src/Application/DTO/ReportDTO.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Application.DTO; + +public record ReportDTO +{ + [Required] + public int? TargetId { get; init; } + [Required] + public int? TargetEntityId { get; init; } + [Required] + public int? CategoryId { get; init; } + public string? Description { get; init; } +} \ No newline at end of file diff --git a/src/Application/Enums/ReportTargetEntities.cs b/src/Application/Enums/ReportTargetEntities.cs new file mode 100644 index 0000000..082197a --- /dev/null +++ b/src/Application/Enums/ReportTargetEntities.cs @@ -0,0 +1,8 @@ +namespace Application; + +public enum ReportTargetEntities +{ + Person = 1, + Club = 2, + Post = 3 +} \ No newline at end of file diff --git a/src/Application/Exceptions/ApplicationAuthorizationException.cs b/src/Application/Exceptions/ApplicationAuthorizationException.cs new file mode 100644 index 0000000..862c995 --- /dev/null +++ b/src/Application/Exceptions/ApplicationAuthorizationException.cs @@ -0,0 +1,3 @@ +namespace Application.Exceptions; + +public class ApplicationAuthorizationException(string? message) : UnauthorizedAccessException(message); \ No newline at end of file diff --git a/src/Application/Interfaces/Queries/IReportsQueryService.cs b/src/Application/Interfaces/Queries/IReportsQueryService.cs new file mode 100644 index 0000000..4b416e2 --- /dev/null +++ b/src/Application/Interfaces/Queries/IReportsQueryService.cs @@ -0,0 +1,10 @@ +using Domain.Entities.Admin; + +namespace Application.Interfaces.Queries; + +public interface IReportsQueryService +{ + public Task> GetAllAsync(); + public Task GetByIdAsync(int id); + public Task> GetByTargetEntityIdAsync(ReportTargetEntities entity); +} \ No newline at end of file diff --git a/src/Application/Interfaces/Repositories/Admin/IReportCategoriesRepository.cs b/src/Application/Interfaces/Repositories/Admin/IReportCategoriesRepository.cs new file mode 100644 index 0000000..ecc9d66 --- /dev/null +++ b/src/Application/Interfaces/Repositories/Admin/IReportCategoriesRepository.cs @@ -0,0 +1,9 @@ +using Domain.Entities.Admin; + +namespace Application.Interfaces.Repositories.Admin; + +public interface IReportCategoriesRepository : IRepository +{ + public Task GetByIdAsync(int id); + public Task CreateAsync(ReportCategory reportCategory); +} \ No newline at end of file diff --git a/src/Application/Interfaces/Repositories/Admin/IReportEntitiesRepository.cs b/src/Application/Interfaces/Repositories/Admin/IReportEntitiesRepository.cs new file mode 100644 index 0000000..70bbc5d --- /dev/null +++ b/src/Application/Interfaces/Repositories/Admin/IReportEntitiesRepository.cs @@ -0,0 +1,8 @@ +using Domain.Entities.Admin; + +namespace Application.Interfaces.Repositories.Admin; + +public interface IReportEntitiesRepository : IRepository +{ + public Task GetByIdAsync(int id); +} \ No newline at end of file diff --git a/src/Application/Interfaces/Repositories/Admin/IReportsRepository.cs b/src/Application/Interfaces/Repositories/Admin/IReportsRepository.cs new file mode 100644 index 0000000..16e3940 --- /dev/null +++ b/src/Application/Interfaces/Repositories/Admin/IReportsRepository.cs @@ -0,0 +1,10 @@ +using Domain.Entities.Admin; + +namespace Application.Interfaces.Repositories.Admin; + +public interface IReportsRepository : IRepository +{ + public Task GetByIdAsync(int id); + public Task CreateAsync(Report report); + public Task UpdateAsync(Report report); +} \ No newline at end of file diff --git a/src/Application/Interfaces/Services/IReportsService.cs b/src/Application/Interfaces/Services/IReportsService.cs new file mode 100644 index 0000000..bc86fa2 --- /dev/null +++ b/src/Application/Interfaces/Services/IReportsService.cs @@ -0,0 +1,8 @@ +using Application.DTO; + +namespace Application.Interfaces.Services; + +public interface IReportsService +{ + public Task CreateReport(ReportDTO report); +} \ No newline at end of file diff --git a/src/Application/Services/ClubAdminsService.cs b/src/Application/Services/ClubAdminsService.cs index a6a6a19..2b19cfb 100644 --- a/src/Application/Services/ClubAdminsService.cs +++ b/src/Application/Services/ClubAdminsService.cs @@ -1,4 +1,5 @@ -using Domain.Entities.Club; +using Serilog; +using Domain.Entities.Club; using Domain.Entities.Person; using Application.Exceptions; using Application.Interfaces.Repositories.Club; @@ -13,16 +14,18 @@ public class ClubAdminsService : IClubAdminsService private readonly IPersonsRepository _personsRepository; private readonly IClubsRepository _clubsRepository; private readonly ISessionService _sessionService; + private readonly ILogger _logger; public ClubAdminsService(IClubAdminsRepository clubAdminsRepository, IClubsRepository clubsRepository, - ISessionService sessionService, IPersonsRepository personsRepository) + ISessionService sessionService, IPersonsRepository personsRepository, ILogger logger) { _clubAdminsRepository = clubAdminsRepository; _clubsRepository = clubsRepository; _sessionService = sessionService; _personsRepository = personsRepository; + _logger = logger; } - + public async Task GrantAdminRights(int personId, int clubId) { var club = await _clubsRepository.GetByIdAsync(clubId); @@ -32,11 +35,12 @@ public async Task GrantAdminRights(int personId, int clubId) if (person is null) throw new NotFoundException(nameof(PersonModel), personId); if (_sessionService.PersonId != club.OwnerId) - throw new UnauthorizedAccessException("Пользователь должен являться владельцем клуба, чтобы назначить администратора"); + throw new ApplicationAuthorizationException("Пользователь должен являться владельцем клуба, чтобы назначить администратора"); if (await _clubAdminsRepository.CheckIfContainsAsync(personId, clubId) || club.OwnerId == personId) throw new InvalidOperationException("Пользователь уже является администратором или владельцем данного клуба"); var clubAdmin = new ClubAdmin { PersonId = personId, ClubId = clubId }; await _clubAdminsRepository.CreateAsync(clubAdmin); + _logger.Information("Пользователь с Id ({PersonId}) назначен администратором клуба с Id ({ClubId})", personId, clubId); } public async Task RevokeAdminRights(int personId, int clubId) @@ -48,11 +52,12 @@ public async Task RevokeAdminRights(int personId, int clubId) if (person is null) throw new NotFoundException(nameof(PersonModel), personId); if (_sessionService.PersonId != club.OwnerId) - throw new UnauthorizedAccessException("Пользователь должен являться владельцем клуба, чтобы разжаловать администратора"); + throw new ApplicationAuthorizationException("Пользователь должен являться владельцем клуба, чтобы разжаловать администратора"); if (!await _clubAdminsRepository.CheckIfContainsAsync(personId, clubId)) throw new InvalidOperationException("Пользователь не является администратором данного клуба"); var id = (await _clubAdminsRepository.GetByClubIdAsync(clubId)).First(c => c.ClubId == clubId && c.PersonId == personId).Id; await _clubAdminsRepository.DeleteByIdAsync(id); + _logger.Information("Пользователь с Id ({PersonId}) лишён прав администратора клуба с Id ({ClubId})", personId, clubId); } } \ No newline at end of file diff --git a/src/Application/Services/ClubCreationRequestsService.cs b/src/Application/Services/ClubCreationRequestsService.cs index 4196238..b3f7913 100644 --- a/src/Application/Services/ClubCreationRequestsService.cs +++ b/src/Application/Services/ClubCreationRequestsService.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Serilog; using Domain.Entities.Admin; using Domain.Entities.Club; using Application.DTO; @@ -14,20 +15,23 @@ public class ClubCreationRequestsService : IClubCreationRequestsService private readonly IClubCreationRequestsRepository _clubCreationRequestsRepository; private readonly IClubsRepository _clubsRepository; private readonly IMapper _mapper; - + private readonly ILogger _logger; + public ClubCreationRequestsService(IClubCreationRequestsRepository clubCreationRequestsRepository, - IClubsRepository clubsRepository, IMapper mapper) + IClubsRepository clubsRepository, IMapper mapper, ILogger logger) { _clubCreationRequestsRepository = clubCreationRequestsRepository; _clubsRepository = clubsRepository; _mapper = mapper; + _logger = logger; } - + public async Task CreateAsync(ClubCreationRequestDTO clubCreationRequestDTO) { var clubCreationRequest = _mapper.Map(clubCreationRequestDTO); clubCreationRequest.IsActive = true; await _clubCreationRequestsRepository.CreateAsync(clubCreationRequest); + _logger.Information("Добавлена заявка на создание клуба с Id ({ClubCreationRequestId})", clubCreationRequest.Id); } public async Task AcceptRequest(int requestId) @@ -47,7 +51,9 @@ public async Task AcceptRequest(int requestId) await _clubsRepository.CreateAsync(club); clubCreationRequest.IsActive = false; await _clubCreationRequestsRepository.UpdateAsync(clubCreationRequest); + _logger.Information("Заявка на создание клуба с Id ({RequestId}) одобрена", requestId); } + public async Task DeclineRequest(int requestId) { var clubCreationRequest = await _clubCreationRequestsRepository.GetByIdAsync(requestId); @@ -56,5 +62,6 @@ public async Task DeclineRequest(int requestId) throw new InvalidOperationException("Заявка на создание клуба уже была рассмотрена"); clubCreationRequest.IsActive = false; await _clubCreationRequestsRepository.UpdateAsync(clubCreationRequest); + _logger.Information("Заявка на создание клуба с Id ({RequestId}) отклонена", requestId); } } \ No newline at end of file diff --git a/src/Application/Services/ClubsService.cs b/src/Application/Services/ClubsService.cs index 30a4643..36731a0 100644 --- a/src/Application/Services/ClubsService.cs +++ b/src/Application/Services/ClubsService.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Serilog; using Domain.Entities.Club; using Application.DTO; using Application.Exceptions; @@ -13,16 +14,18 @@ public class ClubsService : IClubsService private readonly IPersonsClubsRepository _personsClubsRepository; private readonly ISessionService _sessionService; private readonly IMapper _mapper; + private readonly ILogger _logger; public ClubsService(IClubsRepository clubsRepository, IPersonsClubsRepository personsClubsRepository, - ISessionService sessionService, IMapper mapper) + ISessionService sessionService, IMapper mapper, ILogger logger) { _clubsRepository = clubsRepository; _personsClubsRepository = personsClubsRepository; _sessionService = sessionService; _mapper = mapper; + _logger = logger; } - + public async Task ToggleSubscription(int id) { if (await _clubsRepository.GetByIdAsync(id) is null) throw new NotFoundException(nameof(ClubModel), id); @@ -46,6 +49,7 @@ public async Task CreateAsync(ClubDTO clubDTO) { var club = _mapper.Map(clubDTO); await _clubsRepository.CreateAsync(club); + _logger.Information("Создан клуб с Id ({ClubId}), принадлежащий пользователю с Id ({OwnerId})", club.Id, club.OwnerId); } public async Task UpdateAsync(ClubDTO clubDTO, int clubId) @@ -54,7 +58,12 @@ public async Task UpdateAsync(ClubDTO clubDTO, int clubId) if (club is null) throw new NotFoundException(nameof(ClubModel), clubId); club = _mapper.Map(clubDTO, club); await _clubsRepository.UpdateAsync(club); + _logger.Information("Обновлена информация о клубе с Id ({ClubId})", club.Id); } - public async Task DeleteByIdAsync(int id) => await _clubsRepository.DeleteByIdAsync(id); + public async Task DeleteByIdAsync(int id) + { + await _clubsRepository.DeleteByIdAsync(id); + _logger.Information("Удалён клуб с Id ({ClubId})", id); + } } \ No newline at end of file diff --git a/src/Application/Services/PersonsService.cs b/src/Application/Services/PersonsService.cs index 7af208f..be6f578 100644 --- a/src/Application/Services/PersonsService.cs +++ b/src/Application/Services/PersonsService.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Serilog; using Domain.Entities.Person; using Application.DTO; using Application.Exceptions; @@ -12,32 +13,39 @@ public class PersonsService : IPersonsService private readonly IPersonsRepository _personsRepository; private readonly ISessionService _sessionService; private readonly IMapper _mapper; + private readonly ILogger _logger; - public PersonsService(IPersonsRepository personsRepository, ISessionService sessionService, IMapper mapper) + public PersonsService(IPersonsRepository personsRepository, ISessionService sessionService, IMapper mapper, ILogger logger) { _personsRepository = personsRepository; _sessionService = sessionService; _mapper = mapper; + _logger = logger; } public async Task CreateAsync(PersonDTO personDTO) { var person = _mapper.Map(personDTO); await _personsRepository.CreateAsync(person); + _logger.Information("Создан пользователь с Id ({PersonId})", person.Id); } public async Task UpdateAsync(PersonDTO personDTO, int id) { - if (_sessionService.PersonId != id) throw new UnauthorizedAccessException(); + if (_sessionService.PersonId != id) + throw new ApplicationAuthorizationException("Только владелец записи пользователя может её изменить"); var person = await _personsRepository.GetByIdAsync(id); if (person is null) throw new NotFoundException(nameof(PersonModel), id); person = _mapper.Map(personDTO, person); await _personsRepository.UpdateAsync(person); + _logger.Information("Обновлена информация о пользователе с Id ({PersonId})", person.Id); } public async Task DeleteByIdAsync(int id) { - if (_sessionService.PersonId != id) throw new UnauthorizedAccessException(); + if (_sessionService.PersonId != id) + throw new ApplicationAuthorizationException("Только владелец записи пользователя может её удалить"); await _personsRepository.DeleteByIdAsync(id); + _logger.Information("Удалён пользователь с Id ({PersonId})", id); } } \ No newline at end of file diff --git a/src/Application/Services/PostsService.cs b/src/Application/Services/PostsService.cs index a44e519..843cfe2 100644 --- a/src/Application/Services/PostsService.cs +++ b/src/Application/Services/PostsService.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Serilog; using Domain.Entities.Post; using Application.DTO; using Application.Exceptions; @@ -12,21 +13,25 @@ namespace Application.Services; public class PostsService : IPostsService { private readonly IPostsRepository _postsRepository; + private readonly IPostsImagesRepository _postsImagesRepository; private readonly IClubsQueryService _clubsQueryService; private readonly ISessionService _sessionService; private readonly IClubAdminsRepository _clubAdminsRepository; private readonly IClubsRepository _clubsRepository; private readonly IMapper _mapper; + private readonly ILogger _logger; - public PostsService(IPostsRepository postsRepository, IClubsQueryService clubsQueryService, IMapper mapper, - IClubAdminsRepository clubAdminsRepository, IClubsRepository clubsRepository, ISessionService sessionService) + public PostsService(IPostsRepository postsRepository, IPostsImagesRepository postsImagesRepository, IClubsQueryService clubsQueryService, IMapper mapper, + IClubAdminsRepository clubAdminsRepository, IClubsRepository clubsRepository, ISessionService sessionService, ILogger logger) { _postsRepository = postsRepository; + _postsImagesRepository = postsImagesRepository; _clubsQueryService = clubsQueryService; _clubAdminsRepository = clubAdminsRepository; _clubsRepository = clubsRepository; _sessionService = sessionService; _mapper = mapper; + _logger = logger; } public async Task CreateAsync(PostDTO postDTO) @@ -35,9 +40,18 @@ public async Task CreateAsync(PostDTO postDTO) var ownerId = (await _clubsRepository.GetByIdAsync(postDTO.ClubId!.Value))!.OwnerId; if (ownerId != _sessionService.PersonId && !await _clubAdminsRepository.CheckIfContainsAsync(_sessionService.PersonId, postDTO.ClubId!.Value)) - throw new UnauthorizedAccessException("Создавать посты могут только администраторы и владелец клуба"); + throw new ApplicationAuthorizationException("Создавать посты могут только администраторы и владелец клуба"); var post = _mapper.Map(postDTO); await _postsRepository.CreateAsync(post); + foreach (var imageId in postDTO.PostImagesIds) + { + await _postsImagesRepository.CreateAsync(new PostsImages + { + PostId = post.Id, + ImageId = imageId + }); + } + _logger.Information("Создан пост с Id ({PostId}) пользователем с Id ({PersonId})", post.Id, _sessionService.PersonId); return post.Id; } @@ -48,9 +62,10 @@ public async Task UpdateAsync(PostDTO postDTO, int postId) var ownerId = (await _clubsRepository.GetByIdAsync(postDTO.ClubId!.Value))!.OwnerId; if (ownerId != _sessionService.PersonId && !await _clubAdminsRepository.CheckIfContainsAsync(_sessionService.PersonId, postDTO.ClubId!.Value)) - throw new UnauthorizedAccessException("Изменять посты могут только администраторы и владелец клуба"); + throw new ApplicationAuthorizationException("Изменять посты могут только администраторы и владелец клуба"); post = _mapper.Map(postDTO, post); await _postsRepository.UpdateAsync(post); + _logger.Information("Изменён пост с Id ({PostId}) пользователем с Id ({PersonId})", post.Id, _sessionService.PersonId); } public async Task DeleteByIdAsync(int postId) @@ -61,7 +76,8 @@ public async Task DeleteByIdAsync(int postId) var ownerId = (await _clubsRepository.GetByIdAsync(clubId))!.OwnerId; if (ownerId != _sessionService.PersonId && !await _clubAdminsRepository.CheckIfContainsAsync(_sessionService.PersonId, clubId)) - throw new UnauthorizedAccessException("Удалять посты могут только администраторы и владелец клуба"); + throw new ApplicationAuthorizationException("Удалять посты могут только администраторы и владелец клуба"); await _postsRepository.DeleteByIdAsync(postId); + _logger.Information("Удалён пост с Id ({PostId}) пользователем с Id ({PersonId})", post.Id, _sessionService.PersonId); } } \ No newline at end of file diff --git a/src/Application/Services/ReportsService.cs b/src/Application/Services/ReportsService.cs new file mode 100644 index 0000000..0b31490 --- /dev/null +++ b/src/Application/Services/ReportsService.cs @@ -0,0 +1,80 @@ +using Domain.Entities.Admin; +using Domain.Entities.Club; +using Domain.Entities.Person; +using Domain.Entities.Post; +using Application.DTO; +using Application.Exceptions; +using Application.Interfaces.Repositories.Admin; +using Application.Interfaces.Repositories.Club; +using Application.Interfaces.Repositories.Person; +using Application.Interfaces.Repositories.Post; +using Application.Interfaces.Services; + +namespace Application.Services; + +public class ReportsService : IReportsService +{ + private readonly IReportsRepository _reportsRepository; + private readonly IReportCategoriesRepository _reportCategoriesRepository; + private readonly IReportEntitiesRepository _reportEntitiesRepository; + private readonly IPersonsRepository _personsRepository; + private readonly IPostsRepository _postsRepository; + private readonly IClubsRepository _clubsRepository; + private readonly ISessionService _sessionService; + + public ReportsService(IReportsRepository reportsRepository, IReportCategoriesRepository reportCategoriesRepository, + IReportEntitiesRepository reportEntitiesRepository, IPersonsRepository personsRepository, + IPostsRepository postsRepository, IClubsRepository clubsRepository, ISessionService sessionService) + { + _reportsRepository = reportsRepository; + _reportCategoriesRepository = reportCategoriesRepository; + _reportEntitiesRepository = reportEntitiesRepository; + _personsRepository = personsRepository; + _postsRepository = postsRepository; + _clubsRepository = clubsRepository; + _sessionService = sessionService; + } + + public async Task CreateReport(ReportDTO reportDTO) + { + if (await _reportEntitiesRepository.GetByIdAsync(reportDTO.TargetEntityId!.Value) is null) + throw new NotFoundException(nameof(ReportEntity), reportDTO.TargetEntityId.Value); + + var reportCategory = await _reportCategoriesRepository.GetByIdAsync(reportDTO.CategoryId!.Value); + if (reportCategory is null) + throw new NotFoundException(nameof(ReportCategory), reportDTO.CategoryId.Value); + if (reportCategory.ReportEntityId != reportDTO.TargetEntityId.Value) + throw new ArgumentException("Некорректная категория"); + + var report = new Report + { + ReporterId = _sessionService.PersonId, + CategoryId = reportDTO.CategoryId!.Value, + Description = reportDTO.Description, + IsActive = true + }; + + switch (reportDTO.TargetEntityId.Value) + { + case (int) ReportTargetEntities.Person: + if (await _personsRepository.GetByIdAsync(reportDTO.TargetId!.Value) is null) + throw new NotFoundException(nameof(PersonModel), reportDTO.TargetId.Value); + report.TargetPersonId = reportDTO.TargetId; + break; + case (int) ReportTargetEntities.Club: + if (await _clubsRepository.GetByIdAsync(reportDTO.TargetId!.Value) is null) + throw new NotFoundException(nameof(ClubModel), reportDTO.TargetId.Value); + report.TargetClubId = reportDTO.TargetId; + break; + case (int) ReportTargetEntities.Post: + if (await _postsRepository.GetByIdAsync(reportDTO.TargetId!.Value) is null) + throw new NotFoundException(nameof(PostModel), reportDTO.TargetId.Value); + report.TargetPostId = reportDTO.TargetId; + break; + default: + throw new ArgumentException("Указана некорректная сущность при создании жалобы"); + } + + await _reportsRepository.CreateAsync(report); + } +} \ No newline at end of file diff --git a/src/Domain/Entities/Admin/Report.cs b/src/Domain/Entities/Admin/Report.cs new file mode 100644 index 0000000..a05c01d --- /dev/null +++ b/src/Domain/Entities/Admin/Report.cs @@ -0,0 +1,13 @@ +namespace Domain.Entities.Admin; + +public class Report +{ + public int Id { get; set; } + public int ReporterId { get; set; } + public int? TargetPersonId { get; set; } + public int? TargetClubId { get; set; } + public int? TargetPostId { get; set; } + public int CategoryId { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } +} \ No newline at end of file diff --git a/src/Domain/Entities/Admin/ReportCategory.cs b/src/Domain/Entities/Admin/ReportCategory.cs new file mode 100644 index 0000000..a4c69fd --- /dev/null +++ b/src/Domain/Entities/Admin/ReportCategory.cs @@ -0,0 +1,8 @@ +namespace Domain.Entities.Admin; + +public class ReportCategory +{ + public int Id { get; set; } + public int ReportEntityId { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/Domain/Entities/Admin/ReportEntity.cs b/src/Domain/Entities/Admin/ReportEntity.cs new file mode 100644 index 0000000..5ad11e3 --- /dev/null +++ b/src/Domain/Entities/Admin/ReportEntity.cs @@ -0,0 +1,7 @@ +namespace Domain.Entities.Admin; + +public class ReportEntity +{ + public int Id { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs index 886a632..87cf9a0 100644 --- a/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs +++ b/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using System.Reflection; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Serilog; using Application.Interfaces.Repositories; using Application.Interfaces.Services; using Application.Interfaces.Queries; @@ -14,8 +15,11 @@ namespace Infrastructure.Extensions; public static class ServiceCollectionExtensions { + private static readonly ILogger _logger = Log.Logger; + public static IServiceCollection AddStudHubServices(this IServiceCollection services, string connectionString) { + _logger.Debug("Начата регистрация сервисов платформы"); services.AddDbContext(dbContextOptions => dbContextOptions.UseNpgsql(connectionString)); services.AddIdentityServices(connectionString); @@ -38,18 +42,24 @@ public static IServiceCollection AddStudHubServices(this IServiceCollection serv services.AddTransient(); services.AddScoped(); services.AddStudHubRepositories(); + _logger.Debug("Завершена регистрация сервисов платформы"); return services; } public static IServiceCollection AddAdminServices(this IServiceCollection services) { + _logger.Debug("Начата регистрация сервисов панели администрирования"); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + _logger.Debug("Завершена регистрация сервисов панели администрирования"); return services; } public static IServiceCollection AddStudHubRepositories(this IServiceCollection services) { + _logger.Debug("Начата регистрация репозиториев"); foreach (var type in Assembly.GetExecutingAssembly().GetTypes() .Where(type => type is { IsClass: true, IsAbstract: false } && type.GetInterfaces().Contains(typeof(IRepository)))) @@ -62,8 +72,10 @@ public static IServiceCollection AddStudHubRepositories(this IServiceCollection else { services.AddTransient(type); + _logger.Warning("Для репозитория {ServiceType} не найден подходящий интерфейс", type); } } + _logger.Debug("Завершена регистрация репозиториев"); return services; } } \ No newline at end of file diff --git a/src/Infrastructure/Identity/Services/AuthService.cs b/src/Infrastructure/Identity/Services/AuthService.cs index dd97014..da73895 100644 --- a/src/Infrastructure/Identity/Services/AuthService.cs +++ b/src/Infrastructure/Identity/Services/AuthService.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.IdentityModel.Tokens; +using Serilog; using Domain.Entities.Person; using Application.DTO; using Application.Interfaces.Repositories.Person; @@ -21,20 +22,24 @@ public class AuthService private readonly IConfiguration _configuration; private readonly RefreshTokensRepository _refreshTokensRepository; private readonly IPersonsRepository _personsRepository; + private readonly ILogger _logger; - public AuthService(UserManager userManager, IConfiguration configuration, RefreshTokensRepository refreshTokensRepository, IPersonsRepository personsRepository) + public AuthService(UserManager userManager, IConfiguration configuration, + RefreshTokensRepository refreshTokensRepository, IPersonsRepository personsRepository, ILogger logger) { _userManager = userManager; _configuration = configuration; _refreshTokensRepository = refreshTokensRepository; _personsRepository = personsRepository; + _logger = logger; } - + public async Task Login(LoginDTO loginDTO) { var account = await _userManager.FindByEmailAsync(loginDTO.Email); if (account is null || !await _userManager.CheckPasswordAsync(account, loginDTO.Password)) throw new InvalidCredentialException("Неверный email или пароль"); + _logger.Information("Произведён вход в систему пользователем {Email}", account.Email); return await GetTokensAsync(account); } @@ -69,6 +74,8 @@ public async Task Register(RegisterDTO registerDTO) "и включает в себя как минимум одну цифру, символ нижнего и верхнего регистра, специальный символ, " + "затем повторите попытку"); } + _logger.Information("Произведена регистрация пользователя {Email} с Id ({PersonId})", + account.Email, person.Id); } public async Task RefreshTokensAsync(TokenModel tokenModel) @@ -85,9 +92,11 @@ public async Task RefreshTokensAsync(TokenModel tokenModel) var account = await _userManager.FindByIdAsync(refreshToken.AccountId); if (account is null) throw new AuthenticationException("Пользователь не найден"); await RevokeRefreshTokenAsync(tokenModel.RefreshToken); - return await GetTokensAsync(account); + tokenModel = await GetTokensAsync(account); + _logger.Information("Токены аутентификации обновлены для пользователя с Id ({PersonId})", account.PersonId); + return tokenModel; } - + private async Task GetTokensAsync(Account account) { var person = (await _personsRepository.GetByIdAsync(account.PersonId))!; @@ -117,10 +126,14 @@ private async Task GetTokensAsync(Account account) ExpiresAt = DateTime.Now.AddMonths(1) }; await _refreshTokensRepository.AddAsync(refreshToken); + _logger.Debug("Создана новая пара токенов аутентификации для пользователя с Id ({PersonId})", person.Id); TokenModel tokenModel = new TokenModel(accessTokenValue, refreshTokenValue); return tokenModel; } - public async Task RevokeRefreshTokenAsync(string refreshTokenValue) => + public async Task RevokeRefreshTokenAsync(string refreshTokenValue) + { await _refreshTokensRepository.RemoveByValueAsync(refreshTokenValue); + _logger.Debug("Аннулирован токен со значением ({RefreshTokenValue})", refreshTokenValue); + } } \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Data/ApplicationDbContext.cs b/src/Infrastructure/Persistence/Data/ApplicationDbContext.cs index ca317a2..4ead819 100644 --- a/src/Infrastructure/Persistence/Data/ApplicationDbContext.cs +++ b/src/Infrastructure/Persistence/Data/ApplicationDbContext.cs @@ -35,6 +35,9 @@ public class ApplicationDbContext : DbContext public DbSet v_PostDetails { get; set; } public DbSet v_CommentDetails { get; set; } public DbSet v_ClubCreationRequestDetails { get; set; } + public DbSet t_ReportEntities { get; set; } + public DbSet t_ReportCategories { get; set; } + public DbSet t_Reports { get; set; } public ApplicationDbContext(DbContextOptions options) : base(options) { diff --git a/src/Infrastructure/Persistence/Repositories/Admin/ReportCategoriesRepository.cs b/src/Infrastructure/Persistence/Repositories/Admin/ReportCategoriesRepository.cs new file mode 100644 index 0000000..0cb2b4f --- /dev/null +++ b/src/Infrastructure/Persistence/Repositories/Admin/ReportCategoriesRepository.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Domain.Entities.Admin; +using Application.Interfaces.Repositories.Admin; +using Infrastructure.Persistence.Data; + +namespace Infrastructure.Persistence.Repositories.Admin; + +public class ReportCategoriesRepository : IReportCategoriesRepository +{ + private readonly ApplicationDbContext _applicationDbContext; + private readonly DbSet _reportCategories; + + public ReportCategoriesRepository(ApplicationDbContext applicationDbContext) + { + _applicationDbContext = applicationDbContext; + _reportCategories = _applicationDbContext.Set(); + } + + public async Task GetByIdAsync(int id) => await _reportCategories.FindAsync(id); + + public async Task CreateAsync(ReportCategory reportCategory) + { + _reportCategories.Add(reportCategory); + await _applicationDbContext.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Repositories/Admin/ReportEntitiesRepository.cs b/src/Infrastructure/Persistence/Repositories/Admin/ReportEntitiesRepository.cs new file mode 100644 index 0000000..51b4f31 --- /dev/null +++ b/src/Infrastructure/Persistence/Repositories/Admin/ReportEntitiesRepository.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Domain.Entities.Admin; +using Application.Interfaces.Repositories.Admin; +using Infrastructure.Persistence.Data; + +namespace Infrastructure.Persistence.Repositories.Admin; + +public class ReportEntitiesRepository : IReportEntitiesRepository +{ + private readonly DbSet _reportEntities; + private readonly ApplicationDbContext _applicationDbContext; + + public ReportEntitiesRepository(ApplicationDbContext applicationDbContext) + { + _applicationDbContext = applicationDbContext; + _reportEntities = _applicationDbContext.Set(); + } + + public async Task GetByIdAsync(int id) => await _reportEntities.FindAsync(id); +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Repositories/Admin/ReportsRepository.cs b/src/Infrastructure/Persistence/Repositories/Admin/ReportsRepository.cs new file mode 100644 index 0000000..9fb4880 --- /dev/null +++ b/src/Infrastructure/Persistence/Repositories/Admin/ReportsRepository.cs @@ -0,0 +1,32 @@ +using Application.Interfaces.Repositories.Admin; +using Domain.Entities.Admin; +using Infrastructure.Persistence.Data; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Persistence.Repositories.Admin; + +public class ReportsRepository : IReportsRepository +{ + private readonly ApplicationDbContext _applicationDbContext; + private readonly DbSet _reports; + + public ReportsRepository(ApplicationDbContext applicationDbContext) + { + _applicationDbContext = applicationDbContext; + _reports = _applicationDbContext.Set(); + } + + public async Task GetByIdAsync(int id) => await _reports.FindAsync(id); + + public async Task CreateAsync(Report report) + { + _reports.Add(report); + await _applicationDbContext.SaveChangesAsync(); + } + + public async Task UpdateAsync(Report report) + { + _reports.Update(report); + await _applicationDbContext.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Services/ImagesService.cs b/src/Infrastructure/Services/ImagesService.cs index 4905603..747e39a 100644 --- a/src/Infrastructure/Services/ImagesService.cs +++ b/src/Infrastructure/Services/ImagesService.cs @@ -1,3 +1,4 @@ +using Serilog; using Domain.Entities; using Application.Exceptions; using Application.Interfaces.Repositories; @@ -8,11 +9,13 @@ namespace Infrastructure.Services; public class ImagesService : IImagesService { private readonly IImagesRepository _imagesRepository; + private readonly ILogger _logger; private readonly string[] _availableImageExtensions = new[] { ".png", ".jpg", ".jpeg" }; - public ImagesService(IImagesRepository imagesRepository) + public ImagesService(IImagesRepository imagesRepository, ILogger logger) { _imagesRepository = imagesRepository; + _logger = logger; } public async Task GetPathByIdAsync(int id) @@ -25,27 +28,38 @@ public async Task GetPathByIdAsync(int id) public async Task CreateAsync(Stream stream, string extension) { if (!_availableImageExtensions.Contains(extension)) + { + _logger.Warning("Загрузка файла с расширением {FileExtension} прервана", extension); throw new ArgumentException("Расширение файла не поддерживается"); + } var image = new Image { Path = Guid.NewGuid() + extension, CreatedAt = DateTime.Now }; + _logger.Information("Начата загрузка изображения {Image}", image); using (var fileStream = File.Create(Path.Combine("/StudHubImages", image.Path))) { await stream.CopyToAsync(fileStream); } + _logger.Debug("Изображение {Image} сохранено на диск", image); await _imagesRepository.CreateAsync(image); + _logger.Information("Изображение {Image} успешно загружено", image); return image.Id; } public async Task DeleteImageFileByIdAsync(int id) { var filePath = Path.Combine("/StudHubImages", await GetPathByIdAsync(id)); + _logger.Information("Начато удаление изображения с Id ({ImageId})", id); await _imagesRepository.DeleteByIdAsync(id); + _logger.Debug("Изображение с Id ({ImageId}) успешно удалено из базы данных", id); if (File.Exists(filePath)) { File.Delete(filePath); + _logger.Information("Изображение с Id ({ImageId}) успешно удалено", id); + return; } + _logger.Warning("Не удалось найти изображение по пути ({FilePath}) при удалении", filePath); } } \ No newline at end of file diff --git a/src/Infrastructure/Services/Queries/ReportsQueryService.cs b/src/Infrastructure/Services/Queries/ReportsQueryService.cs new file mode 100644 index 0000000..ab0c650 --- /dev/null +++ b/src/Infrastructure/Services/Queries/ReportsQueryService.cs @@ -0,0 +1,46 @@ +using Application; +using Microsoft.EntityFrameworkCore; +using Domain.Entities.Admin; +using Application.Exceptions; +using Application.Interfaces.Queries; +using Infrastructure.Persistence.Data; + +namespace Infrastructure.Services.Queries; + +public class ReportsQueryService : IReportsQueryService +{ + private readonly DbSet _reports; + + public ReportsQueryService(ApplicationDbContext context) + { + _reports = context.t_Reports; + } + + public async Task> GetAllAsync() => await _reports.ToListAsync(); + + public async Task GetByIdAsync(int id) + { + var report = await _reports.FindAsync(id); + if (report is null) + throw new NotFoundException(nameof(Report), id); + return report; + } + + public async Task> GetByTargetEntityIdAsync(ReportTargetEntities entity) + { + ICollection reports = new List(); + switch (entity) + { + case ReportTargetEntities.Person: + reports = await _reports.Where(r => r.TargetPersonId != null).ToListAsync(); + break; + case ReportTargetEntities.Club: + reports = await _reports.Where(r => r.TargetClubId != null).ToListAsync(); + break; + case ReportTargetEntities.Post: + reports = await _reports.Where(r => r.TargetPostId != null).ToListAsync(); + break; + } + return reports; + } +} \ No newline at end of file diff --git a/src/Infrastructure/Services/SessionService.cs b/src/Infrastructure/Services/SessionService.cs index a706972..6b61264 100644 --- a/src/Infrastructure/Services/SessionService.cs +++ b/src/Infrastructure/Services/SessionService.cs @@ -1,12 +1,20 @@ using System.Security.Authentication; using System.Security.Claims; +using Serilog; using Application.Interfaces.Services; namespace Infrastructure.Services; public class SessionService : ISessionService { + private readonly ILogger _logger; private int? _personId; + + public SessionService(ILogger logger) + { + _logger = logger; + } + public int PersonId { get => _personId ?? throw new AuthenticationException("Сессия не инициализирована"); @@ -17,5 +25,6 @@ public void Initialise(ClaimsPrincipal claimsPrincipal) { PersonId = int.Parse(claimsPrincipal.FindFirstValue("personId") ?? throw new AuthenticationException("При инициализации использован недействительный ClaimsPrincipal")); + _logger.Debug("Инициализирована сессия для пользователя с Id ({PersonId})", PersonId); } } \ No newline at end of file diff --git a/src/Platform.API/Controllers/PostsController.cs b/src/Platform.API/Controllers/PostsController.cs index 0721050..a982e61 100644 --- a/src/Platform.API/Controllers/PostsController.cs +++ b/src/Platform.API/Controllers/PostsController.cs @@ -3,6 +3,7 @@ using Application.DTO; using Application.Interfaces.Queries; using Application.Interfaces.Services; +using Platform.API.Models; namespace Platform.API.Controllers { @@ -12,11 +13,13 @@ public class PostsController : ControllerBase { private readonly IPostsQueryService _postsQueryService; private readonly IPostsService _postsService; + private readonly IImagesService _imagesService; - public PostsController(IPostsQueryService postsQueryService, IPostsService postsService) + public PostsController(IPostsQueryService postsQueryService, IPostsService postsService, IImagesService imagesService) { _postsQueryService = postsQueryService; _postsService = postsService; + _imagesService = imagesService; } [ProducesResponseType(StatusCodes.Status200OK)] @@ -45,9 +48,20 @@ public async Task> GetById(int id) [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [HttpPost] - public async Task> Create(PostDTO post) + public async Task> Create(CreatePostRequest request) { - var id = await _postsService.CreateAsync(post); + var postDTO = new PostDTO + { + Title = request.Title, + Content = request.Content, + ClubId = request.ClubId + }; + foreach (var image in request.PostImages) + { + var imageExtension = Path.GetExtension(image.FileName); + postDTO.PostImagesIds.Add(await _imagesService.CreateAsync(image.OpenReadStream(), imageExtension)); + } + var id = await _postsService.CreateAsync(postDTO); return CreatedAtAction(nameof(GetById), new { id }, await _postsQueryService.GetDetailByIdAsync(id)); } diff --git a/src/Platform.API/ExceptionHandlers/AuthorizationExceptionHandler.cs b/src/Platform.API/ExceptionHandlers/ApplicationAuthorizationExceptionHandler.cs similarity index 62% rename from src/Platform.API/ExceptionHandlers/AuthorizationExceptionHandler.cs rename to src/Platform.API/ExceptionHandlers/ApplicationAuthorizationExceptionHandler.cs index 2fc4ab2..d1a81c3 100644 --- a/src/Platform.API/ExceptionHandlers/AuthorizationExceptionHandler.cs +++ b/src/Platform.API/ExceptionHandlers/ApplicationAuthorizationExceptionHandler.cs @@ -1,16 +1,25 @@ +using Application.Exceptions; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; +using ILogger = Serilog.ILogger; namespace Platform.API.ExceptionHandlers; -public class AuthorizationExceptionHandler : IExceptionHandler +public class ApplicationAuthorizationExceptionHandler : IExceptionHandler { private const string ResponseTitle = "Доступ запрещён"; private const int StatusCode = StatusCodes.Status403Forbidden; + private readonly ILogger _logger; + + public ApplicationAuthorizationExceptionHandler(ILogger logger) + { + _logger = logger; + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { - if (exception is not UnauthorizedAccessException) + if (exception is not ApplicationAuthorizationException) { return false; } @@ -23,6 +32,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = exception.Message }; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + _logger.Warning(exception, "Исключение обработано: {ResponseTitle}", ResponseTitle); return true; } } \ No newline at end of file diff --git a/src/Platform.API/ExceptionHandlers/AuthenticationExceptionHandler.cs b/src/Platform.API/ExceptionHandlers/AuthenticationExceptionHandler.cs index ac44437..a6c552b 100644 --- a/src/Platform.API/ExceptionHandlers/AuthenticationExceptionHandler.cs +++ b/src/Platform.API/ExceptionHandlers/AuthenticationExceptionHandler.cs @@ -1,6 +1,7 @@ using System.Security.Authentication; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; +using ILogger = Serilog.ILogger; namespace Platform.API.ExceptionHandlers; @@ -8,6 +9,13 @@ public class AuthenticationExceptionHandler : IExceptionHandler { private const string ResponseTitle = "При аутентификации произошла ошибка"; private const int StatusCode = StatusCodes.Status401Unauthorized; + private readonly ILogger _logger; + + public AuthenticationExceptionHandler(ILogger logger) + { + _logger = logger; + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { @@ -24,6 +32,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = exception.Message }; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + _logger.Warning(exception, "Исключение обработано: {ResponseTitle}", ResponseTitle); return true; } } \ No newline at end of file diff --git a/src/Platform.API/ExceptionHandlers/BadRequestExceptionHandler.cs b/src/Platform.API/ExceptionHandlers/BadRequestExceptionHandler.cs index ce7561d..c12e946 100644 --- a/src/Platform.API/ExceptionHandlers/BadRequestExceptionHandler.cs +++ b/src/Platform.API/ExceptionHandlers/BadRequestExceptionHandler.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; +using ILogger = Serilog.ILogger; namespace Platform.API.ExceptionHandlers; @@ -7,6 +8,13 @@ public class BadRequestExceptionHandler : IExceptionHandler { private const string ResponseTitle = "Некорректный запрос"; private const int StatusCode = StatusCodes.Status400BadRequest; + private readonly ILogger _logger; + + public BadRequestExceptionHandler(ILogger logger) + { + _logger = logger; + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { @@ -23,6 +31,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = exception.Message }; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + _logger.Warning(exception, "Исключение обработано: {ResponseTitle}", ResponseTitle); return true; } } \ No newline at end of file diff --git a/src/Platform.API/ExceptionHandlers/GlobalExceptionHandler.cs b/src/Platform.API/ExceptionHandlers/GlobalExceptionHandler.cs index 7692133..fa14194 100644 --- a/src/Platform.API/ExceptionHandlers/GlobalExceptionHandler.cs +++ b/src/Platform.API/ExceptionHandlers/GlobalExceptionHandler.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; +using ILogger = Serilog.ILogger; namespace Platform.API.ExceptionHandlers; @@ -7,6 +8,13 @@ public class GlobalExceptionHandler : IExceptionHandler { private const string ResponseTitle = "Произошла ошибка на стороне сервера"; private const int StatusCode = StatusCodes.Status500InternalServerError; + private readonly ILogger _logger; + + public GlobalExceptionHandler(ILogger logger) + { + _logger = logger; + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { @@ -19,6 +27,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = exception.Message }; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + _logger.Error(exception, ResponseTitle); return true; } } \ No newline at end of file diff --git a/src/Platform.API/ExceptionHandlers/InvalidOperationExceptionHandler.cs b/src/Platform.API/ExceptionHandlers/InvalidOperationExceptionHandler.cs index 76a6547..91e569c 100644 --- a/src/Platform.API/ExceptionHandlers/InvalidOperationExceptionHandler.cs +++ b/src/Platform.API/ExceptionHandlers/InvalidOperationExceptionHandler.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; +using ILogger = Serilog.ILogger; namespace Platform.API.ExceptionHandlers; @@ -8,6 +9,13 @@ public class InvalidOperationExceptionHandler : IExceptionHandler { private const string ResponseTitle = "Недопустимая операция"; private const int StatusCode = StatusCodes.Status409Conflict; + private readonly ILogger _logger; + + public InvalidOperationExceptionHandler(ILogger logger) + { + _logger = logger; + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { @@ -24,6 +32,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = exception.Message }; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + _logger.Warning(exception, "Исключение обработано: {ResponseTitle}", ResponseTitle); return true; } } \ No newline at end of file diff --git a/src/Platform.API/ExceptionHandlers/NotFoundExceptionHandler.cs b/src/Platform.API/ExceptionHandlers/NotFoundExceptionHandler.cs index d284aee..7c5bc7d 100644 --- a/src/Platform.API/ExceptionHandlers/NotFoundExceptionHandler.cs +++ b/src/Platform.API/ExceptionHandlers/NotFoundExceptionHandler.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; using Application.Exceptions; +using ILogger = Serilog.ILogger; namespace Platform.API.ExceptionHandlers; @@ -8,6 +9,13 @@ public class NotFoundExceptionHandler : IExceptionHandler { private const string ResponseTitle = "Запрашиваемый ресурс отсутствует на сервере"; private const int StatusCode = StatusCodes.Status404NotFound; + private readonly ILogger _logger; + + public NotFoundExceptionHandler(ILogger logger) + { + _logger = logger; + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { @@ -24,6 +32,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = exception.Message }; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + _logger.Warning(exception, "Исключение обработано: {ResponseTitle}", ResponseTitle); return true; } } \ No newline at end of file diff --git a/src/Platform.API/Extensions/ResponseCookiesExtensions.cs b/src/Platform.API/Extensions/ResponseCookiesExtensions.cs index 3c729f2..ae0e229 100644 --- a/src/Platform.API/Extensions/ResponseCookiesExtensions.cs +++ b/src/Platform.API/Extensions/ResponseCookiesExtensions.cs @@ -1,9 +1,13 @@ +using Serilog; +using ILogger = Serilog.ILogger; using Infrastructure.Identity.Models.DTO; namespace Platform.API.Extensions; public static class ResponseCookiesExtensions { + private static readonly ILogger _logger = Log.Logger; + public static void UpdateTokenCookies(this IResponseCookies cookies, TokenModel tokens) { cookies.DeleteTokenCookies(); @@ -14,11 +18,13 @@ public static void UpdateTokenCookies(this IResponseCookies cookies, TokenModel }; cookies.Append("AccessToken", tokens.AccessToken, cookieOptions); cookies.Append("RefreshToken", tokens.RefreshToken, cookieOptions); + _logger.Debug("Обновлены Cookie файлы Access и Refresh токенов"); } public static void DeleteTokenCookies(this IResponseCookies cookies) { cookies.Delete("AccessToken"); cookies.Delete("RefreshToken"); + _logger.Debug("Удалены Cookie файлы Access и Refresh токенов"); } } \ No newline at end of file diff --git a/src/Platform.API/Extensions/ServiceCollectionExtensions.cs b/src/Platform.API/Extensions/ServiceCollectionExtensions.cs index 459d0b0..9fa18c9 100644 --- a/src/Platform.API/Extensions/ServiceCollectionExtensions.cs +++ b/src/Platform.API/Extensions/ServiceCollectionExtensions.cs @@ -10,7 +10,7 @@ public static IServiceCollection AddExceptionHandlers(this IServiceCollection se services.AddExceptionHandler(); services.AddExceptionHandler(); services.AddExceptionHandler(); - services.AddExceptionHandler(); + services.AddExceptionHandler(); services.AddExceptionHandler(); return services; } diff --git a/src/Platform.API/Models/CreatePostRequest.cs b/src/Platform.API/Models/CreatePostRequest.cs new file mode 100644 index 0000000..281a6f8 --- /dev/null +++ b/src/Platform.API/Models/CreatePostRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Platform.API.Models; + +public record CreatePostRequest +{ + [Required] + [MaxLength(50)] + public required string Title { get; init; } + [Required] + [MaxLength(500)] + public required string Content { get; init; } + [Required] + public int? ClubId { get; init; } + [MaxLength(5)] + public List PostImages { get; init; } = new List(); +} \ No newline at end of file diff --git a/src/Platform.API/Platform.API.csproj b/src/Platform.API/Platform.API.csproj index ed3f567..89d0fc0 100644 --- a/src/Platform.API/Platform.API.csproj +++ b/src/Platform.API/Platform.API.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Platform.API/Program.cs b/src/Platform.API/Program.cs index d122b3a..8bb1bb2 100644 --- a/src/Platform.API/Program.cs +++ b/src/Platform.API/Program.cs @@ -1,8 +1,11 @@ +using System.Security.Claims; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.CookiePolicy; using Microsoft.Extensions.FileProviders; using Microsoft.IdentityModel.Tokens; using Scalar.AspNetCore; +using Serilog; +using Serilog.Filters; using Application.Interfaces.Services; using Infrastructure.Extensions; using Platform.API.Extensions; @@ -14,6 +17,13 @@ public class Program public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); + builder.Host.UseSerilog((context, configuration) => + { + configuration.ReadFrom.Configuration(context.Configuration); + // Временное решение, дублирование логов при использовании ExceptionHandlerMiddleware исправлено в .NET 10 + configuration.Filter.ByExcluding(Matching + .FromSource()); + }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApiDocument(options => { @@ -81,6 +91,15 @@ public static void Main(string[] args) options.Secure = CookieSecurePolicy.Always; }); var app = builder.Build(); + app.UseSerilogRequestLogging(options => + { + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + var personId = httpContext.User.FindFirstValue("personId") ?? "Undefined"; + diagnosticContext.Set("PersonId", personId); + }; + options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000}ms for PersonId {PersonId}"; + }); app.UseCors(); app.UseExceptionHandler(); app.UseOpenApi(options => @@ -92,7 +111,9 @@ public static void Main(string[] args) options.Title = app.Environment.IsDevelopment() ? "StudHub API [DEV]" : "StudHub API"; options.Servers = new List { - new ScalarServer("https://dev-api.setka-rtu.ru") + new ScalarServer(app.Environment.IsDevelopment() + ? "https://dev-api.setka-rtu.ru" + : "https://api.setka-rtu.ru") }; if (app.Environment.IsDevelopment()) { diff --git a/src/Platform.API/appsettings.Development.json b/src/Platform.API/appsettings.Development.json index 0c208ae..9f81c7e 100644 --- a/src/Platform.API/appsettings.Development.json +++ b/src/Platform.API/appsettings.Development.json @@ -1,8 +1,22 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft.EntityFrameworkCore": "Information", + "Microsoft.AspNetCore": "Information" + } + }, + "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ], + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "File", + "Args": { + "path": "/Logs/Platform.API/Development/DevLog-.txt", + "rollingInterval": "Day" + } + } + ] } } diff --git a/src/Platform.API/appsettings.json b/src/Platform.API/appsettings.json index 969e6ca..876b1c3 100644 --- a/src/Platform.API/appsettings.json +++ b/src/Platform.API/appsettings.json @@ -1,14 +1,24 @@ { - "Logging": { - "LogLevel": { + "Serilog": { + "MinimumLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } + "Override": { + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ], + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "File", + "Args": { + "path": "/Logs/Platform.API/Production/Log-.txt", + "rollingInterval": "Day" + } + } + ] }, "Domain": ".setka-rtu.ru", - "AllowedHosts": "*", - "JWTKey": "9>I4=o8{AG\\Nh{`I7m*={tP:Gs)dsXxNw.Hj92]dQSO", - "ConnectionStrings": { - "Default": "Host=31.128.38.159;Port=5432;User ID=admin-setka;Password=P1{Hg?:(~jLfDNrp!W9QkF?bO;Database=SetkaDB" - } + "AllowedHosts": "*" } \ No newline at end of file diff --git a/test/Unit/ClubCreationRequestsServiceTests.cs b/test/Unit/ClubCreationRequestsServiceTests.cs new file mode 100644 index 0000000..fc6e0ae --- /dev/null +++ b/test/Unit/ClubCreationRequestsServiceTests.cs @@ -0,0 +1,257 @@ +using System.ComponentModel.DataAnnotations; +using AutoMapper; +using Moq; +using Serilog; +using Domain.Entities.Admin; +using Domain.Entities.Club; +using Application.DTO; +using Application.Exceptions; +using Application.Interfaces.Repositories.Admin; +using Application.Interfaces.Repositories.Club; +using Application.Services; + +namespace Unit; + +public class ClubCreationRequestsServiceTests +{ + private readonly Mock _requestsRepoMock; + private readonly Mock _clubsRepoMock; + private readonly Mock _loggerMock; + private readonly Mock _mapperMock; + private readonly ClubCreationRequestsService _clubCreationRequestsService; + public ClubCreationRequestsServiceTests() + { + _requestsRepoMock = new Mock(); + _clubsRepoMock = new Mock(); + _loggerMock = new Mock(); + _mapperMock = new Mock(); + _clubCreationRequestsService = new ClubCreationRequestsService(_requestsRepoMock.Object, _clubsRepoMock.Object, + _mapperMock.Object, _loggerMock.Object); + } + #region CreateRequest + [Fact] + public void CreateRequest_WithValidData_Succeeds() + { + var clubCreationRequest = new ClubCreationRequestDTO + { + ClubName = "Новый клуб", + Comment = "Новый клуб для тестирования функциональности", + PersonId = 0, + ImageId = 0, + BannerId = 0, + ClubAbout = "Описание клуба для создания" + }; + var context = new ValidationContext(clubCreationRequest); + var results = new List(); + + var isValid = Validator.TryValidateObject(clubCreationRequest, context, results, true); + + Assert.True(isValid); + } + + [Fact] + public void CreateRequest_WithEmptyClubName_ValidationFails() + { + var clubCreationRequest = new ClubCreationRequestDTO + { + Comment = "Новый клуб для тестирования функциональности", + PersonId = 0, + ImageId = 0, + BannerId = 0, + ClubAbout = new string('0', 100) + }; + var context = new ValidationContext(clubCreationRequest); + var results = new List(); + + var isValid = Validator.TryValidateObject(clubCreationRequest, context, results, true); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains("ClubName") && r.ErrorMessage!.Contains("required")); + } + + [Fact] + public void CreateRequest_WithLongClubName_ValidationFails() + { + var clubCreationRequest = new ClubCreationRequestDTO + { + ClubName = new string('0', 64), + Comment = "Новый клуб для тестирования функциональности", + PersonId = 0, + ImageId = 0, + BannerId = 0, + ClubAbout = new string('0', 100) + }; + var context = new ValidationContext(clubCreationRequest); + var results = new List(); + + var isValid = Validator.TryValidateObject(clubCreationRequest, context, results, true); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains("ClubName") && r.ErrorMessage!.Contains("maximum length")); + } + + [Fact] + public void CreateRequest_WithEmptyImageId_ValidationFails() + { + var clubCreationRequest = new ClubCreationRequestDTO + { + ClubName = "Новый клуб", + Comment = "Новый клуб для тестирования функциональности", + PersonId = 0 + }; + var context = new ValidationContext(clubCreationRequest); + var results = new List(); + + var isValid = Validator.TryValidateObject(clubCreationRequest, context, results, true); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains("ImageId") && r.ErrorMessage!.Contains("required")); + } + + [Fact] + public void CreateRequest_WithEmptyPersonId_ValidationFails() + { + var clubCreationRequest = new ClubCreationRequestDTO + { + ClubName = "Новый клуб", + Comment = "Новый клуб для тестирования функциональности", + ImageId = 0 + }; + var context = new ValidationContext(clubCreationRequest); + var results = new List(); + + var isValid = Validator.TryValidateObject(clubCreationRequest, context, results, true); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains("PersonId") && r.ErrorMessage!.Contains("required")); + } + + [Fact] + public void CreateRequest_WithLongClubAbout_ValidationFails() + { + var clubCreationRequest = new ClubCreationRequestDTO + { + ClubName = "Новый клуб", + Comment = "Новый клуб для тестирования функциональности", + PersonId = 0, + ImageId = 0, + BannerId = 0, + ClubAbout = new string('0', 256) + }; + var context = new ValidationContext(clubCreationRequest); + var results = new List(); + + var isValid = Validator.TryValidateObject(clubCreationRequest, context, results, true); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains("ClubAbout") && r.ErrorMessage!.Contains("maximum length")); + } + + [Fact] + public void CreateRequest_WithEmptyComment_ValidationFails() + { + var clubCreationRequest = new ClubCreationRequestDTO + { + ClubName = "Новый клуб", + Comment = "", + PersonId = 0 + }; + var context = new ValidationContext(clubCreationRequest); + var results = new List(); + + var isValid = Validator.TryValidateObject(clubCreationRequest, context, results, true); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains("Comment") && r.ErrorMessage!.Contains("required")); + } + + [Fact] + public void CreateRequest_WithLongComment_ValidationFails() + { + var clubCreationRequest = new ClubCreationRequestDTO + { + ClubName = "Новый клуб", + Comment = new string('0', 1001), + PersonId = 0 + }; + var context = new ValidationContext(clubCreationRequest); + var results = new List(); + + var isValid = Validator.TryValidateObject(clubCreationRequest, context, results, true); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains("Comment") && r.ErrorMessage!.Contains("maximum length")); + } + [Fact] + public async Task AcceptRequest_WhenRequestNotFound_ThrowsNotFoundException() + { + var requestId = 1; + _requestsRepoMock + .Setup(r => r.GetByIdAsync(requestId)) + .ReturnsAsync((ClubCreationRequest)null); + + await Assert.ThrowsAsync(() => _clubCreationRequestsService.AcceptRequest(requestId)); + } + + [Fact] + public async Task AcceptRequest_WhenRequestIsNotActive_ThrowsInvalidOperationException() + { + var requestId = 1; + var inactiveRequest = new ClubCreationRequest { IsActive = false }; + _requestsRepoMock + .Setup(r => r.GetByIdAsync(requestId)) + .ReturnsAsync(inactiveRequest); + + var exception = await Assert.ThrowsAsync( + () => _clubCreationRequestsService.AcceptRequest(requestId)); + Assert.Equal("Заявка на создание клуба уже была рассмотрена", exception.Message); + } + + [Fact] + public async Task AcceptRequest_WhenValidRequest_CreatesClubAndUpdatesRequest() + { + var requestId = 1; + var activeRequest = new ClubCreationRequest + { + Id = requestId, + ClubName = "Test Club", + ClubAbout = "About club", + ImageId = 1, + BannerId = 2, + PersonId = 3, + IsActive = true + }; + + _requestsRepoMock + .Setup(r => r.GetByIdAsync(requestId)) + .ReturnsAsync(activeRequest); + + ClubModel createdClub = null; + _clubsRepoMock + .Setup(r => r.CreateAsync(It.IsAny())) + .Callback(club => createdClub = club) + .Returns(Task.CompletedTask); + + _requestsRepoMock + .Setup(r => r.UpdateAsync(activeRequest)) + .Returns(Task.CompletedTask); + + await _clubCreationRequestsService.AcceptRequest(requestId); + + _clubsRepoMock.Verify(r => r.CreateAsync(It.IsAny()), Times.Once); + _requestsRepoMock.Verify(r => r.UpdateAsync(activeRequest), Times.Once); + + Assert.NotNull(createdClub); + Assert.Equal(activeRequest.ClubName, createdClub.Name); + Assert.Equal(activeRequest.ClubAbout, createdClub.About); + Assert.Equal(activeRequest.ImageId, createdClub.ImageId); + Assert.Equal(activeRequest.BannerId, createdClub.BannerId); + Assert.Equal(activeRequest.PersonId, createdClub.OwnerId); + Assert.False(activeRequest.IsActive); + + _loggerMock.Verify(l => l.Information( + "Заявка на создание клуба с Id ({RequestId}) одобрена", requestId), + Times.Once); + } + #endregion +} \ No newline at end of file diff --git a/test/Unit/Unit.csproj b/test/Unit/Unit.csproj new file mode 100644 index 0000000..3285fca --- /dev/null +++ b/test/Unit/Unit.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + +