diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 55b277f..022fa01 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -6,6 +6,8 @@ + + diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs index 9c0644a..2b0d5a8 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs @@ -20,11 +20,11 @@ public sealed class RequestTypesController(RequestTypeService service) : Control [HttpPost] public async Task Create(CreateRequestTypeRequest body, CancellationToken ct) => - (await service.CreateAsync(body.Name, body.PageUrl, ct)).ToActionResult(); + (await service.CreateAsync(body, ct)).ToActionResult(); [HttpPut("{id:guid}")] public async Task Update(Guid id, UpdateRequestTypeRequest body, CancellationToken ct) => - (await service.UpdateAsync(id, body.Name, body.PageUrl, ct)).ToActionResult(); + (await service.UpdateAsync(id, body, ct)).ToActionResult(); [HttpDelete("{id:guid}")] public async Task Delete(Guid id, CancellationToken ct) => diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs index 473034f..368d8a7 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tnsu.ApprovalRoute.Api.Common; using Tnsu.ApprovalRoute.Application.TemplateRoutes; -using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Api.Controllers; @@ -12,13 +11,8 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase { [HttpGet("resolve")] - public async Task Resolve( - [FromQuery] Guid requestTypeId, - [FromQuery] string? userEmail, - [FromQuery] Guid? projectId, - [FromQuery] RouteStepStatus status, - CancellationToken ct) => - (await service.ResolveAsync(requestTypeId, userEmail, projectId, status, ct)).ToActionResult(); + public async Task Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) => + (await service.ResolveAsync(request, ct)).ToActionResult(); [HttpGet] public async Task List([FromQuery] Guid? requestTypeId, CancellationToken ct) => diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs new file mode 100644 index 0000000..ff4ecf4 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Tnsu.ApprovalRoute.Api.Filters; + +// Валидирует аргументы action'ов, для которых зарегистрирован IValidator — без валидации внутри сервисов. +public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter +{ + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + foreach (var argument in context.ActionArguments.Values) + { + if (argument is null) + continue; + + var validatorType = typeof(IValidator<>).MakeGenericType(argument.GetType()); + if (serviceProvider.GetService(validatorType) is not IValidator validator) + continue; + + var validationContext = new ValidationContext(argument); + var result = await validator.ValidateAsync(validationContext, context.HttpContext.RequestAborted); + if (result.IsValid) + continue; + + context.Result = new BadRequestObjectResult(new + { + message = string.Join(" ", result.Errors.Select(e => e.ErrorMessage)) + }); + return; + } + + await next(); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs index 76e4a2d..27106b6 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.OpenApi; using Tnsu.ApprovalRoute.Api.Auth; +using Tnsu.ApprovalRoute.Api.Filters; using Tnsu.ApprovalRoute.Application; using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Infrastructure; @@ -44,7 +45,7 @@ builder.Services.AddOpenApi(options => }); }); builder.Services.AddHealthChecks(); -builder.Services.AddControllers() +builder.Services.AddControllers(options => options.Filters.Add()) .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index 63a6f1b..e466d09 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs @@ -1,3 +1,5 @@ +using System.Reflection; +using FluentValidation; using Microsoft.Extensions.DependencyInjection; using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Application.TemplateRoutes; @@ -13,6 +15,8 @@ public static class ApplicationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + return services; } } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index 6be017c..014b3a8 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -27,33 +27,23 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) : Result.Ok(ToDto(entity)); } - public async Task> CreateAsync(string name, string pageUrl, CancellationToken cancellationToken) + public async Task> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(name)) - return Result.Fail(new ValidationError("Имя типа заявки обязательно.")); - if (string.IsNullOrWhiteSpace(pageUrl)) - return Result.Fail(new ValidationError("Url страницы обязателен.")); - - var entity = new RequestType { Name = name, PageUrl = pageUrl }; + var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl }; db.RequestTypes.Add(entity); await db.SaveChangesAsync(cancellationToken); return Result.Ok(ToDto(entity)); } - public async Task> UpdateAsync(Guid id, string name, string pageUrl, CancellationToken cancellationToken) + public async Task> UpdateAsync(Guid id, UpdateRequestTypeRequest request, CancellationToken cancellationToken) { var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (entity is null) return Result.Fail(new NotFoundError("Тип заявки", id)); - if (string.IsNullOrWhiteSpace(name)) - return Result.Fail(new ValidationError("Имя типа заявки обязательно.")); - if (string.IsNullOrWhiteSpace(pageUrl)) - return Result.Fail(new ValidationError("Url страницы обязателен.")); - - entity.Name = name; - entity.PageUrl = pageUrl; + entity.Name = request.Name; + entity.PageUrl = request.PageUrl; await db.SaveChangesAsync(cancellationToken); return Result.Ok(ToDto(entity)); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs new file mode 100644 index 0000000..cfd5bf4 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +namespace Tnsu.ApprovalRoute.Application.RequestTypes; + +public sealed class CreateRequestTypeRequestValidator : AbstractValidator +{ + public CreateRequestTypeRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); + RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); + } +} + +public sealed class UpdateRequestTypeRequestValidator : AbstractValidator +{ + public UpdateRequestTypeRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); + RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index ae8c55f..0ba617a 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -37,9 +37,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); - var fieldsError = ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject); - if (fieldsError is not null) - return Result.Fail(fieldsError); + if (!route.ByProject && r.ProjectRole != ProjectRole.None) + return Result.Fail(new ValidationError( + "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); var entity = new TemplateRouteStep { @@ -70,9 +70,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) if (entity is null) return Result.Fail(new NotFoundError("Шаг маршрута", id)); - var fieldsError = ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject); - if (fieldsError is not null) - return Result.Fail(fieldsError); + if (!route.ByProject && r.ProjectRole != ProjectRole.None) + return Result.Fail(new ValidationError( + "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); entity.OrderNo = r.OrderNo; entity.ExecutorEmail = r.ExecutorEmail; @@ -99,17 +99,6 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Ok(); } - private static IError? ValidateFields(string executorEmail, string position, ProjectRole projectRole, bool routeByProject) - { - if (string.IsNullOrWhiteSpace(executorEmail) && string.IsNullOrWhiteSpace(position) && projectRole == ProjectRole.None) - return new ValidationError("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); - - if (!routeByProject && projectRole != ProjectRole.None) - return new ValidationError("Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."); - - return null; - } - private async Task> AppendMissingProjectRoleWarningAsync( TemplateRoute route, Result result, CancellationToken cancellationToken) { diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs new file mode 100644 index 0000000..be1d48b --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; + +public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator +{ + public CreateTemplateRouteStepRequestValidator() + { + RuleFor(x => x) + .Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None)) + .WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); + } +} + +public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator +{ + public UpdateTemplateRouteStepRequestValidator() + { + RuleFor(x => x) + .Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None)) + .WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index 7f828dd..4868411 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -17,3 +17,9 @@ public sealed record ResolveTemplateRouteResultDto( RouteType RouteType, bool RequiresControl, IReadOnlyList Steps); + +public sealed record ResolveTemplateRouteRequest( + Guid RequestTypeId, + string? UserEmail, + Guid? ProjectId, + RouteStepStatus Status); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 4fa96d8..e2704d5 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -39,9 +39,6 @@ public sealed class TemplateRouteService( public async Task> CreateAsync(CreateTemplateRouteRequest r, CancellationToken cancellationToken) { - if (r.CompanyId == Guid.Empty) - return Result.Fail(new ValidationError("Компания обязательна.")); - var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken); if (!requestTypeExists) return Result.Fail(new NotFoundError("Тип заявки", r.RequestTypeId)); @@ -72,9 +69,6 @@ public sealed class TemplateRouteService( if (entity is null) return Result.Fail(new NotFoundError("Маршрут", id)); - if (r.CompanyId == Guid.Empty) - return Result.Fail(new ValidationError("Компания обязательна.")); - var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( @@ -103,28 +97,24 @@ public sealed class TemplateRouteService( } public async Task> ResolveAsync( - Guid requestTypeId, string? userEmail, Guid? projectId, RouteStepStatus status, CancellationToken cancellationToken) + ResolveTemplateRouteRequest request, CancellationToken cancellationToken) { - if (status == RouteStepStatus.None) - return Result.Fail( - new ValidationError("Статус обязателен: Approval или Execution.")); - - var email = string.IsNullOrWhiteSpace(userEmail) ? currentUser.Email : userEmail; + var email = string.IsNullOrWhiteSpace(request.UserEmail) ? currentUser.Email : request.UserEmail; if (string.IsNullOrWhiteSpace(email)) return Result.Fail( new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене.")); - var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == requestTypeId, cancellationToken); + var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken); if (!requestTypeExists) - return Result.Fail(new NotFoundError("Тип заявки", requestTypeId)); + return Result.Fail(new NotFoundError("Тип заявки", request.RequestTypeId)); var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); - var byProject = projectId is not null; + var byProject = request.ProjectId is not null; var route = await db.TemplateRoutes.AsNoTracking() .Include(x => x.Steps) .Where(x => - x.RequestTypeId == requestTypeId && + x.RequestTypeId == request.RequestTypeId && x.CompanyId == employee.CompanyId && x.ByProject == byProject && x.DepartmentId == employee.DepartmentId) @@ -132,7 +122,7 @@ public sealed class TemplateRouteService( ?? await db.TemplateRoutes.AsNoTracking() .Include(x => x.Steps) .Where(x => - x.RequestTypeId == requestTypeId && + x.RequestTypeId == request.RequestTypeId && x.CompanyId == employee.CompanyId && x.ByProject == byProject && x.DepartmentId == null) @@ -141,14 +131,14 @@ public sealed class TemplateRouteService( if (route is null) return Result.Fail(new NotFoundError( "Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»", - requestTypeId)); + request.RequestTypeId)); IReadOnlyList projectExecutors = []; - if (projectId is { } id) - projectExecutors = await portalClient.GetProjectExecutorsAsync(id, cancellationToken); + if (request.ProjectId is { } projectId) + projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken); var steps = route.Steps - .Where(s => s.Status == status) + .Where(s => s.Status == request.Status) .OrderBy(s => s.OrderNo) .Select(s => ResolveStep(s, projectExecutors)) .ToList(); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs new file mode 100644 index 0000000..0088486 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; + +public sealed class CreateTemplateRouteRequestValidator : AbstractValidator +{ + public CreateTemplateRouteRequestValidator() + { + RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна."); + } +} + +public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator +{ + public UpdateTemplateRouteRequestValidator() + { + RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна."); + } +} + +public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator +{ + public ResolveTemplateRouteRequestValidator() + { + RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj b/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj index 9988106..a10ee5e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj @@ -5,6 +5,8 @@ + +