diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
new file mode 100644
index 0000000..4fce20c
--- /dev/null
+++ b/.gitea/workflows/test.yml
@@ -0,0 +1,40 @@
+name: Tests
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Install PostgreSQL
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y postgresql
+ sudo pg_ctlcluster 16 main start
+ sudo -u postgres psql -c "CREATE USER tnsu_approval_route WITH PASSWORD 'tnsu_approval_route' SUPERUSER;"
+ sudo -u postgres psql -c "CREATE DATABASE tnsu_approval_route OWNER tnsu_approval_route;"
+
+ - name: Restore
+ run: dotnet restore backend/Tnsu.ApprovalRoute.sln
+
+ - name: Build
+ run: dotnet build backend/Tnsu.ApprovalRoute.sln --no-restore -c Release
+
+ - name: Test
+ env:
+ TEST_POSTGRES_CONNECTION: "Host=localhost;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
+ run: dotnet test backend/Tnsu.ApprovalRoute.sln --no-build -c Release
diff --git a/backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml b/backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml
index 7b08163..4b0e0c4 100644
--- a/backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml
+++ b/backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml
@@ -1,7 +1,9 @@
-
+
+ ../.gitea/workflows
+
diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props
index 0217988..64c8bfc 100644
--- a/backend/Directory.Packages.props
+++ b/backend/Directory.Packages.props
@@ -5,12 +5,24 @@
+
+
+
+
+
+
+
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/Tnsu.ApprovalRoute.sln b/backend/Tnsu.ApprovalRoute.sln
index e505b17..ee88a58 100644
--- a/backend/Tnsu.ApprovalRoute.sln
+++ b/backend/Tnsu.ApprovalRoute.sln
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Infrastr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.IntegrationTests", "tests\Tnsu.ApprovalRoute.IntegrationTests\Tnsu.ApprovalRoute.IntegrationTests.csproj", "{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -71,6 +75,18 @@ Global
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x64.Build.0 = Release|Any CPU
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.ActiveCfg = Release|Any CPU
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.Build.0 = Release|Any CPU
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x64.Build.0 = Debug|Any CPU
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x86.Build.0 = Debug|Any CPU
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x64.ActiveCfg = Release|Any CPU
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x64.Build.0 = Release|Any CPU
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x86.ActiveCfg = Release|Any CPU
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -80,5 +96,6 @@ Global
{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{46EF4247-8B98-4CC9-A981-186C8D24163C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4697B342-BE47-435B-B854-75F9277AD769} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs
new file mode 100644
index 0000000..331bf3d
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs
@@ -0,0 +1,6 @@
+namespace Tnsu.ApprovalRoute.Api.Auth;
+
+public static class AuthSchemes
+{
+ public const string Dev = "Dev";
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs
new file mode 100644
index 0000000..e72b17c
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs
@@ -0,0 +1,41 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Tnsu.ApprovalRoute.Api.Auth;
+
+public static class AuthSetup
+{
+ public static IServiceCollection AddApprovalRouteAuth(this IServiceCollection services, IConfiguration configuration)
+ {
+ var tenantId = configuration["Entra:TenantId"];
+ var audience = configuration["Entra:Audience"];
+
+ if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience))
+ {
+ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidIssuers =
+ [
+ $"https://login.microsoftonline.com/{tenantId}/v2.0",
+ $"https://sts.windows.net/{tenantId}/"
+ ],
+ ValidAudiences = [audience, $"api://{audience}"]
+ };
+ });
+ }
+ else
+ {
+ services.AddAuthentication(AuthSchemes.Dev)
+ .AddScheme(AuthSchemes.Dev, _ => { });
+ }
+
+ services.AddAuthorization();
+
+ return services;
+ }
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs
new file mode 100644
index 0000000..42e31cd
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs
@@ -0,0 +1,11 @@
+using System.Security.Claims;
+using Tnsu.ApprovalRoute.Application.Common.Interfaces;
+
+namespace Tnsu.ApprovalRoute.Api.Auth;
+
+public sealed class CurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUser
+{
+ public string? Email =>
+ httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Email)
+ ?? httpContextAccessor.HttpContext?.User.FindFirstValue("preferred_username");
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs
new file mode 100644
index 0000000..8086a82
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs
@@ -0,0 +1,26 @@
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.Options;
+
+namespace Tnsu.ApprovalRoute.Api.Auth;
+
+public sealed class DevAuthHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ IConfiguration configuration)
+ : AuthenticationHandler(options, logger, encoder)
+{
+ protected override Task HandleAuthenticateAsync()
+ {
+ var email = Request.Headers["X-Dev-User-Email"].FirstOrDefault()
+ ?? configuration["Dev:DefaultUserEmail"]
+ ?? "dev@tnsu.kz";
+
+ var identity = new ClaimsIdentity([new Claim(ClaimTypes.Email, email)], AuthSchemes.Dev);
+ var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), AuthSchemes.Dev);
+
+ return Task.FromResult(AuthenticateResult.Success(ticket));
+ }
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs b/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs
new file mode 100644
index 0000000..4a9a560
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs
@@ -0,0 +1,5 @@
+namespace Tnsu.ApprovalRoute.Api.Common;
+
+public sealed record ApiResponse(T Data);
+
+public sealed record ApiError(IReadOnlyList Messages);
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs
new file mode 100644
index 0000000..9935ac3
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs
@@ -0,0 +1,32 @@
+using FluentResults;
+using Microsoft.AspNetCore.Mvc;
+using Tnsu.ApprovalRoute.Application.Common.Errors;
+
+namespace Tnsu.ApprovalRoute.Api.Common;
+
+public static class ResultExtensions
+{
+ public static IActionResult ToActionResult(this Result result)
+ {
+ if (result.IsFailed)
+ return result.Errors[0].ToActionResult();
+
+ return new OkObjectResult(new ApiResponse(result.Value));
+ }
+
+ public static IActionResult ToActionResult(this Result result)
+ {
+ if (result.IsFailed)
+ return result.Errors[0].ToActionResult();
+
+ return new NoContentResult();
+ }
+
+ private static IActionResult ToActionResult(this IError error) => error switch
+ {
+ NotFoundError => new NotFoundObjectResult(new ApiError([error.Message])),
+ ValidationError => new BadRequestObjectResult(new ApiError([error.Message])),
+ ConflictError => new ConflictObjectResult(new ApiError([error.Message])),
+ _ => new ObjectResult(new ApiError([error.Message])) { StatusCode = StatusCodes.Status500InternalServerError }
+ };
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs
new file mode 100644
index 0000000..65bb312
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs
@@ -0,0 +1,50 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Tnsu.ApprovalRoute.Api.Common;
+using Tnsu.ApprovalRoute.Application.RequestTypes;
+
+namespace Tnsu.ApprovalRoute.Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/request-types")]
+[Produces("application/json")]
+public sealed class RequestTypesController(RequestTypeService service) : ControllerBase
+{
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task List(CancellationToken ct) =>
+ (await service.ListAsync(ct)).ToActionResult();
+
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Get(int id, CancellationToken ct) =>
+ (await service.GetAsync(id, ct)).ToActionResult();
+
+ [HttpPost]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ public async Task Create(CreateRequestTypeRequest body, CancellationToken ct) =>
+ (await service.CreateAsync(body, ct)).ToActionResult();
+
+ [HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Update(int id, UpdateRequestTypeRequest body, CancellationToken ct) =>
+ (await service.UpdateAsync(id, body, ct)).ToActionResult();
+
+ [HttpPatch("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Patch(int id, PatchRequestTypeRequest body, CancellationToken ct) =>
+ (await service.PatchAsync(id, body, ct)).ToActionResult();
+
+ [HttpDelete("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Delete(int id, CancellationToken ct) =>
+ (await service.DeleteAsync(id, ct)).ToActionResult();
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs
new file mode 100644
index 0000000..0f74e73
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs
@@ -0,0 +1,52 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Tnsu.ApprovalRoute.Api.Common;
+using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
+
+namespace Tnsu.ApprovalRoute.Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/template-routes/{routeId:int}/steps")]
+[Produces("application/json")]
+public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase
+{
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task List(int routeId, CancellationToken ct) =>
+ (await service.ListAsync(routeId, ct)).ToActionResult();
+
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Get(int routeId, int id, CancellationToken ct) =>
+ (await service.GetAsync(routeId, id, ct)).ToActionResult();
+
+ [HttpPost]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Create(int routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) =>
+ (await service.CreateAsync(routeId, body, ct)).ToActionResult();
+
+ [HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Update(int routeId, int id, UpdateTemplateRouteStepRequest body, CancellationToken ct) =>
+ (await service.UpdateAsync(routeId, id, body, ct)).ToActionResult();
+
+ [HttpPatch("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
+ public async Task Patch(int routeId, int id, PatchTemplateRouteStepRequest body, CancellationToken ct) =>
+ (await service.PatchAsync(routeId, id, body, ct)).ToActionResult();
+
+ [HttpDelete("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Delete(int routeId, int id, CancellationToken ct) =>
+ (await service.DeleteAsync(routeId, id, ct)).ToActionResult();
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs
new file mode 100644
index 0000000..8b38e22
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs
@@ -0,0 +1,61 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Tnsu.ApprovalRoute.Api.Common;
+using Tnsu.ApprovalRoute.Application.TemplateRoutes;
+
+namespace Tnsu.ApprovalRoute.Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/template-routes")]
+[Produces("application/json")]
+public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase
+{
+ [HttpGet("resolve")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) =>
+ (await service.ResolveAsync(request, ct)).ToActionResult();
+
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task List([FromQuery] int? requestTypeId, CancellationToken ct) =>
+ (await service.ListAsync(requestTypeId, ct)).ToActionResult();
+
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Get(int id, CancellationToken ct) =>
+ (await service.GetAsync(id, ct)).ToActionResult();
+
+ [HttpPost]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
+ public async Task Create(CreateTemplateRouteRequest body, CancellationToken ct) =>
+ (await service.CreateAsync(body, ct)).ToActionResult();
+
+ [HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
+ public async Task Update(int id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
+ (await service.UpdateAsync(id, body, ct)).ToActionResult();
+
+ [HttpPatch("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
+ public async Task Patch(int id, PatchTemplateRouteRequest body, CancellationToken ct) =>
+ (await service.PatchAsync(id, body, ct)).ToActionResult();
+
+ [HttpDelete("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Delete(int id, CancellationToken ct) =>
+ (await service.DeleteAsync(id, ct)).ToActionResult();
+}
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..d447132
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs
@@ -0,0 +1,33 @@
+using FluentValidation;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Tnsu.ApprovalRoute.Api.Common;
+
+namespace Tnsu.ApprovalRoute.Api.Filters;
+
+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