апи для работы с маршрутами согласования (#2)
Tests / test (push) Successful in 5m45s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/2
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tnsu.kz>
Co-committed-by: gorbenko.pavel <gorbenko.pavel@tnsu.kz>
This commit was merged in pull request #2.
This commit is contained in:
2026-07-27 13:14:32 +00:00
committed by admin
parent bc05df3f73
commit 602554ac64
59 changed files with 2515 additions and 152 deletions
@@ -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<ApprovalRouteDbContext>(opt =>
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
.UseSnakeCaseNamingConvention());
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
services.AddHttpClient<TnsuPortalTokenProvider>();
services.AddHttpClient<ITnsuPortalClient, HttpTnsuPortalClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
});
return services;
}
}
@@ -6,8 +6,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
{
public DbSet<RequestType> RequestTypes => Set<RequestType>();
public DbSet<Route> Routes => Set<Route>();
public DbSet<RouteStep> RouteSteps => Set<RouteStep>();
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -18,19 +18,21 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired();
});
modelBuilder.Entity<Route>(e =>
modelBuilder.Entity<TemplateRoute>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.RouteType).HasConversion<string>().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<RouteStep>(e =>
modelBuilder.Entity<TemplateRouteStep>(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<string>().HasMaxLength(32).IsRequired();
e.Property(x => x.ExecutorEmail).HasMaxLength(256);
e.Property(x => x.Position).HasMaxLength(128);
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
e.HasIndex(x => new { x.RouteId, x.OrderNo });
@@ -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
{
/// <inheritdoc />
@@ -27,11 +27,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Property<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("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<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
@@ -65,12 +69,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid>("DepartmentId")
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<Guid>("RequestTypeId")
.HasColumnType("uuid")
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("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<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
b.Property<string>("ExecutorEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("executor_email");
b.Property<string>("HeadType")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<string>("Position")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("position");
b.Property<string>("ProjectRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<Guid>("RouteId")
.HasColumnType("uuid")
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<string>("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");
});
@@ -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<Guid>(type: "uuid", nullable: false),
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
page_url = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
route_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
request_type_id = table.Column<Guid>(type: "uuid", nullable: false),
department_id = table.Column<Guid>(type: "uuid", nullable: false),
request_type_id = table.Column<int>(type: "integer", nullable: false),
department_id = table.Column<Guid>(type: "uuid", nullable: true),
company_id = table.Column<Guid>(type: "uuid", nullable: false),
by_project = table.Column<bool>(type: "boolean", nullable: false),
requires_control = table.Column<bool>(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<Guid>(type: "uuid", nullable: false),
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
order_no = table.Column<int>(type: "integer", nullable: false),
executor_email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
project_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
executor_email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
project_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
head_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
route_id = table.Column<Guid>(type: "uuid", nullable: false),
route_id = table.Column<int>(type: "integer", nullable: false),
allow_reassign_to_other_approvers = table.Column<bool>(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);
}
/// <inheritdoc />
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");
@@ -24,11 +24,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Property<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("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<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
@@ -62,12 +66,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid>("DepartmentId")
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<Guid>("RequestTypeId")
.HasColumnType("uuid")
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("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<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
b.Property<string>("ExecutorEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("executor_email");
b.Property<string>("HeadType")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<string>("Position")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("position");
b.Property<string>("ProjectRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<Guid>("RouteId")
.HasColumnType("uuid")
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<string>("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");
});
@@ -1,6 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
</ItemGroup>
<ItemGroup>
@@ -12,6 +11,9 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<!-- Даёт доступ к IHttpContextAccessor из общего фреймворка ASP.NET Core (тот же тип, что в Api). -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -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<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
{
var employee = await GetAsync<EmployeeResponse>(
$"/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<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
{
var employee = await GetAsync<EmployeeResponse>(
$"/Employee/ByPosition?position={Uri.EscapeDataString(position)}&company={Uri.EscapeDataString(company)}", cancellationToken);
return employee?.Email;
}
public async Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken)
{
var manager = await GetAsync<ManagerResponse>(
$"/Employee/Manager?email={Uri.EscapeDataString(email)}&managerType={Uri.EscapeDataString(managerType)}", cancellationToken);
return manager?.ManagerEmail;
}
public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
{
var project = await GetAsync<ProjectResponse>($"/Project/{projectId}", cancellationToken);
if (project is null)
return [];
var executors = new List<ProjectExecutorInfo>();
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<T?> GetAsync<T>(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<T>(JsonOptions, cancellationToken);
}
private static void AddIfPresent(List<ProjectExecutorInfo> 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);
}
@@ -0,0 +1,12 @@
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public interface ITnsuPortalClient
{
Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
}
@@ -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);
@@ -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";
}
@@ -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<TnsuPortalOptions> options)
{
private string? _cachedToken;
private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
public async Task<string> 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<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = opts.ClientId,
["client_secret"] = opts.ClientSecret,
["scope"] = opts.Scope
}),
cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<TokenResponse>(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);
}