Sla (#11)
Tests / backend-test (push) Successful in 1m50s
Tests / frontend-test (push) Successful in 49s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/11
This commit was merged in pull request #11.
This commit is contained in:
2026-08-13 04:44:08 +00:00
parent 7f2aabbf29
commit d076f4e98d
103 changed files with 2606 additions and 1265 deletions
@@ -0,0 +1,121 @@
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);
AddIfPresent(executors, ProjectRole.Keeper, project.Keeper);
return executors;
}
// Ответ — {"managerEmail": "..."} (та же форма, что у /Employee/Manager),
// а НЕ форма сотрудника (EmployeeResponse) — проверено живым вызовом
// (GET /Employee/DepartmentHead?departmentId=... на отделе "АУП"/"ТОО
// Монтажник-Т" вернул именно managerEmail). 404 с телом {"error": "..."}
// на отделах без руководителя уже отфильтровывается общим GetAsync<T>.
public async Task<string?> GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken)
{
var manager = await GetAsync<ManagerResponse>(
$"/Employee/DepartmentHead?departmentId={Uri.EscapeDataString(departmentId.ToString())}", cancellationToken);
return manager?.ManagerEmail;
}
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,
[property: JsonPropertyName("keeper")] string? Keeper);
}
@@ -0,0 +1,15 @@
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);
/// <summary>Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден.</summary>
Task<string?> GetDepartmentHeadEmailAsync(Guid departmentId, 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);
}