From 602554ac64fc01cd877b03f1d5cdaf6ccd15a1cc Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 27 Jul 2026 13:14:32 +0000 Subject: [PATCH] =?UTF-8?q?=D0=B0=D0=BF=D0=B8=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=80=D1=88=D1=80=D1=83=D1=82=D0=B0=D0=BC=D0=B8=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B3=D0=BB=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/2 Co-authored-by: gorbenko.pavel Co-committed-by: gorbenko.pavel --- .gitea/workflows/test.yml | 40 ++ .../.idea/indexLayout.xml | 4 +- backend/Directory.Packages.props | 14 +- backend/Tnsu.ApprovalRoute.sln | 17 + .../Auth/AuthSchemes.cs | 6 + .../Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs | 41 ++ .../Auth/CurrentUserAccessor.cs | 11 + .../Auth/DevAuthHandler.cs | 26 ++ .../Common/ApiResponse.cs | 5 + .../Common/ResultExtensions.cs | 32 ++ .../Controllers/RequestTypesController.cs | 50 +++ .../TemplateRouteStepsController.cs | 52 +++ .../Controllers/TemplateRoutesController.cs | 61 +++ .../Filters/ValidationActionFilter.cs | 33 ++ backend/src/Tnsu.ApprovalRoute.Api/Program.cs | 54 ++- .../Properties/launchSettings.json | 14 + .../Tnsu.ApprovalRoute.Api.csproj | 2 +- .../Tnsu.ApprovalRoute.Api/appsettings.json | 15 + .../ApplicationServiceCollectionExtensions.cs | 12 +- .../Common/Errors/Errors.cs | 10 + .../Common/Interfaces/ICurrentUser.cs | 6 + .../RequestTypes/RequestTypeDto.cs | 9 + .../RequestTypes/RequestTypeService.cs | 78 ++++ .../RequestTypes/RequestTypeValidators.cs | 32 ++ .../TemplateRouteStepDto.cs | 42 ++ .../TemplateRouteStepService.cs | 185 ++++++++ .../TemplateRouteStepValidators.cs | 33 ++ .../TemplateRoutes/ResolveTemplateRouteDto.cs | 22 + .../TemplateRoutes/TemplateRouteDto.cs | 34 ++ .../TemplateRoutes/TemplateRouteService.cs | 246 +++++++++++ .../TemplateRoutes/TemplateRouteValidators.cs | 37 ++ .../Tnsu.ApprovalRoute.Application.csproj | 6 +- .../Entities/RequestType.cs | 7 +- .../Entities/Route.cs | 20 - .../Entities/RouteStep.cs | 20 - .../Entities/TemplateRoute.cs | 19 + .../Entities/TemplateRouteStep.cs | 20 + .../Enums/HeadType.cs | 9 + .../Enums/ProjectRole.cs | 7 +- .../Enums/RouteStepStatus.cs | 9 +- ...frastructureServiceCollectionExtensions.cs | 12 + .../Persistence/ApprovalRouteDbContext.cs | 16 +- ... 20260727103117_InitialCreate.Designer.cs} | 69 +-- ...ate.cs => 20260727103117_InitialCreate.cs} | 52 ++- .../ApprovalRouteDbContextModelSnapshot.cs | 67 +-- .../Tnsu.ApprovalRoute.Infrastructure.csproj | 4 +- .../TnsuPortal/HttpTnsuPortalClient.cs | 106 +++++ .../TnsuPortal/ITnsuPortalClient.cs | 12 + .../TnsuPortal/TnsuPortalDtos.cs | 7 + .../TnsuPortal/TnsuPortalOptions.cs | 11 + .../TnsuPortal/TnsuPortalTokenProvider.cs | 42 ++ .../RequestTypeServiceTests.cs | 125 ++++++ .../TemplateRouteServiceTests.cs | 400 ++++++++++++++++++ .../TemplateRouteStepServiceTests.cs | 270 ++++++++++++ .../Infrastructure/PostgresDatabaseFixture.cs | 57 +++ .../Infrastructure/TestCurrentUser.cs | 8 + .../TestServiceProviderFactory.cs | 34 ++ .../Infrastructure/TestSettings.cs | 8 + ...Tnsu.ApprovalRoute.IntegrationTests.csproj | 27 ++ 59 files changed, 2515 insertions(+), 152 deletions(-) create mode 100644 .gitea/workflows/test.yml create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/Errors.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/Common/Interfaces/ICurrentUser.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs delete mode 100644 backend/src/Tnsu.ApprovalRoute.Domain/Entities/Route.cs delete mode 100644 backend/src/Tnsu.ApprovalRoute.Domain/Entities/RouteStep.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260722130455_InitialCreate.Designer.cs => 20260727103117_InitialCreate.Designer.cs} (73%) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260722130455_InitialCreate.cs => 20260727103117_InitialCreate.cs} (60%) create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/PostgresDatabaseFixture.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestCurrentUser.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestSettings.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj 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(argument); + var result = await validator.ValidateAsync(validationContext, context.HttpContext.RequestAborted); + if (result.IsValid) + continue; + + context.Result = new BadRequestObjectResult( + new ApiError(result.Errors.Select(e => e.ErrorMessage).ToList())); + return; + } + + await next(); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs index 5d1223b..2e6474d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs @@ -1,24 +1,76 @@ +using System.Text.Json.Serialization; using Extensions.Hosting.AsyncInitialization; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi; +using Scalar.AspNetCore; +using Tnsu.ApprovalRoute.Api.Auth; +using Tnsu.ApprovalRoute.Api.Filters; using Tnsu.ApprovalRoute.Application; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Infrastructure; var builder = WebApplication.CreateBuilder(args); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteApplication(); -builder.Services.AddOpenApi(); +builder.Services.AddApprovalRouteAuth(builder.Configuration); +builder.Services.AddScoped(); +builder.Services.AddOpenApi(options => +{ + options.AddDocumentTransformer((document, _, _) => + { + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "Введите JWT токен" + }; + return Task.CompletedTask; + }); + options.AddOperationTransformer((operation, context, _) => + { + operation.Security = + [ + new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("Bearer", context.Document)] = [] + } + ]; + return Task.CompletedTask; + }); +}); builder.Services.AddHealthChecks(); +builder.Services.AddControllers(options => options.Filters.Add()) + .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); +builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); if (app.Environment.IsDevelopment()) +{ app.MapOpenApi(); + app.MapScalarApiReference(options => + { + options + .WithTitle("Tnsu Approval Route") + .AddPreferredSecuritySchemes("Bearer") + .AddHttpAuthentication("Bearer", _ => { }); + }); +} + app.MapHealthChecks("/health"); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + await app.InitAndRunAsync(); public partial class Program; diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json b/backend/src/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json new file mode 100644 index 0000000..543069b --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj b/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj index 325ff39..da2a380 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj @@ -7,10 +7,10 @@ - all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json index 7cd39c3..ebc9871 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json @@ -7,5 +7,20 @@ }, "ConnectionStrings": { "Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" + }, + "Entra": { + "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", + "Audience": "9232d9e2-9807-4753-808f-07250e5474b0" + }, + "Dev": { + "DefaultUserEmail": "dev@tnsu.kz" + }, + "TnsuPortal": { + "BaseUrl": "https://api.tnsu.kz", + "TenantId": "", + "ClientId": "", + "ClientSecret": "", + "Scope": "", + "DirectorTansuEmail": "director.tansu@tnsu.kz" } } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index 51291b7..e466d09 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs @@ -1,6 +1,9 @@ using System.Reflection; -using MediatR; +using FluentValidation; using Microsoft.Extensions.DependencyInjection; +using Tnsu.ApprovalRoute.Application.RequestTypes; +using Tnsu.ApprovalRoute.Application.TemplateRoutes; +using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; namespace Tnsu.ApprovalRoute.Application; @@ -8,7 +11,12 @@ public static class ApplicationServiceCollectionExtensions { public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services) { - services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + return services; } } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/Errors.cs b/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/Errors.cs new file mode 100644 index 0000000..4285651 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/Errors.cs @@ -0,0 +1,10 @@ +using FluentResults; + +namespace Tnsu.ApprovalRoute.Application.Common.Errors; + +public sealed class NotFoundError(string entity, object id) + : Error($"{entity} с id {id} не найден."); + +public sealed class ValidationError(string message) : Error(message); + +public sealed class ConflictError(string message) : Error(message); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Common/Interfaces/ICurrentUser.cs b/backend/src/Tnsu.ApprovalRoute.Application/Common/Interfaces/ICurrentUser.cs new file mode 100644 index 0000000..673c68c --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/Common/Interfaces/ICurrentUser.cs @@ -0,0 +1,6 @@ +namespace Tnsu.ApprovalRoute.Application.Common.Interfaces; + +public interface ICurrentUser +{ + string? Email { get; } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs new file mode 100644 index 0000000..50cdce7 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs @@ -0,0 +1,9 @@ +namespace Tnsu.ApprovalRoute.Application.RequestTypes; + +public sealed record RequestTypeDto(int Id, string Name, string PageUrl); + +public sealed record CreateRequestTypeRequest(string Name, string PageUrl); + +public sealed record UpdateRequestTypeRequest(string Name, string PageUrl); + +public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs new file mode 100644 index 0000000..481c5d9 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -0,0 +1,78 @@ +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.Application.RequestTypes; + +public sealed class RequestTypeService(ApprovalRouteDbContext db) +{ + public async Task>> ListAsync(CancellationToken cancellationToken) + { + var items = await db.RequestTypes.AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl)) + .ToListAsync(cancellationToken); + + return Result.Ok>(items); + } + + public async Task> GetAsync(int id, CancellationToken cancellationToken) + { + var entity = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + return entity is null + ? Result.Fail(new NotFoundError("Тип заявки", id)) + : Result.Ok(ToDto(entity)); + } + + public async Task> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken) + { + 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(int 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)); + + entity.Name = request.Name; + entity.PageUrl = request.PageUrl; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(ToDto(entity)); + } + + public async Task> PatchAsync(int id, PatchRequestTypeRequest request, CancellationToken cancellationToken) + { + var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Тип заявки", id)); + + entity.Name = request.Name ?? entity.Name; + entity.PageUrl = request.PageUrl ?? entity.PageUrl; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(ToDto(entity)); + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken) + { + var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Тип заявки", id)); + + db.RequestTypes.Remove(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(); + } + + private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl); +} 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..51946db --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs @@ -0,0 +1,32 @@ +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 страницы обязателен."); + } +} + +public sealed class PatchRequestTypeRequestValidator : AbstractValidator +{ + public PatchRequestTypeRequestValidator() + { + When(x => x.Name is not null, () => + RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым.")); + When(x => x.PageUrl is not null, () => + RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым.")); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs new file mode 100644 index 0000000..ca3074e --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -0,0 +1,42 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; + +public sealed record TemplateRouteStepDto( + int Id, + int OrderNo, + string? ExecutorEmail, + string? Position, + ProjectRole? ProjectRole, + HeadType? HeadType, + RouteStepStatus Status, + int RouteId, + bool AllowReassignToOtherApprovers, + string? Warning = null); + +public sealed record CreateTemplateRouteStepRequest( + int OrderNo, + string? ExecutorEmail, + string? Position, + ProjectRole? ProjectRole, + HeadType? HeadType, + RouteStepStatus Status, + bool AllowReassignToOtherApprovers); + +public sealed record UpdateTemplateRouteStepRequest( + int OrderNo, + string? ExecutorEmail, + string? Position, + ProjectRole? ProjectRole, + HeadType? HeadType, + RouteStepStatus Status, + bool AllowReassignToOtherApprovers); + +public sealed record PatchTemplateRouteStepRequest( + int? OrderNo, + string? ExecutorEmail, + string? Position, + ProjectRole? ProjectRole, + HeadType? HeadType, + RouteStepStatus? Status, + bool? AllowReassignToOtherApprovers); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs new file mode 100644 index 0000000..d43d3a0 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -0,0 +1,185 @@ +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Domain.Enums; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; + +public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) +{ + public async Task>> ListAsync(int routeId, CancellationToken cancellationToken) + { + var items = await db.TemplateRouteSteps.AsNoTracking() + .Where(x => x.RouteId == routeId) + .OrderBy(x => x.OrderNo) + .Select(x => new TemplateRouteStepDto( + x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers)) + .ToListAsync(cancellationToken); + + return Result.Ok>(items); + } + + public async Task> GetAsync(int routeId, int id, CancellationToken cancellationToken) + { + var entity = await db.TemplateRouteSteps.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); + + return entity is null + ? Result.Fail(new NotFoundError("Шаг маршрута", id)) + : Result.Ok(ToDto(entity)); + } + + public async Task> CreateAsync(int routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken) + { + var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); + if (route is null) + return Result.Fail(new NotFoundError("Маршрут", routeId)); + + if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None)) + return Result.Fail(new ValidationError( + "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); + + var duplicate = await HasDuplicateAsync( + route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken); + if (duplicate) + return Result.Fail(new ConflictError( + "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте.")); + + var entity = new TemplateRouteStep + { + RouteId = route.Id, + OrderNo = r.OrderNo, + ExecutorEmail = r.ExecutorEmail, + Position = r.Position, + ProjectRole = r.ProjectRole, + HeadType = r.HeadType, + Status = r.Status, + AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers + }; + db.TemplateRouteSteps.Add(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); + } + + public async Task> UpdateAsync( + int routeId, int id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken) + { + var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); + if (route is null) + return Result.Fail(new NotFoundError("Маршрут", routeId)); + + var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Шаг маршрута", id)); + + if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None)) + return Result.Fail(new ValidationError( + "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); + + var duplicate = await HasDuplicateAsync( + route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken); + if (duplicate) + return Result.Fail(new ConflictError( + "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте.")); + + entity.OrderNo = r.OrderNo; + entity.ExecutorEmail = r.ExecutorEmail; + entity.Position = r.Position; + entity.ProjectRole = r.ProjectRole; + entity.HeadType = r.HeadType; + entity.Status = r.Status; + entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); + } + + public async Task> PatchAsync( + int routeId, int id, PatchTemplateRouteStepRequest r, CancellationToken cancellationToken) + { + var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); + if (route is null) + return Result.Fail(new NotFoundError("Маршрут", routeId)); + + var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Шаг маршрута", id)); + + var executorEmail = r.ExecutorEmail ?? entity.ExecutorEmail; + var position = r.Position ?? entity.Position; + var projectRole = r.ProjectRole ?? entity.ProjectRole; + var headType = r.HeadType ?? entity.HeadType; + var status = r.Status ?? entity.Status; + + if (!route.ByProject && projectRole is not (null or ProjectRole.None)) + return Result.Fail(new ValidationError( + "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); + + if (TemplateRouteStepFields.CountFilled(executorEmail, position, projectRole, headType) != 1) + return Result.Fail(new ValidationError( + "Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); + + var duplicate = await HasDuplicateAsync( + route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken); + if (duplicate) + return Result.Fail(new ConflictError( + "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте.")); + + entity.OrderNo = r.OrderNo ?? entity.OrderNo; + entity.ExecutorEmail = executorEmail; + entity.Position = position; + entity.ProjectRole = projectRole; + entity.HeadType = headType; + entity.Status = status; + entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); + } + + public async Task DeleteAsync(int routeId, int id, CancellationToken cancellationToken) + { + var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Шаг маршрута", id)); + + db.TemplateRouteSteps.Remove(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(); + } + + private Task HasDuplicateAsync( + int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType, + RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) => + db.TemplateRouteSteps.AnyAsync(x => + x.Id != excludeId && + x.RouteId == routeId && + x.ExecutorEmail == executorEmail && + x.Position == position && + x.ProjectRole == projectRole && + x.HeadType == headType && + x.Status == status, + cancellationToken); + + private async Task ToDtoWithWarningAsync( + TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken) + { + string? warning = null; + if (route.ByProject) + { + var hasProjectRoleStep = await db.TemplateRouteSteps + .AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != null && x.ProjectRole != ProjectRole.None, cancellationToken); + if (!hasProjectRoleStep) + warning = "Должен быть хотя бы один шаг с ролью в проекте."; + } + + return ToDto(entity) with { Warning = warning }; + } + + private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( + e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers); +} 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..37f1620 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; + +public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator +{ + public CreateTemplateRouteStepRequestValidator() + { + RuleFor(x => x) + .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) + .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); + } +} + +public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator +{ + public UpdateTemplateRouteStepRequestValidator() + { + RuleFor(x => x) + .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) + .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); + } +} + +internal static class TemplateRouteStepFields +{ + public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) => + (string.IsNullOrWhiteSpace(executorEmail) ? 0 : 1) + + (string.IsNullOrWhiteSpace(position) ? 0 : 1) + + (projectRole is null or ProjectRole.None ? 0 : 1) + + (headType is null or HeadType.None ? 0 : 1); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs new file mode 100644 index 0000000..7947af8 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -0,0 +1,22 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; + +public sealed record ResolvedRouteStepDto( + int Id, + int OrderNo, + string? ExecutorEmail, + string? Position, + bool AllowReassignToOtherApprovers); + +public sealed record ResolveTemplateRouteResultDto( + int RouteId, + RouteType RouteType, + bool RequiresControl, + IReadOnlyList Steps); + +public sealed record ResolveTemplateRouteRequest( + int RequestTypeId, + string? UserEmail, + Guid? ProjectId, + RouteStepStatus Status); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs new file mode 100644 index 0000000..6992701 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs @@ -0,0 +1,34 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; + +public sealed record TemplateRouteDto( + int Id, + RouteType RouteType, + int RequestTypeId, + Guid? DepartmentId, + Guid CompanyId, + bool ByProject, + bool RequiresControl); + +public sealed record CreateTemplateRouteRequest( + RouteType RouteType, + int RequestTypeId, + Guid? DepartmentId, + Guid CompanyId, + bool ByProject, + bool RequiresControl); + +public sealed record UpdateTemplateRouteRequest( + RouteType RouteType, + Guid? DepartmentId, + Guid CompanyId, + bool ByProject, + bool RequiresControl); + +public sealed record PatchTemplateRouteRequest( + RouteType? RouteType, + Guid? DepartmentId, + Guid? CompanyId, + bool? ByProject, + bool? RequiresControl); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs new file mode 100644 index 0000000..3acf480 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -0,0 +1,246 @@ +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Domain.Enums; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; + +public sealed class TemplateRouteService( + ApprovalRouteDbContext db, + ITnsuPortalClient portalClient, + ICurrentUser currentUser, + IOptions portalOptions) +{ + public async Task>> ListAsync(int? requestTypeId, CancellationToken cancellationToken) + { + var query = db.TemplateRoutes.AsNoTracking().AsQueryable(); + if (requestTypeId is { } id) + query = query.Where(x => x.RequestTypeId == id); + + var items = await query + .Select(x => new TemplateRouteDto( + x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl)) + .ToListAsync(cancellationToken); + + return Result.Ok>(items); + } + + public async Task> GetAsync(int id, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + return entity is null + ? Result.Fail(new NotFoundError("Маршрут", id)) + : Result.Ok(ToDto(entity)); + } + + public async Task> CreateAsync(CreateTemplateRouteRequest r, CancellationToken cancellationToken) + { + var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken); + if (!requestTypeExists) + return Result.Fail(new NotFoundError("Тип заявки", r.RequestTypeId)); + + var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken); + if (duplicate) + return Result.Fail(new ConflictError( + "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); + + var entity = new TemplateRoute + { + RouteType = r.RouteType, + RequestTypeId = r.RequestTypeId, + DepartmentId = r.DepartmentId, + CompanyId = r.CompanyId, + ByProject = r.ByProject, + RequiresControl = r.RequiresControl + }; + db.TemplateRoutes.Add(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(ToDto(entity)); + } + + public async Task> UpdateAsync(int id, UpdateTemplateRouteRequest r, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Маршрут", id)); + + var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken); + if (duplicate) + return Result.Fail(new ConflictError( + "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); + + entity.RouteType = r.RouteType; + entity.DepartmentId = r.DepartmentId; + entity.CompanyId = r.CompanyId; + entity.ByProject = r.ByProject; + entity.RequiresControl = r.RequiresControl; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(ToDto(entity)); + } + + public async Task> PatchAsync(int id, PatchTemplateRouteRequest r, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Маршрут", id)); + + var departmentId = r.DepartmentId ?? entity.DepartmentId; + var companyId = r.CompanyId ?? entity.CompanyId; + var byProject = r.ByProject ?? entity.ByProject; + + var duplicate = await HasDuplicateAsync(entity.RequestTypeId, departmentId, companyId, byProject, excludeId: entity.Id, cancellationToken); + if (duplicate) + return Result.Fail(new ConflictError( + "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); + + entity.RouteType = r.RouteType ?? entity.RouteType; + entity.DepartmentId = departmentId; + entity.CompanyId = companyId; + entity.ByProject = byProject; + entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(ToDto(entity)); + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Маршрут", id)); + + db.TemplateRoutes.Remove(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(); + } + + public async Task> ResolveAsync( + ResolveTemplateRouteRequest request, CancellationToken cancellationToken) + { + 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 == request.RequestTypeId, cancellationToken); + if (!requestTypeExists) + return Result.Fail(new NotFoundError("Тип заявки", request.RequestTypeId)); + + var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); + if (employee is null) + return Result.Fail( + new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email)); + + var byProject = request.ProjectId is not null; + + var route = await db.TemplateRoutes.AsNoTracking() + .Include(x => x.Steps) + .Where(x => + x.RequestTypeId == request.RequestTypeId && + x.CompanyId == employee.CompanyId && + x.ByProject == byProject && + x.DepartmentId == employee.DepartmentId) + .FirstOrDefaultAsync(cancellationToken) + ?? await db.TemplateRoutes.AsNoTracking() + .Include(x => x.Steps) + .Where(x => + x.RequestTypeId == request.RequestTypeId && + x.CompanyId == employee.CompanyId && + x.ByProject == byProject && + x.DepartmentId == null) + .FirstOrDefaultAsync(cancellationToken); + + if (route is null) + return Result.Fail(new NotFoundError( + "Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»", + request.RequestTypeId)); + + IReadOnlyList projectExecutors = []; + if (request.ProjectId is { } projectId) + projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken); + + var matchingSteps = route.Steps + .Where(s => s.Status == request.Status) + .OrderBy(s => s.OrderNo) + .ToList(); + + var steps = new List(); + foreach (var step in matchingSteps) + steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, cancellationToken)); + + var collapsedSteps = CollapseByExecutorEmail(steps); + + return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, collapsedSteps)); + } + + private Task HasDuplicateAsync( + int requestTypeId, Guid? departmentId, Guid companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) => + db.TemplateRoutes.AnyAsync(x => + x.Id != excludeId && + x.RequestTypeId == requestTypeId && + x.DepartmentId == departmentId && + x.CompanyId == companyId && + x.ByProject == byProject, + cancellationToken); + + private async Task ResolveStepAsync( + TemplateRouteStep step, string initiatorEmail, string initiatorCompany, + IReadOnlyList projectExecutors, CancellationToken cancellationToken) + { + string? resolvedEmail; + + if (!string.IsNullOrWhiteSpace(step.ExecutorEmail)) + resolvedEmail = step.ExecutorEmail; + else if (!string.IsNullOrWhiteSpace(step.Position)) + resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken); + else if (step.ProjectRole is not (null or ProjectRole.None)) + resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email; + else if (step.HeadType is not (null or HeadType.None)) + resolvedEmail = step.HeadType switch + { + HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken), + HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken), + HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail, + _ => null + }; + else + resolvedEmail = null; + + string? resolvedPosition = null; + if (!string.IsNullOrWhiteSpace(resolvedEmail)) + { + var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken); + resolvedPosition = resolvedEmployee?.Position; + } + + return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers); + } + + private static IReadOnlyList CollapseByExecutorEmail(IReadOnlyList steps) + { + var seenEmails = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(); + + foreach (var step in steps) + { + if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail)) + continue; + + result.Add(step); + } + + return result; + } + + private static TemplateRouteDto ToDto(TemplateRoute e) => new( + e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); +} 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..23019e4 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs @@ -0,0 +1,37 @@ +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 PatchTemplateRouteRequestValidator : AbstractValidator +{ + public PatchTemplateRouteRequestValidator() + { + When(x => x.CompanyId is not null, () => + RuleFor(x => x.CompanyId!.Value).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 60bfbd4..a10ee5e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj @@ -1,8 +1,12 @@ + - + + + + diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs index 2cd90e7..d86af83 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs @@ -1,13 +1,10 @@ -using System; -using System.Collections.Generic; - namespace Tnsu.ApprovalRoute.Domain.Entities; public class RequestType { - public Guid Id { get; set; } = Guid.NewGuid(); + public int Id { get; set; } public string Name { get; set; } = string.Empty; public string PageUrl { get; set; } = string.Empty; - public ICollection Routes { get; set; } = new List(); + public ICollection Routes { get; set; } = new List(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/Route.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/Route.cs deleted file mode 100644 index 2df9be2..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/Route.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Tnsu.ApprovalRoute.Domain.Enums; - -namespace Tnsu.ApprovalRoute.Domain.Entities; - -public class Route -{ - public Guid Id { get; set; } = Guid.NewGuid(); - public RouteType RouteType { get; set; } = RouteType.Sequential; - public Guid RequestTypeId { get; set; } - - // Внешние справочники (1С/ZUP) — хранятся как ссылки без FK в этой БД. - public Guid DepartmentId { get; set; } - public Guid CompanyId { get; set; } - public bool ByProject { get; set; } - - public bool RequiresControl { get; set; } - - public RequestType? RequestType { get; set; } - public ICollection Steps { get; set; } = new List(); -} diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RouteStep.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RouteStep.cs deleted file mode 100644 index a70ef8c..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RouteStep.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Tnsu.ApprovalRoute.Domain.Enums; - -namespace Tnsu.ApprovalRoute.Domain.Entities; - -public class RouteStep -{ - public Guid Id { get; set; } = Guid.NewGuid(); - public int OrderNo { get; set; } - - // Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users. - public string ExecutorEmail { get; set; } = string.Empty; - public string Position { get; set; } = string.Empty; - public ProjectRole ProjectRole { get; set; } = ProjectRole.None; - - public RouteStepStatus Status { get; set; } = RouteStepStatus.Draft; - public Guid RouteId { get; set; } - public bool AllowReassignToOtherApprovers { get; set; } - - public Route? Route { get; set; } -} diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs new file mode 100644 index 0000000..9319917 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs @@ -0,0 +1,19 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Domain.Entities; + +public class TemplateRoute +{ + public int Id { get; set; } + public RouteType RouteType { get; set; } = RouteType.Sequential; + public int RequestTypeId { get; set; } + + public Guid? DepartmentId { get; set; } + public Guid CompanyId { get; set; } + public bool ByProject { get; set; } + + public bool RequiresControl { get; set; } + + public RequestType? RequestType { get; set; } + public ICollection Steps { get; set; } = new List(); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs new file mode 100644 index 0000000..9901b07 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -0,0 +1,20 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Domain.Entities; + +public class TemplateRouteStep +{ + public int Id { get; set; } + public int OrderNo { get; set; } + + public string? ExecutorEmail { get; set; } + public string? Position { get; set; } + public ProjectRole? ProjectRole { get; set; } + public HeadType? HeadType { get; set; } + + public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval; + public int RouteId { get; set; } + public bool AllowReassignToOtherApprovers { get; set; } + + public TemplateRoute? Route { get; set; } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs new file mode 100644 index 0000000..a3e097d --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs @@ -0,0 +1,9 @@ +namespace Tnsu.ApprovalRoute.Domain.Enums; + +public enum HeadType +{ + None, + Chief, + DirectionHead, + DirectorTansu +} diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs index f27fc56..3d4bfcd 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs @@ -3,7 +3,8 @@ namespace Tnsu.ApprovalRoute.Domain.Enums; public enum ProjectRole { None, - Manager, - Engineer, - Observer + ProjectManager, + Planner, + ManagerHSE, + Security } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteStepStatus.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteStepStatus.cs index 77d1367..234f672 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteStepStatus.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteStepStatus.cs @@ -3,11 +3,6 @@ namespace Tnsu.ApprovalRoute.Domain.Enums; public enum RouteStepStatus { None, - Draft, - OnApproval, - Approved, - InExecution, - Completed, - Returned, - Closed + Approval, + Execution } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs index a006dd4..fccf5ce 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -2,7 +2,9 @@ using Extensions.Hosting.AsyncInitialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure; @@ -11,11 +13,21 @@ public static class InfrastructureServiceCollectionExtensions public static IServiceCollection AddApprovalRouteInfrastructure( this IServiceCollection services, IConfiguration configuration) { + services.AddHttpContextAccessor(); + services.AddDbContext(opt => opt.UseNpgsql(configuration.GetConnectionString("Postgres")) .UseSnakeCaseNamingConvention()); services.AddAsyncInitializer(); + services.Configure(configuration.GetSection("TnsuPortal")); + services.AddHttpClient(); + services.AddHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + }); + return services; } } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 080ae1f..2c84977 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -6,8 +6,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; public class ApprovalRouteDbContext(DbContextOptions options) : DbContext(options) { public DbSet RequestTypes => Set(); - public DbSet Routes => Set(); - public DbSet RouteSteps => Set(); + public DbSet TemplateRoutes => Set(); + public DbSet TemplateRouteSteps => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -18,19 +18,21 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired(); }); - modelBuilder.Entity(e => + modelBuilder.Entity(e => { e.HasKey(x => x.Id); e.Property(x => x.RouteType).HasConversion().HasMaxLength(32).IsRequired(); e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId); + e.HasIndex(x => new { x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject }).IsUnique(); }); - modelBuilder.Entity(e => + modelBuilder.Entity(e => { e.HasKey(x => x.Id); - e.Property(x => x.ExecutorEmail).HasMaxLength(256).IsRequired(); - e.Property(x => x.Position).HasMaxLength(128).IsRequired(); - e.Property(x => x.ProjectRole).HasConversion().HasMaxLength(32).IsRequired(); + e.Property(x => x.ExecutorEmail).HasMaxLength(256); + e.Property(x => x.Position).HasMaxLength(128); + e.Property(x => x.ProjectRole).HasConversion().HasMaxLength(32); + e.Property(x => x.HeadType).HasConversion().HasMaxLength(32); e.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId); e.HasIndex(x => new { x.RouteId, x.OrderNo }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260722130455_InitialCreate.Designer.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.Designer.cs similarity index 73% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260722130455_InitialCreate.Designer.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.Designer.cs index 475d855..e977bc9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260722130455_InitialCreate.Designer.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApprovalRouteDbContext))] - [Migration("20260722130455_InitialCreate")] + [Migration("20260727103117_InitialCreate")] partial class InitialCreate { /// @@ -27,11 +27,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid") + .HasColumnType("integer") .HasColumnName("id"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("Name") .IsRequired() .HasMaxLength(256) @@ -50,13 +52,15 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("request_types", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid") + .HasColumnType("integer") .HasColumnName("id"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("ByProject") .HasColumnType("boolean") .HasColumnName("by_project"); @@ -65,12 +69,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("uuid") .HasColumnName("company_id"); - b.Property("DepartmentId") + b.Property("DepartmentId") .HasColumnType("uuid") .HasColumnName("department_id"); - b.Property("RequestTypeId") - .HasColumnType("uuid") + b.Property("RequestTypeId") + .HasColumnType("integer") .HasColumnName("request_type_id"); b.Property("RequiresControl") @@ -84,49 +88,54 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnName("route_type"); b.HasKey("Id") - .HasName("pk_routes"); + .HasName("pk_template_routes"); - b.HasIndex("RequestTypeId") - .HasDatabaseName("ix_routes_request_type_id"); + b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") + .IsUnique() + .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); - b.ToTable("routes", (string)null); + b.ToTable("template_routes", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid") + .HasColumnType("integer") .HasColumnName("id"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("AllowReassignToOtherApprovers") .HasColumnType("boolean") .HasColumnName("allow_reassign_to_other_approvers"); b.Property("ExecutorEmail") - .IsRequired() .HasMaxLength(256) .HasColumnType("character varying(256)") .HasColumnName("executor_email"); + b.Property("HeadType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + b.Property("OrderNo") .HasColumnType("integer") .HasColumnName("order_no"); b.Property("Position") - .IsRequired() .HasMaxLength(128) .HasColumnType("character varying(128)") .HasColumnName("position"); b.Property("ProjectRole") - .IsRequired() .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("project_role"); - b.Property("RouteId") - .HasColumnType("uuid") + b.Property("RouteId") + .HasColumnType("integer") .HasColumnName("route_id"); b.Property("Status") @@ -136,34 +145,34 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnName("status"); b.HasKey("Id") - .HasName("pk_route_steps"); + .HasName("pk_template_route_steps"); b.HasIndex("RouteId", "OrderNo") - .HasDatabaseName("ix_route_steps_route_id_order_no"); + .HasDatabaseName("ix_template_route_steps_route_id_order_no"); - b.ToTable("route_steps", (string)null); + b.ToTable("template_route_steps", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") .WithMany("Routes") .HasForeignKey("RequestTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_routes_request_types_request_type_id"); + .HasConstraintName("fk_template_routes_request_types_request_type_id"); b.Navigation("RequestType"); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => { - b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.Route", "Route") + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route") .WithMany("Steps") .HasForeignKey("RouteId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_route_steps_routes_route_id"); + .HasConstraintName("fk_template_route_steps_template_routes_route_id"); b.Navigation("Route"); }); @@ -173,7 +182,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.Navigation("Routes"); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.Navigation("Steps"); }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260722130455_InitialCreate.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.cs similarity index 60% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260722130455_InitialCreate.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.cs index 772cbab..bc8ec2b 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260722130455_InitialCreate.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.cs @@ -1,5 +1,6 @@ using System; using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -15,7 +16,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations name: "request_types", columns: table => new { - id = table.Column(type: "uuid", nullable: false), + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), page_url = table.Column(type: "character varying(512)", maxLength: 512, nullable: false) }, @@ -25,22 +27,23 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations }); migrationBuilder.CreateTable( - name: "routes", + name: "template_routes", columns: table => new { - id = table.Column(type: "uuid", nullable: false), + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), route_type = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), - request_type_id = table.Column(type: "uuid", nullable: false), - department_id = table.Column(type: "uuid", nullable: false), + request_type_id = table.Column(type: "integer", nullable: false), + department_id = table.Column(type: "uuid", nullable: true), company_id = table.Column(type: "uuid", nullable: false), by_project = table.Column(type: "boolean", nullable: false), requires_control = table.Column(type: "boolean", nullable: false) }, constraints: table => { - table.PrimaryKey("pk_routes", x => x.id); + table.PrimaryKey("pk_template_routes", x => x.id); table.ForeignKey( - name: "fk_routes_request_types_request_type_id", + name: "fk_template_routes_request_types_request_type_id", column: x => x.request_type_id, principalTable: "request_types", principalColumn: "id", @@ -48,48 +51,51 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations }); migrationBuilder.CreateTable( - name: "route_steps", + name: "template_route_steps", columns: table => new { - id = table.Column(type: "uuid", nullable: false), + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), order_no = table.Column(type: "integer", nullable: false), - executor_email = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), - position = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), - project_role = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + executor_email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + position = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + project_role = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + head_type = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), - route_id = table.Column(type: "uuid", nullable: false), + route_id = table.Column(type: "integer", nullable: false), allow_reassign_to_other_approvers = table.Column(type: "boolean", nullable: false) }, constraints: table => { - table.PrimaryKey("pk_route_steps", x => x.id); + table.PrimaryKey("pk_template_route_steps", x => x.id); table.ForeignKey( - name: "fk_route_steps_routes_route_id", + name: "fk_template_route_steps_template_routes_route_id", column: x => x.route_id, - principalTable: "routes", + principalTable: "template_routes", principalColumn: "id", onDelete: ReferentialAction.Cascade); }); migrationBuilder.CreateIndex( - name: "ix_route_steps_route_id_order_no", - table: "route_steps", + name: "ix_template_route_steps_route_id_order_no", + table: "template_route_steps", columns: new[] { "route_id", "order_no" }); migrationBuilder.CreateIndex( - name: "ix_routes_request_type_id", - table: "routes", - column: "request_type_id"); + name: "ix_template_routes_request_type_id_department_id_company_id_by", + table: "template_routes", + columns: new[] { "request_type_id", "department_id", "company_id", "by_project" }, + unique: true); } /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( - name: "route_steps"); + name: "template_route_steps"); migrationBuilder.DropTable( - name: "routes"); + name: "template_routes"); migrationBuilder.DropTable( name: "request_types"); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 26f31c4..5b2cea2 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -24,11 +24,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid") + .HasColumnType("integer") .HasColumnName("id"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("Name") .IsRequired() .HasMaxLength(256) @@ -47,13 +49,15 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("request_types", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid") + .HasColumnType("integer") .HasColumnName("id"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("ByProject") .HasColumnType("boolean") .HasColumnName("by_project"); @@ -62,12 +66,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("uuid") .HasColumnName("company_id"); - b.Property("DepartmentId") + b.Property("DepartmentId") .HasColumnType("uuid") .HasColumnName("department_id"); - b.Property("RequestTypeId") - .HasColumnType("uuid") + b.Property("RequestTypeId") + .HasColumnType("integer") .HasColumnName("request_type_id"); b.Property("RequiresControl") @@ -81,49 +85,54 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnName("route_type"); b.HasKey("Id") - .HasName("pk_routes"); + .HasName("pk_template_routes"); - b.HasIndex("RequestTypeId") - .HasDatabaseName("ix_routes_request_type_id"); + b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") + .IsUnique() + .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); - b.ToTable("routes", (string)null); + b.ToTable("template_routes", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid") + .HasColumnType("integer") .HasColumnName("id"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("AllowReassignToOtherApprovers") .HasColumnType("boolean") .HasColumnName("allow_reassign_to_other_approvers"); b.Property("ExecutorEmail") - .IsRequired() .HasMaxLength(256) .HasColumnType("character varying(256)") .HasColumnName("executor_email"); + b.Property("HeadType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + b.Property("OrderNo") .HasColumnType("integer") .HasColumnName("order_no"); b.Property("Position") - .IsRequired() .HasMaxLength(128) .HasColumnType("character varying(128)") .HasColumnName("position"); b.Property("ProjectRole") - .IsRequired() .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("project_role"); - b.Property("RouteId") - .HasColumnType("uuid") + b.Property("RouteId") + .HasColumnType("integer") .HasColumnName("route_id"); b.Property("Status") @@ -133,34 +142,34 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnName("status"); b.HasKey("Id") - .HasName("pk_route_steps"); + .HasName("pk_template_route_steps"); b.HasIndex("RouteId", "OrderNo") - .HasDatabaseName("ix_route_steps_route_id_order_no"); + .HasDatabaseName("ix_template_route_steps_route_id_order_no"); - b.ToTable("route_steps", (string)null); + b.ToTable("template_route_steps", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") .WithMany("Routes") .HasForeignKey("RequestTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_routes_request_types_request_type_id"); + .HasConstraintName("fk_template_routes_request_types_request_type_id"); b.Navigation("RequestType"); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => { - b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.Route", "Route") + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route") .WithMany("Steps") .HasForeignKey("RouteId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_route_steps_routes_route_id"); + .HasConstraintName("fk_template_route_steps_template_routes_route_id"); b.Navigation("Route"); }); @@ -170,7 +179,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.Navigation("Routes"); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.Navigation("Steps"); }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj index 79b63af..33b62bc 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj @@ -1,6 +1,5 @@ - @@ -12,6 +11,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs new file mode 100644 index 0000000..0a46479 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -0,0 +1,106 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +public sealed class HttpTnsuPortalClient( + HttpClient httpClient, + TnsuPortalTokenProvider tokenProvider, + IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient +{ + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + public async Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) + { + var employee = await GetAsync( + $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken); + if (employee is null) + return null; + + return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position); + } + + public async Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) + { + var employee = await GetAsync( + $"/Employee/ByPosition?position={Uri.EscapeDataString(position)}&company={Uri.EscapeDataString(company)}", cancellationToken); + + return employee?.Email; + } + + public async Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) + { + var manager = await GetAsync( + $"/Employee/Manager?email={Uri.EscapeDataString(email)}&managerType={Uri.EscapeDataString(managerType)}", cancellationToken); + + return manager?.ManagerEmail; + } + + public async Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) + { + var project = await GetAsync($"/Project/{projectId}", cancellationToken); + if (project is null) + return []; + + var executors = new List(); + AddIfPresent(executors, ProjectRole.ProjectManager, project.ProjectManager); + AddIfPresent(executors, ProjectRole.Planner, project.Planner); + AddIfPresent(executors, ProjectRole.ManagerHSE, project.ManagerHSE); + AddIfPresent(executors, ProjectRole.Security, project.Security); + + return executors; + } + + private async Task GetAsync(string requestUri, CancellationToken cancellationToken) where T : class + { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + await AuthorizeAsync(request, cancellationToken); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + } + + private static void AddIfPresent(List executors, ProjectRole role, string? email) + { + if (!string.IsNullOrWhiteSpace(email)) + executors.Add(new ProjectExecutorInfo(role, email)); + } + + private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); + if (!string.IsNullOrWhiteSpace(incoming)) + { + request.Headers.TryAddWithoutValidation("Authorization", incoming); + return; + } + + var token = await tokenProvider.GetAccessTokenAsync(cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + private sealed record EmployeeResponse( + [property: JsonPropertyName("email")] string? Email, + [property: JsonPropertyName("company")] string? Company, + [property: JsonPropertyName("companyId")] Guid? CompanyId, + [property: JsonPropertyName("departmentId")] Guid? DepartmentId, + [property: JsonPropertyName("position")] string? Position); + + private sealed record ManagerResponse( + [property: JsonPropertyName("managerEmail")] string? ManagerEmail); + + private sealed record ProjectResponse( + [property: JsonPropertyName("projectManager")] string? ProjectManager, + [property: JsonPropertyName("planner")] string? Planner, + [property: JsonPropertyName("managerHSE")] string? ManagerHSE, + [property: JsonPropertyName("security")] string? Security); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs new file mode 100644 index 0000000..4b5a5c6 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs @@ -0,0 +1,12 @@ +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +public interface ITnsuPortalClient +{ + Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken); + + Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken); + + Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken); + + Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs new file mode 100644 index 0000000..29fe375 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs @@ -0,0 +1,7 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position); + +public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs new file mode 100644 index 0000000..a0adf35 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs @@ -0,0 +1,11 @@ +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +public sealed class TnsuPortalOptions +{ + public string BaseUrl { get; set; } = "https://api.tnsu.kz"; + public string TenantId { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string Scope { get; set; } = string.Empty; + public string DirectorTansuEmail { get; set; } = "director.tansu@tnsu.kz"; +} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs new file mode 100644 index 0000000..2d5dcac --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs @@ -0,0 +1,42 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +public sealed class TnsuPortalTokenProvider(HttpClient httpClient, IOptions options) +{ + private string? _cachedToken; + private DateTimeOffset _expiresAt = DateTimeOffset.MinValue; + + public async Task GetAccessTokenAsync(CancellationToken cancellationToken) + { + if (_cachedToken is not null && DateTimeOffset.UtcNow < _expiresAt) + return _cachedToken; + + var opts = options.Value; + var response = await httpClient.PostAsync( + $"https://login.microsoftonline.com/{opts.TenantId}/oauth2/v2.0/token", + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = opts.ClientId, + ["client_secret"] = opts.ClientSecret, + ["scope"] = opts.Scope + }), + cancellationToken); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new InvalidOperationException("Не удалось получить токен для api.tnsu.kz."); + + _cachedToken = payload.AccessToken; + _expiresAt = DateTimeOffset.UtcNow.AddSeconds(payload.ExpiresIn - 60); + + return _cachedToken; + } + + private sealed record TokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("expires_in")] int ExpiresIn); +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs new file mode 100644 index 0000000..30ff46f --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs @@ -0,0 +1,125 @@ +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tnsu.ApprovalRoute.Application.RequestTypes; +using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : IClassFixture +{ + private readonly RequestTypeService _service = TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService(); + + [Fact(DisplayName = "Создание типа заявки и получение его по Id возвращает сохранённые данные")] + public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity() + { + // Action + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation"), CancellationToken.None); + + // Assert + created.IsSuccess.ShouldBeTrue(); + created.Value.Name.ShouldBe("Vacation"); + created.Value.PageUrl.ShouldBe("/vacation"); + + // Action + var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); + + // Assert + fetched.IsSuccess.ShouldBeTrue(); + fetched.Value.Id.ShouldBe(created.Value.Id); + fetched.Value.Name.ShouldBe("Vacation"); + } + + [Fact(DisplayName = "Получение несуществующего типа заявки возвращает неуспешный результат")] + public async Task GetAsync_UnknownId_ReturnsFailedResult() + { + // Action + var result = await _service.GetAsync(999999, CancellationToken.None); + + // Assert + result.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Обновление типа заявки изменяет имя и url страницы")] + public async Task UpdateAsync_ChangesFields() + { + // Arrange + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old"), CancellationToken.None); + + // Action + var updated = await _service.UpdateAsync( + created.Value.Id, new UpdateRequestTypeRequest("New name", "/new"), CancellationToken.None); + + // Assert + updated.IsSuccess.ShouldBeTrue(); + updated.Value.Name.ShouldBe("New name"); + updated.Value.PageUrl.ShouldBe("/new"); + } + + [Fact(DisplayName = "Обновление несуществующего типа заявки возвращает неуспешный результат")] + public async Task UpdateAsync_UnknownId_ReturnsFailedResult() + { + // Action + var result = await _service.UpdateAsync( + 999999, new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None); + + // Assert + result.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Патч типа заявки меняет только переданное поле, остальные остаются прежними")] + public async Task PatchAsync_UpdatesOnlyProvidedFields() + { + // Arrange + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original"), CancellationToken.None); + + // Action + var patched = await _service.PatchAsync( + created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null), CancellationToken.None); + + // Assert + patched.IsSuccess.ShouldBeTrue(); + patched.Value.Name.ShouldBe("Patched name"); + patched.Value.PageUrl.ShouldBe("/original"); + } + + [Fact(DisplayName = "Патч несуществующего типа заявки возвращает неуспешный результат")] + public async Task PatchAsync_UnknownId_ReturnsFailedResult() + { + // Action + var result = await _service.PatchAsync( + 999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null), CancellationToken.None); + + // Assert + result.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Удаление типа заявки убирает его из базы")] + public async Task DeleteAsync_RemovesEntity() + { + // Arrange + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp"), CancellationToken.None); + + // Action + var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); + + // Assert + deleted.IsSuccess.ShouldBeTrue(); + + var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); + fetched.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Список типов заявок включает ранее созданный тип")] + public async Task ListAsync_IncludesCreatedEntity() + { + // Arrange + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed"), CancellationToken.None); + + // Action + var list = await _service.ListAsync(CancellationToken.None); + + // Assert + list.IsSuccess.ShouldBeTrue(); + list.Value.ShouldContain(x => x.Id == created.Value.Id); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs new file mode 100644 index 0000000..c20f332 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -0,0 +1,400 @@ +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Shouldly; +using Tnsu.ApprovalRoute.Application.RequestTypes; +using Tnsu.ApprovalRoute.Application.TemplateRoutes; +using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; +using Tnsu.ApprovalRoute.Domain.Enums; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; +using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +public sealed class TemplateRouteServiceTests : IClassFixture +{ + private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + private static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + + private readonly string _connectionString; + private readonly ServiceProvider _services; + private readonly TemplateRouteService _service; + private readonly RequestTypeService _requestTypeService; + + public TemplateRouteServiceTests(PostgresDatabaseFixture fixture) + { + _connectionString = fixture.ConnectionString; + + var portalClient = new Mock(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); + portalClient + .Setup(x => x.GetProjectExecutorsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]); + + _services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + _service = _services.GetRequiredService(); + _requestTypeService = _services.GetRequiredService(); + } + + private async Task CreateRequestTypeAsync(string name) => + (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id; + + [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")] + public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync("Vacation"); + + // Action + var created = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), + CancellationToken.None); + + // Assert + created.IsSuccess.ShouldBeTrue(); + created.Value.RequestTypeId.ShouldBe(requestTypeId); + + var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); + fetched.IsSuccess.ShouldBeTrue(); + fetched.Value.CompanyId.ShouldBe(CompanyId); + } + + [Fact(DisplayName = "Создание маршрута с несуществующим типом заявки возвращает неуспешный результат")] + public async Task CreateAsync_UnknownRequestType_ReturnsFailedResult() + { + // Action + var result = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, 999999, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + // Assert + result.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Повторное создание маршрута с тем же типом заявки, отделом, компанией и признаком «по проекту» возвращает конфликт")] + public async Task CreateAsync_DuplicateRoute_ReturnsFailedResult() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync("Business trip"); + var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false); + var first = await _service.CreateAsync(request, CancellationToken.None); + first.IsSuccess.ShouldBeTrue(); + + // Action + var duplicate = await _service.CreateAsync(request, CancellationToken.None); + + // Assert + duplicate.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Обновление маршрута изменяет тип маршрута и признак необходимости контроля")] + public async Task UpdateAsync_ChangesFields() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync("Sick leave"); + var created = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + // Action + var updated = await _service.UpdateAsync( + created.Value.Id, + new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), + CancellationToken.None); + + // Assert + updated.IsSuccess.ShouldBeTrue(); + updated.Value.RouteType.ShouldBe(RouteType.Parallel); + updated.Value.RequiresControl.ShouldBeTrue(); + } + + [Fact(DisplayName = "Патч маршрута меняет только переданные поля, остальные остаются прежними")] + public async Task PatchAsync_UpdatesOnlyProvidedFields() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync("Patch route request"); + var created = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + // Action + var patched = await _service.PatchAsync( + created.Value.Id, + new PatchTemplateRouteRequest(RouteType.Parallel, DepartmentId: null, CompanyId: null, ByProject: null, RequiresControl: true), + CancellationToken.None); + + // Assert + patched.IsSuccess.ShouldBeTrue(); + patched.Value.RouteType.ShouldBe(RouteType.Parallel); + patched.Value.RequiresControl.ShouldBeTrue(); + patched.Value.CompanyId.ShouldBe(CompanyId); + patched.Value.DepartmentId.ShouldBe(DepartmentId); + } + + [Fact(DisplayName = "Удаление маршрута убирает его из базы")] + public async Task DeleteAsync_RemovesEntity() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync("Remote work"); + var created = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + // Action + var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); + + // Assert + deleted.IsSuccess.ShouldBeTrue(); + + var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); + fetched.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Резолв маршрута по точному совпадению отдела возвращает шаги с нужным статусом")] + public async Task ResolveAsync_ExactDepartmentMatch_ReturnsMatchingStepsForStatus() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync("Exact match request"); + var route = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), + CancellationToken.None); + + var stepService = _services.GetRequiredService(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.RouteId.ShouldBe(route.Value.Id); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz"); + } + + [Fact(DisplayName = "Резолв маршрута схлопывает шаги, резолвящиеся в одинаковый email, оставляя шаг с меньшим OrderNo")] + public async Task ResolveAsync_StepsResolveToSameEmail_CollapsesToLowestOrderNo() + { + // Arrange + var portalClient = new Mock(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); + portalClient + .Setup(x => x.GetManagerAsync(It.IsAny(), "chief", It.IsAny())) + .ReturnsAsync("dup@tnsu.kz"); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + var requestTypeId = (await requestTypeService.CreateAsync( + new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates"), CancellationToken.None)).Value.Id; + var route = await routeService.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Action + var resolved = await routeService.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.Count.ShouldBe(2); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("dup@tnsu.kz"); + resolved.Value.Steps[0].OrderNo.ShouldBe(1); + resolved.Value.Steps[1].ExecutorEmail.ShouldBe("other@tnsu.kz"); + resolved.Value.Steps[1].OrderNo.ShouldBe(2); + } + + [Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")] + public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync("Project request"); + var route = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false), + CancellationToken.None); + + var stepService = _services.GetRequiredService(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", Guid.NewGuid(), RouteStepStatus.Approval), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("pm@tnsu.kz"); + } + + [Fact(DisplayName = "Резолв шага с должностью подставляет email сотрудника, найденного по должности и компании инициатора")] + public async Task ResolveAsync_ByPosition_UsesEmployeeFoundByPositionAndCompany() + { + // Arrange + var portalClient = new Mock(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer")); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny())) + .ReturnsAsync("lead@tnsu.kz"); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + var requestTypeId = (await requestTypeService.CreateAsync( + new CreateRequestTypeRequest("Position request", "/position-request"), CancellationToken.None)).Value.Id; + var route = await routeService.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Action + var resolved = await routeService.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("lead@tnsu.kz"); + resolved.Value.Steps[0].Position.ShouldBe("Lead Engineer"); + } + + [Fact(DisplayName = "Резолв шага с типом руководителя Chief подставляет email руководителя из api.tnsu.kz")] + public async Task ResolveAsync_ByHeadTypeChief_UsesManagerEmail() + { + // Arrange + var portalClient = new Mock(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position")); + portalClient + .Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny())) + .ReturnsAsync("chief@tnsu.kz"); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + var requestTypeId = (await requestTypeService.CreateAsync( + new CreateRequestTypeRequest("Chief request", "/chief-request"), CancellationToken.None)).Value.Id; + var route = await routeService.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Action + var resolved = await routeService.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("chief@tnsu.kz"); + } + + [Fact(DisplayName = "Резолв шага с типом руководителя DirectorTansu подставляет захардкоженный email из настроек")] + public async Task ResolveAsync_ByHeadTypeDirectorTansu_UsesConfiguredEmail() + { + // Arrange + var portalClient = new Mock(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position")); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + var requestTypeId = (await requestTypeService.CreateAsync( + new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request"), CancellationToken.None)).Value.Id; + var route = await routeService.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Action + var resolved = await routeService.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("director.tansu@tnsu.kz"); + portalClient.Verify(x => x.GetManagerAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")] + public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync("Company-wide request"); + var route = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.RouteId.ShouldBe(route.Value.Id); + } + + [Fact(DisplayName = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")] + public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult() + { + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(999999, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + // Assert + resolved.IsFailed.ShouldBeTrue(); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs new file mode 100644 index 0000000..02ea273 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -0,0 +1,270 @@ +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tnsu.ApprovalRoute.Application.RequestTypes; +using Tnsu.ApprovalRoute.Application.TemplateRoutes; +using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; +using Tnsu.ApprovalRoute.Domain.Enums; +using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +public sealed class TemplateRouteStepServiceTests : IClassFixture +{ + private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + + private readonly TemplateRouteStepService _service; + private readonly TemplateRouteService _routeService; + private readonly RequestTypeService _requestTypeService; + + public TemplateRouteStepServiceTests(PostgresDatabaseFixture fixture) + { + var services = TestServiceProviderFactory.Build(fixture.ConnectionString); + _service = services.GetRequiredService(); + _routeService = services.GetRequiredService(); + _requestTypeService = services.GetRequiredService(); + } + + private async Task CreateRouteAsync(string requestTypeName, bool byProject) + { + var requestTypeId = (await _requestTypeService.CreateAsync( + new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id; + + var route = await _routeService.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false), + CancellationToken.None); + + return route.Value.Id; + } + + [Fact(DisplayName = "Создание шага для несуществующего маршрута возвращает неуспешный результат")] + public async Task CreateAsync_UnknownRoute_ReturnsFailedResult() + { + // Action + var result = await _service.CreateAsync( + 999999, + new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Assert + result.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Роль в проекте недопустима для шага маршрута без признака «по проекту»")] + public async Task CreateAsync_ProjectRoleOnNonProjectRoute_ReturnsFailedResult() + { + // Arrange + var routeId = await CreateRouteAsync("Non project route", byProject: false); + + // Action + var result = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Assert + result.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Повторное создание шага с теми же исполнителем, должностью, ролью, типом руководителя и статусом возвращает конфликт")] + public async Task CreateAsync_DuplicateStep_ReturnsFailedResult() + { + // Arrange + var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false); + var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false); + var first = await _service.CreateAsync(routeId, request, CancellationToken.None); + first.IsSuccess.ShouldBeTrue(); + + // Action + var duplicate = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true), + CancellationToken.None); + + // Assert + duplicate.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Создание шага только с типом руководителя (без исполнителя, должности и роли) успешно")] + public async Task CreateAsync_HeadTypeOnly_ReturnsCreatedStep() + { + // Arrange + var routeId = await CreateRouteAsync("Head type only route", byProject: false); + + // Action + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Assert + created.IsSuccess.ShouldBeTrue(); + created.Value.HeadType.ShouldBe(HeadType.Chief); + created.Value.ExecutorEmail.ShouldBeNull(); + } + + [Fact(DisplayName = "Создание шага маршрута и получение его по Id возвращает сохранённые данные")] + public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep() + { + // Arrange + var routeId = await CreateRouteAsync("Simple route", byProject: false); + + // Action + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Assert + created.IsSuccess.ShouldBeTrue(); + + var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None); + fetched.IsSuccess.ShouldBeTrue(); + fetched.Value.ExecutorEmail.ShouldBe("a@b.kz"); + } + + [Fact(DisplayName = "Создание шага без роли в проекте на маршруте «по проекту» возвращает предупреждение")] + public async Task CreateAsync_ByProjectRouteWithoutProjectRoleStep_ReturnsWarning() + { + // Arrange + var routeId = await CreateRouteAsync("By-project route without role", byProject: true); + + // Action + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Assert + created.IsSuccess.ShouldBeTrue(); + created.Value.Warning.ShouldNotBeNull(); + created.Value.Warning.ShouldContain("ролью в проекте"); + } + + [Fact(DisplayName = "Создание шага с ролью в проекте на маршруте «по проекту» не возвращает предупреждение")] + public async Task CreateAsync_ByProjectRouteWithProjectRoleStep_HasNoWarning() + { + // Arrange + var routeId = await CreateRouteAsync("By-project route with role", byProject: true); + + // Action + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Assert + created.IsSuccess.ShouldBeTrue(); + created.Value.Warning.ShouldBeNull(); + } + + [Fact(DisplayName = "Обновление шага маршрута изменяет исполнителя, статус и разрешение на переназначение")] + public async Task UpdateAsync_ChangesFields() + { + // Arrange + var routeId = await CreateRouteAsync("Route to update step", byProject: false); + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Action + var updated = await _service.UpdateAsync( + routeId, + created.Value.Id, + new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true), + CancellationToken.None); + + // Assert + updated.IsSuccess.ShouldBeTrue(); + updated.Value.ExecutorEmail.ShouldBe("b@b.kz"); + updated.Value.Status.ShouldBe(RouteStepStatus.Execution); + updated.Value.AllowReassignToOtherApprovers.ShouldBeTrue(); + } + + [Fact(DisplayName = "Патч шага маршрута меняет только переданные поля, остальные остаются прежними")] + public async Task PatchAsync_UpdatesOnlyProvidedFields() + { + // Arrange + var routeId = await CreateRouteAsync("Route to patch step", byProject: false); + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Action + var patched = await _service.PatchAsync( + routeId, + created.Value.Id, + new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null), + CancellationToken.None); + + // Assert + patched.IsSuccess.ShouldBeTrue(); + patched.Value.ExecutorEmail.ShouldBe("a@b.kz"); + patched.Value.OrderNo.ShouldBe(2); + patched.Value.Status.ShouldBe(RouteStepStatus.Execution); + } + + [Fact(DisplayName = "Патч, после которого заполнено больше одного из полей исполнителя, возвращает неуспешный результат")] + public async Task PatchAsync_ResultsInMoreThanOneFilledField_ReturnsFailedResult() + { + // Arrange + var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false); + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Action + var patched = await _service.PatchAsync( + routeId, + created.Value.Id, + new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null), + CancellationToken.None); + + // Assert + patched.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")] + public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled() + { + // Arrange + var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false); + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Action + var patched = await _service.PatchAsync( + routeId, + created.Value.Id, + new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null), + CancellationToken.None); + + // Assert + patched.IsSuccess.ShouldBeTrue(); + patched.Value.ExecutorEmail.ShouldBe("a@b.kz"); + } + + [Fact(DisplayName = "Удаление шага маршрута убирает его из базы")] + public async Task DeleteAsync_RemovesStep() + { + // Arrange + var routeId = await CreateRouteAsync("Route to delete step", byProject: false); + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Action + var deleted = await _service.DeleteAsync(routeId, created.Value.Id, CancellationToken.None); + + // Assert + deleted.IsSuccess.ShouldBeTrue(); + + var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None); + fetched.IsFailed.ShouldBeTrue(); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/PostgresDatabaseFixture.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/PostgresDatabaseFixture.cs new file mode 100644 index 0000000..e82c1dc --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/PostgresDatabaseFixture.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Xunit; + +namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +public sealed class PostgresDatabaseFixture : IAsyncLifetime +{ + private readonly string _databaseName = $"tnsu_test_{Guid.NewGuid():N}"; + private readonly NpgsqlConnectionStringBuilder _adminConnectionBuilder = new(TestSettings.BaseConnectionString); + + public string ConnectionString { get; private set; } = string.Empty; + + public async Task InitializeAsync() + { + await using (var adminConnection = new NpgsqlConnection(_adminConnectionBuilder.ConnectionString)) + { + await adminConnection.OpenAsync(); + await using var createCommand = new NpgsqlCommand($"CREATE DATABASE \"{_databaseName}\"", adminConnection); + await createCommand.ExecuteNonQueryAsync(); + } + + ConnectionString = new NpgsqlConnectionStringBuilder(_adminConnectionBuilder.ConnectionString) + { + Database = _databaseName + }.ConnectionString; + + var options = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString) + .UseSnakeCaseNamingConvention() + .Options; + + await using var db = new ApprovalRouteDbContext(options); + await db.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + await using var adminConnection = new NpgsqlConnection(_adminConnectionBuilder.ConnectionString); + await adminConnection.OpenAsync(); + + await using (var terminateCommand = new NpgsqlCommand( + """ + SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = @databaseName AND pid <> pg_backend_pid(); + """, + adminConnection)) + { + terminateCommand.Parameters.AddWithValue("databaseName", _databaseName); + await terminateCommand.ExecuteNonQueryAsync(); + } + + await using var dropCommand = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{_databaseName}\"", adminConnection); + await dropCommand.ExecuteNonQueryAsync(); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestCurrentUser.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestCurrentUser.cs new file mode 100644 index 0000000..020d450 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestCurrentUser.cs @@ -0,0 +1,8 @@ +using Tnsu.ApprovalRoute.Application.Common.Interfaces; + +namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +internal sealed class TestCurrentUser(string? email = null) : ICurrentUser +{ + public string? Email { get; } = email; +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs new file mode 100644 index 0000000..7461499 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Tnsu.ApprovalRoute.Application; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; +using Tnsu.ApprovalRoute.Infrastructure; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +internal static class TestServiceProviderFactory +{ + public static ServiceProvider Build( + string connectionString, + string? currentUserEmail = "initiator@tnsu.kz", + ITnsuPortalClient? portalClient = null) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Postgres"] = connectionString + }) + .Build(); + + var services = new ServiceCollection(); + services.AddApprovalRouteInfrastructure(configuration); + services.AddApprovalRouteApplication(); + services.AddSingleton(new TestCurrentUser(currentUserEmail)); + + if (portalClient is not null) + services.AddSingleton(portalClient); + + return services.BuildServiceProvider(); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestSettings.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestSettings.cs new file mode 100644 index 0000000..da4f2dc --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestSettings.cs @@ -0,0 +1,8 @@ +namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +internal static class TestSettings +{ + public static string BaseConnectionString => + Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION") + ?? "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"; +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj new file mode 100644 index 0000000..4b9680b --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj @@ -0,0 +1,27 @@ + + + + false + + + + + + + + + + + + + + + + + + + + + + +