From 82512eb1db95892c451d2c3015d75d9604bc06e5 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 19 Aug 2026 09:08:36 +0000 Subject: [PATCH] =?UTF-8?q?=D0=90=D1=80=D1=85=D0=B8=D0=B2=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=BE=D0=B2=20(#1?= =?UTF-8?q?3)?= 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/13 --- .gitignore | 4 +- backend/Directory.Packages.props | 14 + .../Auth/HangfireDashboardBasicAuthFilter.cs | 69 ++++ .../Controllers/RequestTypeListsController.cs | 47 +++ backend/Tnsu.ApprovalRoute.Api/Program.cs | 40 ++- .../Tnsu.ApprovalRoute.Api.csproj | 5 + .../Tnsu.ApprovalRoute.Api/appsettings.json | 25 +- .../ApplicationServiceCollectionExtensions.cs | 5 + .../CreateNextListPartitionJob.cs | 148 +++++++++ .../ListPartitioning/ListPartitionDto.cs | 35 ++ .../ListPartitioning/ListPartitionRegistry.cs | 123 +++++++ .../ListPartitioning/ListPartitionService.cs | 170 ++++++++++ .../ListPartitioningOptions.cs | 31 ++ .../Reminders/ReminderService.cs | 52 ++- .../RequestTypes/RequestTypeService.cs | 8 +- .../TemplateRouteStepService.cs | 13 +- .../TemplateRoutes/TemplateRouteService.cs | 15 +- .../Tnsu.ApprovalRoute.Application.csproj | 1 + .../Entities/RequestListPartition.cs | 47 +++ .../Entities/RequestType.cs | 18 + ...frastructureServiceCollectionExtensions.cs | 17 + .../Persistence/ApprovalRouteDbContext.cs | 13 + .../Persistence/ListPartitioningLock.cs | 51 +++ ...0817102423_AddListPartitioning.Designer.cs | 314 ++++++++++++++++++ .../20260817102423_AddListPartitioning.cs | 62 ++++ .../ApprovalRouteDbContextModelSnapshot.cs | 60 ++++ .../SharePoint/ISharePointListProvisioner.cs | 27 ++ .../SharePoint/ListTitleConvention.cs | 54 +++ .../SharePoint/SharePointClient.cs | 187 ++++++++++- .../SharePoint/SharePointListProvisioner.cs | 174 ++++++++++ .../SharePoint/SharePointOptions.cs | 11 + .../TnsuPortal/CachingTnsuPortalClient.cs | 2 +- .../HttpSharePointWebhookSubscriber.cs | 94 ++++++ .../TnsuPortal/HttpTnsuPortalClient.cs | 27 +- .../ISharePointWebhookSubscriber.cs | 16 + .../TnsuPortal/ITnsuPortalClient.cs | 2 +- .../TnsuPortal/TnsuPortalAuthorizer.cs | 39 +++ .../TnsuPortal/TnsuPortalCallerToken.cs | 37 +++ .../TnsuPortal/TnsuPortalDtos.cs | 3 + .../ListPartitionRegistryTests.cs | 137 ++++++++ .../ListPartitioningLockTests.cs | 120 +++++++ .../ReminderServiceTests.cs | 2 + .../SharePointWebhookSubscriberTests.cs | 189 +++++++++++ .../TemplateRouteServiceTests.cs | 5 +- .../TestServiceProviderFactory.cs | 1 + .../approvalAdmin/ApprovalAdminWebPart.ts | 22 +- .../components/screens/DashboardScreen.tsx | 17 + .../screens/ManageRequestsScreen.module.scss | 15 + .../screens/ManageRequestsScreen.tsx | 7 + .../hooks/useRequestTypeListsAll.ts | 10 + .../webparts/approvalAdmin/models/index.ts | 1 + .../approvalAdmin/models/requestManage.ts | 7 + .../approvalAdmin/models/requestTypeLists.ts | 16 + .../webparts/approvalAdmin/services/api.ts | 10 +- .../services/requestManageService.ts | 13 +- frontend/src/webparts/approvalAdmin/texts.ts | 6 + 56 files changed, 2587 insertions(+), 51 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Api/Auth/HangfireDashboardBasicAuthFilter.cs create mode 100644 backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionDto.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitioningOptions.cs create mode 100644 backend/Tnsu.ApprovalRoute.Domain/Entities/RequestListPartition.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ListPartitioningLock.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.Designer.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ISharePointListProvisioner.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ListTitleConvention.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpSharePointWebhookSubscriber.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ISharePointWebhookSubscriber.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalAuthorizer.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalCallerToken.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitionRegistryTests.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitioningLockTests.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/SharePointWebhookSubscriberTests.cs create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useRequestTypeListsAll.ts create mode 100644 frontend/src/webparts/approvalAdmin/models/requestTypeLists.ts diff --git a/.gitignore b/.gitignore index 4f8ee7c..b969f7a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,6 @@ frontend/.idea/ workspace.xml **/bin/ **/obj/ -.DS_Store \ No newline at end of file +.DS_Store +logs/ +appsettings.Local.json \ No newline at end of file diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 0ee4fae..52a390c 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -1,6 +1,12 @@ true + + true @@ -9,6 +15,9 @@ + + + @@ -18,8 +27,13 @@ + + + + + diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/HangfireDashboardBasicAuthFilter.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/HangfireDashboardBasicAuthFilter.cs new file mode 100644 index 0000000..98613de --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Auth/HangfireDashboardBasicAuthFilter.cs @@ -0,0 +1,69 @@ +using System.Security.Cryptography; +using System.Text; +using Hangfire.Dashboard; +using Microsoft.Net.Http.Headers; + +namespace Tnsu.ApprovalRoute.Api.Auth; + +/// +/// Простая Basic Auth для дашборда Hangfire — единственный логин/пароль из +/// конфигурации (Hangfire:Dashboard), а не полноценная схема аутентификации: сам +/// дашборд не участвует в остальной авторизации API (это отдельная страница, +/// открываемая прямо в браузере, не по JWT). +/// +/// Пустые логин/пароль (не заданы в конфигурации) означают «дашборд закрыт для +/// всех» — не «открыт для всех»: пустая строка в конфиге не должна случайно снять +/// защиту. +/// +public sealed class HangfireDashboardBasicAuthFilter(string username, string password) : IDashboardAuthorizationFilter +{ + public bool Authorize(DashboardContext context) + { + var httpContext = context.GetHttpContext(); + + if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password) && + TryGetBasicAuthCredentials(httpContext.Request.Headers.Authorization, out var user, out var pass) && + FixedTimeEquals(user, username) && FixedTimeEquals(pass, password)) + { + return true; + } + + httpContext.Response.Headers[HeaderNames.WWWAuthenticate] = "Basic realm=\"Hangfire\""; + httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; + return false; + } + + private static bool TryGetBasicAuthCredentials( + string? authorizationHeader, out string username, out string password) + { + username = string.Empty; + password = string.Empty; + + if (string.IsNullOrEmpty(authorizationHeader) || + !authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + return false; + + try + { + var decoded = Encoding.UTF8.GetString( + Convert.FromBase64String(authorizationHeader["Basic ".Length..].Trim())); + var separatorIndex = decoded.IndexOf(':'); + if (separatorIndex < 0) return false; + + username = decoded[..separatorIndex]; + password = decoded[(separatorIndex + 1)..]; + return true; + } + catch (FormatException) + { + // Битый base64 в заголовке — не наша забота, просто не авторизуем. + return false; + } + } + + // Сравнение за постоянное время: длина логина/пароля в Basic Auth и так видна + // по заголовку, но посимвольное сравнение через == утекало бы ЕЩЁ и через + // тайминг совпадения символов — дешёво закрыть и это. + private static bool FixedTimeEquals(string a, string b) => + CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b)); +} diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs new file mode 100644 index 0000000..2e54a01 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.ListPartitioning; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +/// +/// Нумерованная серия партиций SharePoint-списка по типу заявок: куда писать сейчас +/// и вся серия для админки/портала. +/// +[ApiController] +[Authorize] +[Route("api/request-types")] +[Produces("application/json")] +public sealed class RequestTypeListsController(ListPartitionService service) : ControllerBase +{ + /// + /// Серии партиций ВСЕХ типов заявок разом — для портала: чтобы искать по + /// заявкам сразу во всех партициях (архив + текущая) любого типа, не нужно + /// запрашивать серию по каждому типу отдельно. + /// + [HttpGet("lists")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task ListAll(CancellationToken ct) => + (await service.ListAllAsync(ct)).ToActionResult(); + + /// Вся серия партиций одного типа заявок (архив + текущая), от первой к последней. + [HttpGet("{requestTypeId:int}/lists")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + public async Task List(int requestTypeId, CancellationToken ct) => + (await service.ListAsync(requestTypeId, ct)).ToActionResult(); + + /// + /// Партиция, в которую нужно писать новые заявки этого типа. + /// + /// Не чистый GET: если партиция добрала порог элементов, запрос ставит в очередь + /// фоновую задачу создания следующей (см. ListPartitionService.GetCurrentAsync). + /// + [HttpGet("{requestTypeId:int}/lists/current")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + public async Task Current(int requestTypeId, CancellationToken ct) => + (await service.GetCurrentAsync(requestTypeId, ct)).ToActionResult(); +} diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs index 178f2f3..2390684 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs @@ -1,14 +1,18 @@ using System.Text.Json.Serialization; using Extensions.Hosting.AsyncInitialization; +using Hangfire; +using Hangfire.PostgreSql; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.OpenApi; using Scalar.AspNetCore; +using Serilog; using Tnsu.ApprovalRoute.Api.Auth; using Tnsu.ApprovalRoute.Api.Filters; using Tnsu.ApprovalRoute.Application; +using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Infrastructure; @@ -17,11 +21,25 @@ const string CorsPolicyName = "Frontend"; var builder = WebApplication.CreateBuilder(args); +// Локальные оверрайды, не попадающие в git (см. .gitignore) — на своей машине +// можно, например, занизить ListPartitioning:ItemCountThreshold, чтобы не +// накапливать тысячи тестовых заявок для проверки создания партиций. +builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); + +builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .Enrich.FromLogContext() + .WriteTo.Console() + .WriteTo.File( + "logs/log-.txt", + rollingInterval: RollingInterval.Month, + retainedFileCountLimit: 6)); + builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - + options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); @@ -37,6 +55,16 @@ builder.Services.AddCors(options => builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteApplication(); builder.Services.Configure(builder.Configuration.GetSection("Reminders")); +builder.Services.Configure(builder.Configuration.GetSection("ListPartitioning")); + +builder.Services.AddHangfire(configuration => configuration + .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UsePostgreSqlStorage(postgres => + postgres.UseNpgsqlConnection(builder.Configuration.GetConnectionString("Postgres")))); + +builder.Services.AddHangfireServer(); builder.Services.AddApprovalRouteAuth(builder.Configuration); builder.Services.AddScoped(); builder.Services.AddOpenApi(options => @@ -92,6 +120,16 @@ app.UseCors(CorsPolicyName); app.UseAuthentication(); app.UseAuthorization(); +app.UseHangfireDashboard("/hangfire", new DashboardOptions +{ + Authorization = + [ + new HangfireDashboardBasicAuthFilter( + app.Configuration["Hangfire:Dashboard:Username"] ?? string.Empty, + app.Configuration["Hangfire:Dashboard:Password"] ?? string.Empty) + ] +}); + app.MapControllers(); await app.InitAndRunAsync(); diff --git a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj index da2a380..ac24261 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj +++ b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj @@ -5,6 +5,8 @@ + + @@ -12,5 +14,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index e01d0f1..d6fc1ee 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -1,8 +1,13 @@ { - "Logging": { - "LogLevel": { + "Serilog": { + "MinimumLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Hangfire": "Warning", + "System.Net.Http.HttpClient": "Warning" + } } }, "ConnectionStrings": { @@ -19,11 +24,23 @@ "https://localhost:4321" ] }, + "ListPartitioning": { + "ItemCountThreshold": 4900, + "StaleLockAfter": "00:30:00", + "CheckThresholdOnRead": true + }, + "Hangfire": { + "Dashboard": { + "Username": "admin", + "Password": "RzaT2krczbRpE36QAucdCXR" + } + }, "SharePoint": { "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", "ClientId": "9232d9e2-9807-4753-808f-07250e5474b0", "CertificateSettingName": "AzurePfx", - "CertificatePassword": "Rk.xDjccnfyjdktybz1" + "CertificatePassword": "Rk.xDjccnfyjdktybz1", + "NotificationUrl": "https://api.tnsu.kz/Webhook/SharePoint" }, "TnsuPortal": { "BaseUrl": "https://api.tnsu.kz", diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index 3e5732c..0a35929 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using System.Reflection; using FluentValidation; using Microsoft.Extensions.DependencyInjection; using Tnsu.ApprovalRoute.Application.Reminders; +using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Application.TemplateRoutes; using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; @@ -14,6 +15,10 @@ public static class ApplicationServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Джоба резолвится Hangfire'ом из scope на каждое выполнение. + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs new file mode 100644 index 0000000..06494c1 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs @@ -0,0 +1,148 @@ +using System.Diagnostics; +using Hangfire; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.Application.ListPartitioning; + +/// +/// Фоновая задача: создать следующую партицию списка по типу заявок, записать её в +/// реестр и переключить на неё запись. +/// +/// Блокировка захватывается ЗДЕСЬ, при каждом запуске (в т.ч. на каждой повторной +/// попытке Hangfire), а не в момент постановки в очередь. Enqueue и фактическое +/// выполнение — разные события во времени: задача может полежать в очереди, быть +/// повторена автоматически после сбоя, в теории — перезапущена вручную из дашборда. +/// Всё это заново становится тем же самым «выполнением» с точки зрения Hangfire, но +/// не с точки зрения блокировки: settled только выполнение может её удержать, и +/// проверять это надо на каждом фактическом входе в код, создающий список в +/// SharePoint, а не один раз на входе в очередь. Иначе, к примеру, зависший на +/// повторе процесс и новый независимый триггер (например, второй HTTP-запрос, +/// поймавший тот же порог до переключения списка) синхронизировались бы неверно. +/// +/// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список +/// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя +/// безопасен и не создаёт вторую партицию. Больше трёх попыток смысла нет — если +/// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire, +/// а не молотить впустую. +/// +[AutomaticRetry(Attempts = 3)] +public sealed class CreateNextListPartitionJob( + ApprovalRouteDbContext db, + ListPartitioningLock provisioningLock, + ListPartitionRegistry registry, + ISharePointListProvisioner provisioner, + TnsuPortalCallerToken callerToken, + IOptions options, + ILogger logger) +{ + /// + /// Заголовок Authorization пользователя, чей запрос вызвал провижининг (см. + /// ListPartitionService.TryEnqueueProvisioningAsync). Нужен, чтобы подписать + /// новый список на уведомления от имени этого пользователя: своего HttpContext + /// у фоновой задачи нет, а client_credentials сейчас не настроен (см. + /// TnsuPortalAuthorizer). Провижининг занимает секунды, поэтому токен не + /// успевает истечь — но на ПОВТОРЕ Hangfire (минуты спустя) он уже может быть + /// просроченным: тогда подписка вернёт 401 и упадёт мягко, не сорвав создание + /// списка (см. ISharePointWebhookSubscriber). + /// + /// Учтите: как аргумент задачи он попадает в хранилище Hangfire (Postgres) и + /// виден в его дашборде. + /// + public async Task RunAsync(int requestTypeId, string? authorization, CancellationToken cancellationToken) + { + callerToken.Set(authorization); + + // Единственная настоящая защита от параллельного запуска: атомарный захват + // флага в строке request_types. Проверка перед постановкой в очередь — + // только чтобы не плодить лишние задачи; гонку разруливает именно здесь. + if (!await provisioningLock.TryAcquireAsync( + requestTypeId, options.Value.StaleLockAfter, cancellationToken)) + { + logger.LogInformation( + "Провижининг списка для типа заявок {RequestTypeId} уже идёт — задача пропущена.", + requestTypeId); + return; + } + + try + { + var requestType = await db.RequestTypes + .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken); + + if (requestType is null) + { + logger.LogWarning("Тип заявок {RequestTypeId} не найден — провижининг отменён.", requestTypeId); + return; + } + + if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl)) + { + logger.LogWarning( + "У типа заявок {RequestTypeId} не настроен список SharePoint — провижининг отменён.", + requestTypeId); + return; + } + + // Партиция, на которую сейчас указывает RequestType.ListId, прямо сейчас + // перестаёт быть текущей — записываем её в архив (request_list_partitions + // хранит только архив, см. ListPartitionRegistry). До этого момента она + // нигде отдельной строкой не значилась. + var archivedCurrent = await registry.ArchiveCurrentAsync(requestType, cancellationToken); + + var nextNumber = archivedCurrent.Number + 1; + + logger.LogInformation( + "Создаю партицию №{Number} для типа заявок {RequestTypeId} ({RequestTypeName}) " + + "по структуре {SourceListId}.", + nextNumber, requestTypeId, requestType.Name, requestType.ListId); + + var stopwatch = Stopwatch.StartNew(); + ProvisionedList created; + try + { + // Партиция всегда создаётся на том же сайте, что и текущая (см. + // комментарий на RequestListPartition о недопустимости разъезда серии + // по сайтам) — сайт берём из RequestType, а не откуда-либо ещё, так что + // расхождение структурно невозможно. + created = await provisioner.CreateNextListPairAsync( + requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken); + } + catch (Exception ex) + { + // Логируем с контекстом (номер партиции, тип заявок) здесь, а не + // полагаемся только на то, что исключение долетит до лога Hangfire: + // там оно будет без этих подробностей, вперемешку с логами других задач. + // Дальше даём ему пойти в Hangfire как обычно — так сработает + // AutomaticRetry(Attempts = 3). + logger.LogError( + ex, + "Провижининг партиции №{Number} для типа заявок {RequestTypeId} не удался за {ElapsedMs} мс.", + nextNumber, requestTypeId, stopwatch.ElapsedMilliseconds); + throw; + } + + // Новая партиция строкой в request_list_partitions пока не появляется — + // она получит свою запись только когда САМА станет архивом, следующим + // вызовом этой же задачи (см. doc-комментарий класса ListPartitionRegistry). + // Здесь переключаем только указатель. + requestType.ListId = created.ListId; + await db.SaveChangesAsync(cancellationToken); + + logger.LogInformation( + "Партиция №{Number} ({ListId}) создана за {ElapsedMs} мс, запись по типу заявок " + + "{RequestTypeId} переключена на неё.", + nextNumber, created.ListId, stopwatch.ElapsedMilliseconds, requestTypeId); + } + finally + { + // Снимаем блокировку и на успехе, и на исключении: иначе упавшая задача + // держала бы тип заблокированным до истечения StaleLockAfter. + await provisioningLock.ReleaseAsync(requestTypeId, CancellationToken.None); + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionDto.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionDto.cs new file mode 100644 index 0000000..a67e196 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionDto.cs @@ -0,0 +1,35 @@ +namespace Tnsu.ApprovalRoute.Application.ListPartitioning; + +/// Одна партиция серии — для админки/портала (вся серия) и для ответа «текущий список». +/// +/// true — в эту партицию идёт запись новых заявок; остальные партиции серии — архив только для чтения. +/// +public sealed record RequestListPartitionDto( + int Number, + string SiteUrl, + string ListId, + string? Title, + bool IsCurrent, + DateTimeOffset CreatedAt); + +/// Вся серия партиций одного типа заявок — единица ответа bulk-эндпоинта для портала. +public sealed record RequestTypeListSeriesDto( + int RequestTypeId, + IReadOnlyList Partitions); + +/// Ответ «куда писать новую заявку». +/// +/// Сколько элементов сейчас в списке — по нему видно, насколько он близок к порогу. +/// null, если SharePoint не ответил (тогда условие создания следующей партиции не проверялось). +/// +/// +/// true — этим запросом поставлена в очередь задача создания следующей партиции серии. +/// +public sealed record CurrentListPartitionDto( + int RequestTypeId, + int Number, + string SiteUrl, + string ListId, + string? Title, + int? ItemCount = null, + bool ProvisioningQueued = false); diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs new file mode 100644 index 0000000..d7578b6 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs @@ -0,0 +1,123 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +namespace Tnsu.ApprovalRoute.Application.ListPartitioning; + +/// +/// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему +/// эндпоинту, и фоновой задаче. +/// +/// request_list_partitions хранит ТОЛЬКО архив — партиции, которые уже перестали +/// быть текущими. Текущая партиция нигде отдельной строкой не пишется: она всегда +/// синтезируется на лету из RequestType.ListId/SiteUrl (см. BuildCurrentAsync). +/// Иначе один и тот же список оказывался бы записан дважды — и в RequestType.ListId, +/// и строкой в этой таблице — сразу же, при первом же чтении, до того как партиция +/// реально стала архивом. +/// +public sealed class ListPartitionRegistry( + ApprovalRouteDbContext db, + ISharePointClient sharePoint, + ILogger logger) +{ + /// + /// Партиции, которые УЖЕ архив — всё, что реестр знает о серии, кроме текущей. + /// + /// Фильтр по ListId != RequestType.ListId — не оптимизация, а самовосстановление + /// на случай узкого окна в ArchiveCurrentAsync (см. её комментарий): если старая + /// партиция уже успела записаться архивом, а переключение указателя на новую ещё + /// не прошло (SharePoint упал между этими двумя шагами), без фильтра она была бы + /// видна СРАЗУ в обоих качествах — и как архив, и как текущая (см. + /// BuildCurrentAsync). Фильтр держит текущую ровно в одном месте, даже в этом + /// переходном состоянии. + /// + public async Task> GetArchivedAsync( + RequestType requestType, CancellationToken cancellationToken) => + await db.RequestListPartitions.AsNoTracking() + .Where(x => x.RequestTypeId == requestType.Id && x.ListId != requestType.ListId) + .OrderBy(x => x.Number) + .ToListAsync(cancellationToken); + + /// + /// Синтезирует запись о ТЕКУЩЕЙ партиции — без обращения к request_list_partitions + /// на запись: строка появится там только когда эта партиция реально станет + /// архивом (см. ArchiveCurrentAsync). Номер — следующий за последним архивным + /// (или 1, если архива ещё нет); Title читается REST'ом каждый раз, а не + /// кешируется — кешировать было бы негде без строки в БД. + /// + /// archived — уже загруженный список архивных партиций этого типа (см. + /// GetArchivedAsync), передаётся, а не читается заново, чтобы вызывающая + /// сторона (которой archived обычно уже нужен для остальной серии) не делала + /// один и тот же запрос дважды. + /// + public async Task BuildCurrentAsync( + RequestType requestType, IReadOnlyList archived, CancellationToken cancellationToken) + { + var number = archived.Count == 0 ? 1 : archived.Max(x => x.Number) + 1; + var title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken); + + return new RequestListPartitionDto( + number, requestType.SiteUrl!, requestType.ListId!, title, IsCurrent: true, DateTimeOffset.UtcNow); + } + + /// + /// Записывает ТЕКУЩУЮ партицию как архивную строку — вызывать строго в момент, + /// когда она перестаёт быть текущей (единственный вызывающий — + /// CreateNextListPartitionJob, прямо перед тем как переключить + /// RequestType.ListId на следующую партицию). + /// + /// Читает свой собственный, НЕфильтрованный список существующих партиций — + /// намеренно не переиспользует GetArchivedAsync(): та фильтрует по + /// ListId != RequestType.ListId, и ровно между этим вызовом и переключением + /// указателя (см. CreateNextListPartitionJob) только что записанная строка + /// на мгновение совпадает с текущим ListId — фильтр GetArchivedAsync её бы не + /// увидел, идемпотентность сломалась бы, и повтор Hangfire упал бы на + /// уникальном индексе (request_type_id, number) вместо того, чтобы найти уже + /// записанное и вернуть его. + /// + public async Task ArchiveCurrentAsync( + RequestType requestType, CancellationToken cancellationToken) + { + var series = await db.RequestListPartitions + .Where(x => x.RequestTypeId == requestType.Id) + .ToListAsync(cancellationToken); + + var existing = series.FirstOrDefault(x => x.ListId == requestType.ListId); + if (existing is not null) return existing; + + var entity = new RequestListPartition + { + RequestTypeId = requestType.Id, + Number = series.Count == 0 ? 1 : series.Max(x => x.Number) + 1, + ListId = requestType.ListId!, + Title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken), + CreatedAt = DateTimeOffset.UtcNow + }; + + db.RequestListPartitions.Add(entity); + await db.SaveChangesAsync(cancellationToken); + + logger.LogInformation( + "Список {ListId} типа заявок {RequestTypeId} заархивирован под номером {Number}.", + entity.ListId, requestType.Id, entity.Number); + + return entity; + } + + // Заголовок нужен только для отображения в админке/портале — из-за его + // отсутствия чтение партиции падать не должно. + private async Task TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken) + { + try + { + return await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Не удалось прочитать заголовок списка {ListId}.", listId); + return null; + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs new file mode 100644 index 0000000..f7862c9 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs @@ -0,0 +1,170 @@ +using FluentResults; +using Hangfire; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.Application.ListPartitioning; + +/// +/// Реестр нумерованных партиций списка по типу заявок: какая партиция текущая (куда +/// писать) и вся серия целиком (архив + текущая) для админки и портала. +/// +public sealed class ListPartitionService( + ApprovalRouteDbContext db, + ISharePointClient sharePoint, + ListPartitionRegistry registry, + IBackgroundJobClient backgroundJobs, + TnsuPortalCallerToken callerToken, + IOptions options, + ILogger logger) +{ + /// + /// Партиция, в которую сейчас нужно писать новые заявки. + /// + /// Побочный эффект: если партиция набрала порог элементов, ставит в очередь + /// задачу создания следующей. Побочный эффект в GET — сознательный компромисс + /// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой + /// записью, и это единственный момент, когда мы точно знаем, что список живой. + /// Повторная постановка безопасна — задача разруливает гонку захватом блокировки. + /// + public async Task> GetCurrentAsync( + int requestTypeId, CancellationToken cancellationToken) + { + var requestType = await db.RequestTypes + .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken); + + if (requestType is null) + return Result.Fail(new NotFoundError("Тип заявки", requestTypeId)); + + if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl)) + return Result.Fail(new ValidationError( + $"У типа заявки {requestTypeId} не настроен список SharePoint.")); + + // Синтезируется на лету, не пишется в request_list_partitions — см. + // ListPartitionRegistry.BuildCurrentAsync: строка появится там только когда + // партиция реально станет архивом. + var archived = await registry.GetArchivedAsync(requestType, cancellationToken); + var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken); + + var itemCount = await TryGetItemCountAsync(requestType.SiteUrl, requestType.ListId, cancellationToken); + var queued = await TryEnqueueProvisioningAsync(requestType, current.Number, itemCount, cancellationToken); + + return Result.Ok(new CurrentListPartitionDto( + requestTypeId, current.Number, requestType.SiteUrl, current.ListId, current.Title, itemCount, queued)); + } + + /// Вся серия партиций типа заявок, от первой к последней — для админки. + public async Task>> ListAsync( + int requestTypeId, CancellationToken cancellationToken) + { + var requestType = await db.RequestTypes + .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken); + + if (requestType is null) + return Result.Fail>(new NotFoundError("Тип заявки", requestTypeId)); + + return Result.Ok(await BuildSeriesAsync(requestType, cancellationToken)); + } + + /// + /// Серии всех типов заявок разом — для портала: чтобы искать по заявкам сразу во + /// всех партициях (архив + текущая) любого типа, порталу нужен весь список + /// адресов один раз, а не по одному запросу на тип. Типы без настроенного + /// списка SharePoint в ответ не попадают — искать там всё равно нечего. + /// + public async Task>> ListAllAsync(CancellationToken cancellationToken) + { + var requestTypes = await db.RequestTypes + .Where(x => x.ListId != null && x.SiteUrl != null) + .ToListAsync(cancellationToken); + + var result = new List(requestTypes.Count); + foreach (var requestType in requestTypes) + { + var partitions = await BuildSeriesAsync(requestType, cancellationToken); + result.Add(new RequestTypeListSeriesDto(requestType.Id, partitions)); + } + + return Result.Ok>(result); + } + + private async Task> BuildSeriesAsync( + RequestType requestType, CancellationToken cancellationToken) + { + // Тип без настроенного списка — пустая серия, а не ошибка: в админке такой + // тип заявок вполне может существовать до привязки к SharePoint. + if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl)) + return []; + + var archived = await registry.GetArchivedAsync(requestType, cancellationToken); + var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken); + + var result = new List(archived.Count + 1); + result.AddRange(archived.Select(x => + new RequestListPartitionDto(x.Number, requestType.SiteUrl!, x.ListId, x.Title, IsCurrent: false, x.CreatedAt))); + result.Add(current); + + return result; + } + + /// + /// Число элементов в списке. Ошибку SharePoint глушим: эндпоинт обязан отдать + /// адрес списка даже когда SharePoint недоступен, иначе портал не сможет + /// принять заявку из-за сбоя необязательной проверки порога. + /// + private async Task TryGetItemCountAsync(string siteUrl, string listId, CancellationToken cancellationToken) + { + if (!options.Value.CheckThresholdOnRead) return null; + + try + { + return await sharePoint.GetItemCountAsync(siteUrl, listId, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Не удалось получить число элементов списка {ListId} — порог не проверен.", listId); + return null; + } + } + + /// + /// Ставит задачу создания следующей партиции, если порог достигнут. Возвращает + /// true, только если задача действительно поставлена. + /// + private async Task TryEnqueueProvisioningAsync( + RequestType requestType, int currentNumber, int? itemCount, CancellationToken cancellationToken) + { + if (itemCount is null || itemCount < options.Value.ItemCountThreshold) return false; + + // Следующая партиция уже создана, а запись на неё ещё не переключили — + // задача не нужна (и переключать здесь нельзя: этим владеет провижининг). + var hasNext = await db.RequestListPartitions + .AnyAsync(x => x.RequestTypeId == requestType.Id && x.Number > currentNumber, cancellationToken); + if (hasNext) return false; + + // Провижининг уже идёт. Проверка неатомарная и это нормально: она экономит + // постановку лишней задачи, а настоящую гонку ловит захват блокировки внутри + // самой задачи (см. CreateNextListPartitionJob). + if (requestType.ListPartitioningStartedAt is not null) return false; + + // Токен ЭТОГО запроса уезжает в задачу: подписать новый список на + // уведомления нужно от имени пользователя, а своего токена у фоновой задачи + // нет (см. CreateNextListPartitionJob.RunAsync — там же про его хранение и + // срок жизни). + backgroundJobs.Enqueue( + job => job.RunAsync(requestType.Id, callerToken.Current, CancellationToken.None)); + + logger.LogInformation( + "В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " + + "поставлена задача создания партиции №{NextNumber}.", + requestType.ListId, requestType.Id, itemCount, options.Value.ItemCountThreshold, currentNumber + 1); + + return true; + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitioningOptions.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitioningOptions.cs new file mode 100644 index 0000000..b158e5e --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitioningOptions.cs @@ -0,0 +1,31 @@ +namespace Tnsu.ApprovalRoute.Application.ListPartitioning; + +/// Настройки нумерованных партиций списков заявок (секция ListPartitioning в конфигурации). +public sealed class ListPartitioningOptions +{ + /// + /// Порог элементов в списке данных, по достижении которого ставится задача на + /// создание следующей партиции (пары списков). + /// + /// 5000 — не предел SharePoint на число элементов (их может быть миллионы), а + /// «list view threshold»: запросы, которым приходится просмотреть больше 5000 + /// элементов без подходящего индекса, SharePoint отклоняет. Порог намеренно чуть + /// ниже, чтобы задача успела создать следующую партицию до того, как текущая + /// начнёт упираться. + /// + public int ItemCountThreshold { get; set; } = 4500; + + /// + /// Через сколько блокировка провижининга считается брошенной и может быть + /// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListPartitioningStartedAt. + /// + public TimeSpan StaleLockAfter { get; set; } = TimeSpan.FromMinutes(30); + + /// + /// Проверять ли порог в эндпоинте «текущий список». false — эндпоинт только + /// отдаёт список, не обращаясь к SharePoint за числом элементов и никого не + /// ставя в очередь. Полезно, если SharePoint временно недоступен или нужно + /// приостановить автосоздание партиций, не трогая остальную логику. + /// + public bool CheckThresholdOnRead { get; set; } = true; +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 1a62dc5..5c08ecc 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -3,6 +3,7 @@ using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; @@ -20,6 +21,7 @@ namespace Tnsu.ApprovalRoute.Application.Reminders; public sealed class ReminderService( ApprovalRouteDbContext db, ISharePointClient sharePoint, + ListPartitionRegistry partitionRegistry, IOptions options, ILogger logger) { @@ -62,14 +64,60 @@ public sealed class ReminderService( } } + /// + /// Обходит ВСЕ партиции типа заявки (архив + текущая) — заявка, уехавшая в + /// архив, продолжает ждать чьего-то решения точно так же, как в текущем + /// списке, и не должна пропадать из напоминаний только из-за того, что для + /// новых заявок завели следующий список. + /// + /// request_list_partitions хранит только архив (см. ListPartitionRegistry) — + /// текущая партиция синтезируется отдельно и добавляется к архиву здесь же, + /// а не читается из БД. + /// private async Task> GetOverdueForTypeAsync( RequestType type, DateTimeOffset now, CancellationToken cancellationToken) + { + var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken); + var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken); + + var partitions = archived + .Select(x => (x.ListId, x.Number)) + .Append((current.ListId, current.Number)); + + var reminders = new List(); + foreach (var (listId, number) in partitions) + { + try + { + reminders.AddRange(await GetOverdueForPartitionAsync(type, listId, number, now, cancellationToken)); + } + catch (Exception e) + { + // Недоступная партиция (например, архивный список переименовали + // или удалили вручную) не должна обнулять напоминания по + // остальным партициям того же типа. + logger.LogError( + e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны", + listId, number, type.Name); + } + } + + return reminders; + } + + private async Task> GetOverdueForPartitionAsync( + RequestType type, string listId, int number, DateTimeOffset now, CancellationToken cancellationToken) { var siteUrl = type.SiteUrl!; - var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, type.ListId!, cancellationToken); + + // Читаем Title ЖИВЬЁМ — заголовок могли переименовать вручную, а по url + // список найдётся всегда. + var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken); if (string.IsNullOrWhiteSpace(dataListTitle)) { - logger.LogWarning("Список данных типа «{Type}» не найден по url {Url}", type.Name, type.ListId); + logger.LogWarning( + "Список данных партиции №{Number} типа «{Type}» не найден по url {Url}", + number, type.Name, listId); return []; } diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index fa58fa8..7ee49e8 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -1,12 +1,14 @@ using FluentResults; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Application.RequestTypes; -public sealed class RequestTypeService(ApprovalRouteDbContext db) +public sealed class RequestTypeService(ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger logger) { public async Task>> ListAsync(CancellationToken cancellationToken) { @@ -45,6 +47,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) }; db.RequestTypes.Add(entity); await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Тип заявки {RequestTypeId} создан пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } @@ -67,6 +70,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) entity.SlaHours = request.SlaHours; entity.AskOverdueReason = request.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Тип заявки {RequestTypeId} изменён пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } @@ -95,6 +99,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) entity.SlaHours = request.SlaHours ?? entity.SlaHours; entity.AskOverdueReason = request.AskOverdueReason ?? entity.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Тип заявки {RequestTypeId} изменён пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } @@ -107,6 +112,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) db.RequestTypes.Remove(entity); await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Тип заявки {RequestTypeId} удалён пользователем {Author}", id, currentUser.Email); return Result.Ok(); } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 516efa9..b2b3654 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -1,13 +1,16 @@ using FluentResults; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; 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; namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; -public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) +public sealed class TemplateRouteStepService( + ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger logger) { public async Task>> ListAsync(int routeId, CancellationToken cancellationToken) { @@ -79,6 +82,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) }; db.TemplateRouteSteps.Add(entity); await db.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "Шаг {StepId} маршрута {RouteId} создан пользователем {Author}", entity.Id, route.Id, currentUser.Email); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); } @@ -130,6 +135,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) entity.SlaHours = r.SlaHours; entity.AskOverdueReason = r.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); } @@ -199,6 +206,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) entity.SlaHours = r.SlaHours ?? entity.SlaHours; entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); } @@ -211,6 +220,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) db.TemplateRouteSteps.Remove(entity); await db.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "Шаг {StepId} маршрута {RouteId} удалён пользователем {Author}", id, routeId, currentUser.Email); return Result.Ok(); } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index aae5af9..34b9dd5 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -1,6 +1,7 @@ using System.Globalization; using FluentResults; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.Common.Interfaces; @@ -15,7 +16,8 @@ public sealed class TemplateRouteService( ApprovalRouteDbContext db, ITnsuPortalClient portalClient, ICurrentUser currentUser, - IOptions portalOptions) + IOptions portalOptions, + ILogger logger) { public async Task>> ListAsync(int? requestTypeId, CancellationToken cancellationToken) { @@ -61,6 +63,7 @@ public sealed class TemplateRouteService( }; db.TemplateRoutes.Add(entity); await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Маршрут {RouteId} создан пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } @@ -81,6 +84,7 @@ public sealed class TemplateRouteService( entity.ByProject = r.ByProject; entity.RequiresControl = r.RequiresControl; await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } @@ -105,6 +109,7 @@ public sealed class TemplateRouteService( entity.ByProject = byProject; entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl; await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } @@ -117,6 +122,7 @@ public sealed class TemplateRouteService( db.TemplateRoutes.Remove(entity); await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Маршрут {RouteId} удалён пользователем {Author}", id, currentUser.Email); return Result.Ok(); } @@ -261,9 +267,12 @@ public sealed class TemplateRouteService( } else if (!string.IsNullOrWhiteSpace(step.Position)) { - resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken); - if (string.IsNullOrWhiteSpace(resolvedEmail)) + var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken); + resolvedEmail = employeeByPosition.Email; + if (!employeeByPosition.Found) warning = $"Не найден сотрудник на должности «{step.Position}» в компании «{initiatorCompany}»."; + else if (string.IsNullOrWhiteSpace(resolvedEmail)) + warning = $"Сотрудник {employeeByPosition.Fio} на должности «{step.Position}» найден, но email не указан в системе портала."; } else if (step.ProjectRole is not (null or ProjectRole.None)) { diff --git a/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj index a10ee5e..bc84cb2 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj +++ b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj @@ -7,6 +7,7 @@ + diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestListPartition.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestListPartition.cs new file mode 100644 index 0000000..8ffcb3c --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestListPartition.cs @@ -0,0 +1,47 @@ +namespace Tnsu.ApprovalRoute.Domain.Entities; + +/// +/// Один SharePoint-список данных из нумерованной серии по типу заявок. +/// +/// Списки заявок нумерованные («<Форма> <N> — данные»): когда в текущем +/// списке набирается порог элементов, рядом создаётся следующая пара (партиция), а +/// прежняя остаётся архивом только для чтения. Эта таблица — реестр всей серии: без +/// неё поиск по заявкам типа не знает, по каким спискам искать. +/// +/// Текущий (тот, куда идёт запись) список продолжает лежать в +/// — реестр его дублирует строкой с наибольшим +/// , но источником правды для записи остаётся RequestType. +/// +/// Сайта у партиции нет намеренно: вся серия живёт на одном сайте — том, что указан +/// в на момент создания партиции. Хранить его +/// отдельно на каждой партиции — заводить возможность архивам разъехаться по разным +/// сайтам, а такой сценарий не поддерживается ни провижинером (см. +/// SharePointListProvisioner — всегда пишет на siteUrl запрашивающего типа заявок), +/// ни поиском. Если сайт типа заявок когда-нибудь придётся сменить, старые партиции +/// нужно будет переносить осознанно, а не молча продолжать читать их со старого адреса. +/// +public class RequestListPartition +{ + public int Id { get; set; } + + public int RequestTypeId { get; set; } + public RequestType? RequestType { get; set; } + + /// Номер партиции в серии, с 1. Уникален в пределах типа заявок. + public int Number { get; set; } + + /// + /// Серверный относительный url списка — та же семантика, что у + /// (несмотря на имя, это url, а не GUID). + /// + public string ListId { get; set; } = string.Empty; + + /// + /// Title списка на момент создания — для админки, чтобы показать серию без + /// похода в SharePoint за каждым заголовком. Может разойтись с фактическим, + /// если список переименовали вручную. + /// + public string? Title { get; set; } + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs index 70db213..a5f4cdf 100644 --- a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs @@ -25,5 +25,23 @@ public class RequestType /// public bool? AskOverdueReason { get; set; } + /// + /// Момент, когда фоновая задача создания следующей партиции списка взяла эту + /// строку в работу. null — задача не выполняется. + /// + /// Одновременно и признак «идёт провижининг», и БЛОКИРОВКА: задача стартует + /// только если сумела перевести поле из null в now() одним условным UPDATE + /// (см. ListPartitioningLock) — тогда две параллельные задачи по одному типу + /// заявок невозможны. + /// + /// Метка времени, а не bool, намеренно: упавший процесс не успевает снять + /// флаг, и bool заблокировал бы тип навсегда, а по метке видно, что блокировка + /// просрочена, и её можно перехватить. + /// + public DateTimeOffset? ListPartitioningStartedAt { get; set; } + public ICollection Routes { get; set; } = new List(); + + /// Нумерованная серия партиций списка этого типа заявок (архив + текущая). + public ICollection ListPartitions { get; set; } = new List(); } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 9b1ba29..9d62bf5 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -20,15 +20,32 @@ public static class InfrastructureServiceCollectionExtensions opt.UseNpgsql(configuration.GetConnectionString("Postgres")) .UseSnakeCaseNamingConvention()); services.AddAsyncInitializer(); + services.AddScoped(); services.Configure(configuration.GetSection("SharePoint")); // Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё // время жизни процесса (см. DbCertificateProvider). services.AddSingleton(); services.AddHttpClient(); + services.AddScoped(); services.Configure(configuration.GetSection("TnsuPortal")); services.AddHttpClient(); + // Токен вызывающего и общая авторизация запросов к порталу — одни на всех + // его клиентов (см. TnsuPortalCallerToken/TnsuPortalAuthorizer). Именно + // Scoped: Hangfire даёт задаче свою область DI, и положенный в задаче токен + // не должен протекать в соседние. + services.AddScoped(); + services.AddScoped(); + + // Подписка новых списков на уведомления SharePoint. Клиент отдельный от + // ITnsuPortalClient: там кешируемые чтения справочника, здесь команда. + // Своих опций нет — адрес приёмника лежит в SharePoint:NotificationUrl. + services.AddHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + }); // ITnsuPortalClient резолвится в CachingTnsuPortalClient (кеш на 5 минут, // см. её комментарий) — сам HttpTnsuPortalClient регистрируем по // конкретному типу, а не по интерфейсу, чтобы декоратор мог его получить diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 3bf9ee4..edea59c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -7,6 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; public class ApprovalRouteDbContext(DbContextOptions options) : DbContext(options) { public DbSet RequestTypes => Set(); + public DbSet RequestListPartitions => Set(); public DbSet TemplateRoutes => Set(); public DbSet TemplateRouteSteps => Set(); public DbSet Settings => Set(); @@ -33,6 +34,18 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.Property(x => x.Section).HasMaxLength(256).IsRequired(); }); + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.ListId).HasMaxLength(512).IsRequired(); + e.Property(x => x.Title).HasMaxLength(512); + e.HasOne(x => x.RequestType).WithMany(x => x.ListPartitions).HasForeignKey(x => x.RequestTypeId); + // Номер уникален в пределах типа заявок — вторая попытка записать тот же + // номер (например, при двойном запуске задачи) упадёт на этом индексе, + // а не создаст в реестре дубль. + e.HasIndex(x => new { x.RequestTypeId, x.Number }).IsUnique(); + }); + modelBuilder.Entity(e => { e.HasKey(x => x.Id); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ListPartitioningLock.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ListPartitioningLock.cs new file mode 100644 index 0000000..3556cea --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ListPartitioningLock.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; + +/// +/// Блокировка «по типу заявок идёт создание следующей партиции списка» на строке +/// request_types. +/// +/// Почему сырой SQL, а не обычный EF: через change tracker это было бы +/// read-modify-write (прочитали флаг → проверили → записали), и две задачи, +/// прочитавшие null одновременно, обе решили бы, что блокировка свободна. +/// Здесь проверка и захват — ОДИН условный UPDATE, атомарный на уровне строки: +/// Postgres берёт row lock на время оператора, поэтому ровно одна из +/// конкурирующих задач увидит affected rows = 1, остальные — 0. +/// +/// Никакого pg_advisory_lock: он живёт в рамках сессии, а нам нужно, чтобы +/// блокировку было видно в самой строке (админке — что провижининг идёт) и чтобы +/// она переживала возврат соединения в пул между шагами задачи. +/// +public sealed class ListPartitioningLock(ApprovalRouteDbContext db) +{ + /// + /// Попытаться захватить блокировку. true — захватили (и только тогда можно + /// провижинить), false — держит кто-то другой либо типа заявок нет. + /// + /// Блокировка старше staleAfter считается брошенной (процесс упал, не сняв + /// флаг) и перехватывается. + /// + public async Task TryAcquireAsync(int requestTypeId, TimeSpan staleAfter, CancellationToken cancellationToken) + { + // Имена таблицы/колонки — snake_case из UseSnakeCaseNamingConvention + // (см. InfrastructureServiceCollectionExtensions). + var affected = await db.Database.ExecuteSqlInterpolatedAsync( + $""" + UPDATE request_types + SET list_partitioning_started_at = now() + WHERE id = {requestTypeId} + AND (list_partitioning_started_at IS NULL + OR list_partitioning_started_at < now() - CAST({staleAfter} AS interval)) + """, + cancellationToken); + + return affected == 1; + } + + /// Снять блокировку. Идемпотентно: снятая блокировка снимается повторно без ошибки. + public Task ReleaseAsync(int requestTypeId, CancellationToken cancellationToken) => + db.Database.ExecuteSqlInterpolatedAsync( + $"UPDATE request_types SET list_partitioning_started_at = NULL WHERE id = {requestTypeId}", + cancellationToken); +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.Designer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.Designer.cs new file mode 100644 index 0000000..2995735 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.Designer.cs @@ -0,0 +1,314 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApprovalRouteDbContext))] + [Migration("20260817102423_AddListPartitioning")] + partial class AddListPartitioning + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_request_list_partitions"); + + b.HasIndex("RequestTypeId", "Number") + .IsUnique() + .HasDatabaseName("ix_request_list_partitions_request_type_id_number"); + + b.ToTable("request_list_partitions", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + + b.Property("ListId") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("ListPartitioningStartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("list_partitioning_started_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("PageUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("page_url"); + + b.Property("Section") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("section"); + + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("site_url"); + + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + + b.HasKey("Id") + .HasName("pk_request_types"); + + b.ToTable("request_types", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ByProject") + .HasColumnType("boolean") + .HasColumnName("by_project"); + + b.Property("CompanyId") + .HasColumnType("uuid") + .HasColumnName("company_id"); + + b.Property("DepartmentId") + .HasColumnType("uuid") + .HasColumnName("department_id"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("RequiresControl") + .HasColumnType("boolean") + .HasColumnName("requires_control"); + + b.HasKey("Id") + .HasName("pk_template_routes"); + + b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") + .IsUnique() + .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); + + b.ToTable("template_routes", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowReassignToOtherApprovers") + .HasColumnType("boolean") + .HasColumnName("allow_reassign_to_other_approvers"); + + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + + b.Property("ExecutorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("executor_email"); + + b.Property("FieldName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + + b.Property("HeadType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + + b.Property("Operation") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("Position") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("position"); + + b.Property("ProjectRole") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("project_role"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Value") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_template_route_steps"); + + b.HasIndex("RouteId", "OrderNo") + .HasDatabaseName("ix_template_route_steps_route_id_order_no"); + + b.ToTable("template_route_steps", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b => + { + b.Property("OptionName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("option_name"); + + b.Property("FileData") + .HasColumnType("bytea") + .HasColumnName("file_data"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("OptionName") + .HasName("pk_settings"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany("ListPartitions") + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_request_list_partitions_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + + 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_template_routes_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route") + .WithMany("Steps") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_route_steps_template_routes_route_id"); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => + { + b.Navigation("ListPartitions"); + + b.Navigation("Routes"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.cs new file mode 100644 index 0000000..9c2eff6 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddListPartitioning : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "list_partitioning_started_at", + table: "request_types", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.CreateTable( + name: "request_list_partitions", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + request_type_id = table.Column(type: "integer", nullable: false), + number = table.Column(type: "integer", nullable: false), + list_id = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + title = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_request_list_partitions", x => x.id); + table.ForeignKey( + name: "fk_request_list_partitions_request_types_request_type_id", + column: x => x.request_type_id, + principalTable: "request_types", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_request_list_partitions_request_type_id_number", + table: "request_list_partitions", + columns: new[] { "request_type_id", "number" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "request_list_partitions"); + + migrationBuilder.DropColumn( + name: "list_partitioning_started_at", + table: "request_types"); + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index a3209ad..1e95a34 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -22,6 +22,48 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_request_list_partitions"); + + b.HasIndex("RequestTypeId", "Number") + .IsUnique() + .HasDatabaseName("ix_request_list_partitions_request_type_id_number"); + + b.ToTable("request_list_partitions", (string)null); + }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { b.Property("Id") @@ -40,6 +82,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(512)") .HasColumnName("list_id"); + b.Property("ListPartitioningStartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("list_partitioning_started_at"); + b.Property("Name") .IsRequired() .HasMaxLength(256) @@ -212,6 +258,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("settings", (string)null); }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany("ListPartitions") + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_request_list_partitions_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") @@ -238,6 +296,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { + b.Navigation("ListPartitions"); + b.Navigation("Routes"); }); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ISharePointListProvisioner.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ISharePointListProvisioner.cs new file mode 100644 index 0000000..6e44bf1 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ISharePointListProvisioner.cs @@ -0,0 +1,27 @@ +namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +/// Созданный список: серверный относительный url (он же ListId) и Title. +public sealed record ProvisionedList(string ListId, string Title); + +/// +/// Создание следующей пары списков серии в SharePoint («<Форма> <N> — данные» +/// + «<Форма> <N> — согласование») дублированием текущей пары. +/// +/// Интерфейс живёт в Infrastructure, а не в Application, потому что Application уже +/// ссылается на Infrastructure (так же устроен ) — +/// обратная ссылка замкнула бы проекты в кольцо. +/// +public interface ISharePointListProvisioner +{ + /// + /// Создать пару списков с номером , скопировав структуру + /// со списка . Возвращает созданный список ДАННЫХ + /// (список согласования создаётся тем же вызовом, но наружу не отдаётся — его + /// находят по конвенции имени). + /// + Task CreateNextListPairAsync( + string siteUrl, + string sourceDataListId, + int number, + CancellationToken cancellationToken); +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ListTitleConvention.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ListTitleConvention.cs new file mode 100644 index 0000000..8e3ee3e --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ListTitleConvention.cs @@ -0,0 +1,54 @@ +using System.Text.RegularExpressions; + +namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +/// +/// Конвенция имён списков заявок: «<Форма> <N> — данные» и парный ему +/// «<Форма> <N> — согласование». +/// +/// Третья копия одного и того же соглашения — оно же в провижинере request-portal +/// (provisioning/Provision-Lists.ps1, Get-NumberedListTitle) и в самом портале +/// (shared/listTitles.ts). Держать в одном месте не получается: три разных runtime. +/// Меняя здесь, менять и там. +/// +/// Номер стоит ПЕРЕД суффиксом, потому что имя списка согласования и портал, и +/// админка выводят из имени списка данных регексом, привязанным к концу строки. +/// +internal static class ListTitleConvention +{ + private const string DataSuffix = "данные"; + private const string ApprovalSuffix = "согласование"; + + private static readonly Regex TitlePattern = new( + @"^(?.+?)\s*—\s*(?данные|согласование)\s*$", + RegexOptions.Compiled); + + private static readonly Regex TrailingNumber = new(@"\s+\d+$", RegexOptions.Compiled); + + /// + /// Имя того же списка с номером . Уже имеющийся номер + /// заменяется, а не дописывается вторым. + /// + public static string WithNumber(string title, int number) + { + var match = TitlePattern.Match(title); + if (!match.Success) + throw new InvalidOperationException( + $"Имя списка «{title}» не по конвенции «<Форма> — данные|согласование» — номер поставить некуда."); + + var baseName = TrailingNumber.Replace(match.Groups["base"].Value, string.Empty); + return $"{baseName} {number} — {match.Groups["suffix"].Value}"; + } + + /// + /// Имя парного списка согласования по имени списка данных (номер сохраняется). + /// null — имя не по конвенции либо это не список данных. + /// + public static string? ToApprovalTitle(string dataListTitle) + { + var match = TitlePattern.Match(dataListTitle); + if (!match.Success || match.Groups["suffix"].Value != DataSuffix) return null; + + return $"{match.Groups["base"].Value} — {ApprovalSuffix}"; + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs index 510a7d0..e41187b 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs @@ -7,11 +7,45 @@ using Microsoft.Extensions.Options; namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; +/// Список SharePoint: Id нужен для операций над колонками, url — как ссылка на список. +public sealed record SharePointListInfo(Guid Id, string Title, string ServerRelativeUrl, int ItemCount); + +/// Колонка списка в виде её SchemaXml — в таком виде её можно создать в другом списке. +public sealed record SharePointFieldSchema(string InternalName, string SchemaXml); + public interface ISharePointClient { + /// Список по серверному относительному url. null — списка нет. + Task GetListByUrlAsync( + string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); + + /// Список по заголовку. null — списка нет. + Task GetListByTitleAsync( + string siteUrl, string listTitle, CancellationToken cancellationToken); + + /// + /// Пользовательские колонки списка — те, что нужно повторить в дубле. Системные + /// (унаследованные от базового типа, скрытые, только для чтения) отфильтрованы: + /// они появляются в новом списке сами. + /// + Task> GetCustomFieldsAsync( + string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); + + /// Создать пустой список (GenericList с версионированием) и вернуть его. + Task CreateListAsync(string siteUrl, string title, CancellationToken cancellationToken); + + /// Создать колонку в списке из её SchemaXml. + Task AddFieldFromXmlAsync(string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken); + /// Заголовок списка по его серверному относительному URL (см. RequestType.ListId). Task GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); + /// + /// Число элементов в списке (ItemCount самого списка, без выгрузки элементов) — + /// по нему решается, не пора ли заводить следующий список серии. + /// + Task GetItemCountAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); + /// Элементы списка данных, найденного по серверному относительному URL. Task> GetItemsByListUrlAsync( string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken); @@ -50,6 +84,116 @@ public sealed class SharePointClient( return json.TryGetProperty("Title", out var title) ? title.GetString() : null; } + public Task GetListByUrlAsync( + string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) => + GetListInfoAsync( + siteUrl, + $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')", + cancellationToken); + + public Task GetListByTitleAsync( + string siteUrl, string listTitle, CancellationToken cancellationToken) => + GetListInfoAsync( + siteUrl, + $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')", + cancellationToken); + + private async Task GetListInfoAsync( + string siteUrl, string listUrl, CancellationToken cancellationToken) + { + var url = $"{listUrl}?$select=Id,Title,ItemCount,RootFolder/ServerRelativeUrl&$expand=RootFolder"; + var json = await TryGetAsync(siteUrl, url, cancellationToken); + if (json is null) return null; + + var value = json.Value; + var serverRelativeUrl = value.TryGetProperty("RootFolder", out var rootFolder) + && rootFolder.TryGetProperty("ServerRelativeUrl", out var relative) + ? relative.GetString() + : null; + + if (serverRelativeUrl is null) return null; + + return new SharePointListInfo( + value.GetProperty("Id").GetGuid(), + value.GetProperty("Title").GetString() ?? string.Empty, + serverRelativeUrl, + value.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var items) ? items : 0); + } + + public async Task> GetCustomFieldsAsync( + string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) + { + // Ни FromBaseType, ни CanBeDeleted не годятся как признак «колонку явно + // добавили в список»: у переиспользуемых стандартных site-колонок SharePoint + // (например, «Department» — встроенная текстовая колонка из схемы v3, + // подключаемая через DelayActivateTemplateBinding) ОБА флага стоят как у + // настоящего системного поля, хотя колонка реально добавлена в список и в + // неё пишут данные. Из-за этого такие колонки тихо выпадали из копирования — + // без ошибки, просто без предупреждения. + // + // Поэтому здесь фильтруем только Hidden/ReadOnlyField (их создать всё равно + // нельзя), а отделять настоящие базовые поля (Title, ID, ContentType …) от + // явно добавленных — задача вызывающего кода: он сравнивает с полями + // ТОЛЬКО ЧТО СОЗДАННОГО пустого списка того же шаблона (см. CloneAsync, + // alreadyThere) — то, что есть на обоих, действительно базовое; то, что + // есть только на источнике, добавлено явно, независимо от флагов SharePoint. + var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/fields" + + "?$select=InternalName,SchemaXml,Hidden,ReadOnlyField" + + "&$filter=Hidden eq false and ReadOnlyField eq false"; + + var json = await GetAsync(siteUrl, url, cancellationToken); + var rows = json.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array + ? value.EnumerateArray() + : json.EnumerateArray(); + + var fields = new List(); + foreach (var row in rows) + { + var internalName = row.TryGetProperty("InternalName", out var name) ? name.GetString() : null; + var schemaXml = row.TryGetProperty("SchemaXml", out var schema) ? schema.GetString() : null; + if (internalName is null || schemaXml is null) continue; + + fields.Add(new SharePointFieldSchema(internalName, schemaXml)); + } + + return fields; + } + + public async Task CreateListAsync( + string siteUrl, string title, CancellationToken cancellationToken) + { + // BaseTemplate 100 — GenericList, как -Template GenericList у провижинера. + await PostAsync( + siteUrl, + $"{siteUrl.TrimEnd('/')}/_api/web/lists", + JsonSerializer.Serialize(new { Title = title, BaseTemplate = 100, EnableVersioning = true }), + cancellationToken); + + // Ответ POST'а не отдаёт RootFolder, а нам нужен серверный относительный url — + // дочитываем список отдельным запросом. + return await GetListByTitleAsync(siteUrl, title, cancellationToken) + ?? throw new InvalidOperationException($"Список «{title}» создан, но не читается обратно."); + } + + public Task AddFieldFromXmlAsync( + string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken) => + PostAsync( + siteUrl, + $"{siteUrl.TrimEnd('/')}/_api/web/lists(guid'{listId}')/fields/createfieldasxml", + // Options = 8 (AddFieldInternalNameHint): SharePoint обязан сохранить + // InternalName из SchemaXml, а не сгенерировать свой. К колонкам обращается + // код портала именно по внутреннему имени, так что это не косметика. + JsonSerializer.Serialize(new { parameters = new { SchemaXml = schemaXml, Options = 8 } }), + cancellationToken); + + public async Task GetItemCountAsync( + string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) + { + var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')?$select=ItemCount"; + var json = await GetAsync(siteUrl, url, cancellationToken); + return json.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var value) ? value : null; + } + public Task> GetItemsByListUrlAsync( string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) => GetItemsAsync( @@ -79,9 +223,26 @@ public sealed class SharePointClient( : []; } - private async Task GetAsync(string siteUrl, string url, CancellationToken cancellationToken) + private async Task GetAsync(string siteUrl, string url, CancellationToken cancellationToken) => + await SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: false, cancellationToken) + ?? throw new InvalidOperationException($"SharePoint не вернул тело на {url}."); + + /// GET, для которого «нет такого списка» — не ошибка, а null. + private Task TryGetAsync(string siteUrl, string url, CancellationToken cancellationToken) => + SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: true, cancellationToken); + + private Task PostAsync(string siteUrl, string url, string json, CancellationToken cancellationToken) => + SendAsync(HttpMethod.Post, siteUrl, url, json, allowNotFound: false, cancellationToken); + + private async Task SendAsync( + HttpMethod method, + string siteUrl, + string url, + string? body, + bool allowNotFound, + CancellationToken cancellationToken) { - using var request = new HttpRequestMessage(HttpMethod.Get, url); + using var request = new HttpRequestMessage(method, url); request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", await GetTokenAsync(siteUrl, cancellationToken)); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json") @@ -89,14 +250,30 @@ public sealed class SharePointClient( Parameters = { new NameValueHeaderValue("odata", "nometadata") } }); + if (body is not null) + { + request.Content = new StringContent(body); + // odata=nometadata и на запись: тогда в теле не нужен блок __metadata с + // типом сущности — SharePoint выводит тип из самого эндпоинта. + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") + { + Parameters = { new NameValueHeaderValue("odata", "nometadata") } + }; + } + using var response = await httpClient.SendAsync(request, cancellationToken); - var body = await response.Content.ReadAsStringAsync(cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (allowNotFound && response.StatusCode == System.Net.HttpStatusCode.NotFound) + return null; if (!response.IsSuccessStatusCode) throw new InvalidOperationException( - $"SharePoint ответил {(int)response.StatusCode} на {url}: {body}"); + $"SharePoint ответил {(int)response.StatusCode} на {method} {url}: {responseBody}"); - using var document = JsonDocument.Parse(body); + if (string.IsNullOrWhiteSpace(responseBody)) return null; + + using var document = JsonDocument.Parse(responseBody); return document.RootElement.Clone(); } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs new file mode 100644 index 0000000..8f7714c --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs @@ -0,0 +1,174 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +/// +/// Создаёт следующую пару списков ДУБЛИРОВАНИЕМ текущей: берёт структуру живого +/// списка (его пользовательские колонки) и повторяет её в новом списке с +/// номером N+1. Манифесты provisioning.json request-portal при этом не читаются — +/// источник структуры сам SharePoint, поэтому дубль не может разойтись с тем, что +/// реально в списке. +/// +/// Идемпотентен: список с целевым именем переиспользуется, уже существующие колонки +/// пропускаются. Повторный запуск после частичного сбоя дозаполняет недостающее, а +/// не создаёт второй список. +/// +public sealed class SharePointListProvisioner( + ISharePointClient sharePoint, + ISharePointWebhookSubscriber webhookSubscriber, + ILogger logger) : ISharePointListProvisioner +{ + public async Task CreateNextListPairAsync( + string siteUrl, + string sourceDataListId, + int number, + CancellationToken cancellationToken) + { + logger.LogInformation( + "Дублирую пару списков №{Number} на {SiteUrl} по образцу {SourceListId}.", + number, siteUrl, sourceDataListId); + var stopwatch = Stopwatch.StartNew(); + + var sourceData = await sharePoint.GetListByUrlAsync(siteUrl, sourceDataListId, cancellationToken) + ?? throw new InvalidOperationException( + $"Исходный список {sourceDataListId} не найден на {siteUrl} — дублировать нечего."); + + var newData = await CloneAsync( + siteUrl, + sourceData, + ListTitleConvention.WithNumber(sourceData.Title, number), + role: "данные", + retarget: null, + cancellationToken); + + await CloneApprovalPairAsync(siteUrl, sourceData, newData, number, cancellationToken); + + logger.LogInformation( + "Пара списков №{Number} готова за {ElapsedMs} мс: данные «{Title}» ({ListId}).", + number, stopwatch.ElapsedMilliseconds, newData.Title, newData.ServerRelativeUrl); + + // Подписка — ПОСЛЕДНИМ шагом, когда списки уже созданы: + // • подписывать нечего, пока списка нет; + // • только для списка ДАННЫХ — заявки портал отслеживает по нему, списку + // согласования подписка не нужна; + // • в самом конце, а не сразу после создания списка данных: сорвись + // дублирование пары на списке согласования, подписка была бы уже + // заведена, и повтор задачи Hangfire отправил бы второй запрос на ту же + // подписку (идемпотентна ли ручка портала — неизвестно). + // Не бросает и провижининг не рушит (см. ISharePointWebhookSubscriber). + await webhookSubscriber.TrySubscribeAsync(siteUrl, newData.Title, cancellationToken); + + return new ProvisionedList(newData.ServerRelativeUrl, newData.Title); + } + + /// + /// Дублирует парный список согласования. Его не на что сослаться, кроме имени: + /// в БД хранится ссылка только на список данных, а согласование ищется по + /// конвенции — так же, как это делают портал и админка. + /// + private async Task CloneApprovalPairAsync( + string siteUrl, + SharePointListInfo sourceData, + SharePointListInfo newData, + int number, + CancellationToken cancellationToken) + { + var sourceApprovalTitle = ListTitleConvention.ToApprovalTitle(sourceData.Title); + if (sourceApprovalTitle is null) + { + logger.LogWarning( + "Список «{Title}» не по конвенции «… — данные» — парный список согласования не дублируется.", + sourceData.Title); + return; + } + + var sourceApproval = await sharePoint.GetListByTitleAsync(siteUrl, sourceApprovalTitle, cancellationToken); + if (sourceApproval is null) + { + logger.LogWarning( + "Список согласования «{Title}» не найден — дублируется только список данных.", + sourceApprovalTitle); + return; + } + + await CloneAsync( + siteUrl, + sourceApproval, + ListTitleConvention.WithNumber(sourceApproval.Title, number), + role: "согласование", + // Ticket списка согласования смотрит на список данных — в дубле он должен + // смотреть на НОВЫЙ список данных, иначе строки маршрута новых заявок + // будут ссылаться в старый список. + retarget: (sourceData.Id, newData.Id), + cancellationToken); + } + + /// + /// role — только для логов («данные»/«согласование»): по нему в логе видно, какую + /// половину пары обрабатывает конкретный вызов, не заглядывая в стек вызовов. + /// + private async Task CloneAsync( + string siteUrl, + SharePointListInfo source, + string newTitle, + string role, + (Guid From, Guid To)? retarget, + CancellationToken cancellationToken) + { + var existing = await sharePoint.GetListByTitleAsync(siteUrl, newTitle, cancellationToken); + var target = existing ?? await sharePoint.CreateListAsync(siteUrl, newTitle, cancellationToken); + + logger.LogInformation( + existing is null + ? "Список {Role} «{Title}» создан." + : "Список {Role} «{Title}» уже существует — дозаполняю недостающие колонки.", + role, newTitle); + + var sourceFields = await sharePoint.GetCustomFieldsAsync(siteUrl, source.ServerRelativeUrl, cancellationToken); + var targetFields = await sharePoint.GetCustomFieldsAsync(siteUrl, target.ServerRelativeUrl, cancellationToken); + var alreadyThere = targetFields.Select(x => x.InternalName).ToHashSet(StringComparer.OrdinalIgnoreCase); + + var copied = 0; + foreach (var field in sourceFields) + { + if (alreadyThere.Contains(field.InternalName)) continue; + + var schemaXml = retarget is null + ? field.SchemaXml + : RetargetLookup(field.SchemaXml, retarget.Value.From, retarget.Value.To); + + await sharePoint.AddFieldFromXmlAsync(siteUrl, target.Id, schemaXml, cancellationToken); + copied++; + + // По каждой колонке — только на Debug: при штатной первой партии их + // десятки на список, и на Information это был бы шум. Итог всё равно + // виден в сводке ниже. + logger.LogDebug( + "Список {Role} «{Title}»: скопирована колонка {InternalName}.", role, newTitle, field.InternalName); + } + + logger.LogInformation( + "Список {Role} «{Title}»: скопировано колонок {Copied}, уже было {AlreadyThere}.", + role, newTitle, copied, sourceFields.Count - copied); + + return target; + } + + /// + /// Перенацеливает Lookup на новый список данных. + /// + /// Целевой список Lookup хранит в SchemaXml GUID'ом (атрибут List), поэтому + /// подменяем именно GUID. Подменяем ТОЛЬКО ссылки на исходный список данных: + /// кросс-формные lookup'ы (например, «организация рабочего места» ссылается на + /// список «приема на работу») должны и дальше смотреть на список ЧУЖОЙ формы. + /// + private static string RetargetLookup(string schemaXml, Guid from, Guid to) + { + var fromText = from.ToString("D"); + return schemaXml.Contains(fromText, StringComparison.OrdinalIgnoreCase) + ? schemaXml.Replace(fromText, to.ToString("D"), StringComparison.OrdinalIgnoreCase) + : schemaXml; + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs index 71186da..2a4b14c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs @@ -20,4 +20,15 @@ public sealed class SharePointOptions /// это здесь не стоит — пароль от сертификата в исходниках не место. /// public string CertificatePassword { get; set; } = string.Empty; + + /// + /// Куда SharePoint будет присылать уведомления об изменениях списка — параметр + /// notificationUrl ручки POST /Webhook/Subscribe, которой новый список данных + /// подписывается на уведомления (см. HttpSharePointWebhookSubscriber). + /// Это приёмник на СТОРОНЕ ПОРТАЛА, а не адрес нашего сервиса. + /// + /// Пусто — подписывать не будем вовсе: без адреса приёмника подписка + /// бессмысленна, и это же единственный способ её выключить. + /// + public string NotificationUrl { get; set; } = "https://api.tnsu.kz/Webhook/SharePoint"; } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs index 6ddab54..785e246 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs @@ -22,7 +22,7 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC $"emp:email:{email.ToLowerInvariant()}", () => inner.GetEmployeeByEmailAsync(email, cancellationToken)); - public Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) => + public Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) => GetOrAddAsync( $"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}", () => inner.GetEmployeeByPositionAsync(position, company, cancellationToken)); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpSharePointWebhookSubscriber.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpSharePointWebhookSubscriber.cs new file mode 100644 index 0000000..92cad00 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpSharePointWebhookSubscriber.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// POST {BaseUrl}/Webhook/Subscribe?listName=…&url=…&notificationUrl=… — просит +/// портал завести подписку SharePoint на только что созданный список данных. +/// +/// Параметры уходят строкой запроса, тела у запроса нет — так описана ручка. +/// +/// Отдельный клиент, а не метод в ITnsuPortalClient: тот про справочник сотрудников +/// и обёрнут кешем (CachingTnsuPortalClient), а здесь команда, которую кешировать +/// нельзя ни в каком виде. Авторизация общая с ним — см. TnsuPortalAuthorizer. +/// +/// Адрес приёмника уведомлений живёт в SharePoint:NotificationUrl — в одном блоке с +/// остальными настройками SharePoint, а не в своём: подписка относится к спискам, +/// а не к порталу как таковому. +/// +/// Ошибки НЕ пробрасываются наружу. Список к этому моменту уже создан, и уронить +/// провижининг из-за недоступности портала значило бы оставить партиционирование +/// нерабочим целиком (задача Hangfire ушла бы в повторы, а порог по элементам +/// продолжал бы срабатывать). Поэтому сбой — Error в лог с полным контекстом +/// (имя списка и сайт видны, подписку можно завести руками), а провижининг идёт +/// дальше. Отдельно предупреждаем про 401/403: в фоновой задаче нет входящего +/// токена, а client_credentials требует заполненных TnsuPortal:ClientSecret/Scope +/// (в конфигурации они пустые — см. TnsuPortalAuthorizer). +/// +public sealed class HttpSharePointWebhookSubscriber( + HttpClient httpClient, + TnsuPortalAuthorizer authorizer, + IOptions options, + ILogger logger) : ISharePointWebhookSubscriber +{ + public async Task TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken) + { + var notificationUrl = options.Value.NotificationUrl; + if (string.IsNullOrWhiteSpace(notificationUrl)) + { + // Без адреса приёмника подписка бессмысленна — он же служит выключателем + // (см. SharePointOptions.NotificationUrl). + logger.LogInformation( + "SharePoint:NotificationUrl не задан — список «{ListTitle}» на уведомления не подписан.", + listTitle); + return false; + } + + if (string.IsNullOrWhiteSpace(listTitle)) + { + // Без имени списка ручке нечего подписывать: у неё нет другого способа + // его найти (адрес списка она не принимает). + logger.LogError("Заголовок нового списка пуст — подписка на уведомления невозможна."); + return false; + } + + // Полный адрес сайта целиком («https://tnsukz.sharepoint.com/sites/requests»), + // а не короткое имя — именно это ждёт ручка. + var site = siteUrl.TrimEnd('/'); + var url = + $"/Webhook/Subscribe?listName={Uri.EscapeDataString(listTitle)}" + + $"&url={Uri.EscapeDataString(site)}" + + $"¬ificationUrl={Uri.EscapeDataString(notificationUrl)}"; + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, url); + await authorizer.AuthorizeAsync(request, cancellationToken); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + // Тело читаем именно на неуспехе — в нём причина отказа портала. + var body = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogError( + "Портал отказал в подписке списка «{ListTitle}» (сайт {Site}): {StatusCode}. Ответ: {Body}", + listTitle, site, (int)response.StatusCode, body); + return false; + } + + logger.LogInformation( + "Список «{ListTitle}» (сайт {Site}) подписан на уведомления SharePoint.", listTitle, site); + return true; + } + catch (Exception ex) + { + logger.LogError( + ex, + "Не удалось подписать список «{ListTitle}» (сайт {Site}) на уведомления — подпишите вручную.", + listTitle, site); + return false; + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 24bd5e7..40c5b9c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -1,17 +1,14 @@ 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 + TnsuPortalAuthorizer authorizer) : ITnsuPortalClient { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; @@ -25,12 +22,12 @@ public sealed class HttpTnsuPortalClient( 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) + 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; + return new EmployeeLookupResult(employee is not null, employee?.Email, employee?.Fio); } public async Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) @@ -73,7 +70,7 @@ public sealed class HttpTnsuPortalClient( private async Task GetAsync(string requestUri, CancellationToken cancellationToken) where T : class { using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); - await AuthorizeAsync(request, cancellationToken); + await authorizer.AuthorizeAsync(request, cancellationToken); using var response = await httpClient.SendAsync(request, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) @@ -89,25 +86,13 @@ public sealed class HttpTnsuPortalClient( 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); + [property: JsonPropertyName("position")] string? Position, + [property: JsonPropertyName("fio")] string? Fio); private sealed record ManagerResponse( [property: JsonPropertyName("managerEmail")] string? ManagerEmail); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ISharePointWebhookSubscriber.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ISharePointWebhookSubscriber.cs new file mode 100644 index 0000000..890273d --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ISharePointWebhookSubscriber.cs @@ -0,0 +1,16 @@ +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// Подписывает список данных SharePoint на уведомления об изменениях через портал. +/// +public interface ISharePointWebhookSubscriber +{ + /// + /// Подписать список. НЕ бросает: подписка — следствие создания списка, а не его + /// условие (см. реализацию), поэтому сбой портала не должен рушить провижининг + /// партиции. Возвращает true, только если портал подтвердил подписку. + /// + /// Абсолютный адрес сайта, где создан список. + /// Заголовок списка данных — русское имя, как в SharePoint. + Task TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken); +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs index 502affa..2821d9d 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs @@ -4,7 +4,7 @@ public interface ITnsuPortalClient { Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken); - Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken); + Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken); Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalAuthorizer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalAuthorizer.cs new file mode 100644 index 0000000..124f70b --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalAuthorizer.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Headers; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// Авторизация запросов к api.tnsu.kz. Вынесена из HttpTnsuPortalClient, потому что +/// к порталу ходит не только справочник сотрудников (см. +/// HttpSharePointWebhookSubscriber), а правило выбора токена должно быть одно. +/// +/// Токен берётся в двух режимах, и порядок принципиален: +/// 1) Токен вызывающего пользователя (см. TnsuPortalCallerToken) — из текущего +/// HTTP-запроса либо переданный аргументом в фоновую задачу. Портал тогда +/// видит исходного пользователя, а не сервисное приложение. +/// 2) Его нет вовсе — client_credentials самого сервиса (см. +/// TnsuPortalTokenProvider). +/// +/// ВАЖНО про второй путь: TnsuPortal:ClientSecret/Scope в конфигурации пусты +/// (см. appsettings.json), поэтому он сейчас нерабочий — это именно фолбэк, а не +/// штатный режим. Вызывающая сторона обязана это учитывать: см. +/// HttpSharePointWebhookSubscriber, где сбой подписки намеренно не рушит +/// провижининг списка. +/// +public sealed class TnsuPortalAuthorizer( + TnsuPortalCallerToken callerToken, + TnsuPortalTokenProvider tokenProvider) +{ + public async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var caller = callerToken.Current; + if (!string.IsNullOrWhiteSpace(caller)) + { + request.Headers.TryAddWithoutValidation("Authorization", caller); + return; + } + + var token = await tokenProvider.GetAccessTokenAsync(cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalCallerToken.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalCallerToken.cs new file mode 100644 index 0000000..882aa64 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalCallerToken.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Http; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// Authorization, с которым идти в api.tnsu.kz в пределах текущей области DI. +/// +/// Два источника, и второй нужен именно фоновым задачам: +/// • обрабатывается HTTP-запрос — заголовок берётся прямо из него; +/// • выполняется задача Hangfire — своего HttpContext'а у неё нет, поэтому +/// токен инициировавшего пользователя ей передают аргументом и кладут сюда +/// через (см. CreateNextListPartitionJob). +/// +/// Scoped: Hangfire создаёт отдельную область DI на каждое выполнение задачи, +/// поэтому положенный здесь токен не протекает между задачами. +/// +public sealed class TnsuPortalCallerToken(IHttpContextAccessor httpContextAccessor) +{ + private string? _explicit; + + /// + /// Заголовок целиком, вида «Bearer …». null — вызывающего токена нет + /// (тогда авторизация уходит на client_credentials, см. TnsuPortalAuthorizer). + /// + public string? Current + { + get + { + var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); + return string.IsNullOrWhiteSpace(incoming) ? _explicit : incoming; + } + } + + /// Положить токен явно — для кода без HttpContext (фоновые задачи). + public void Set(string? authorization) => + _explicit = string.IsNullOrWhiteSpace(authorization) ? null : authorization; +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs index 29fe375..9a0636b 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs @@ -5,3 +5,6 @@ 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); + +/// Найден ли сотрудник вообще — отдельно от того, заполнен ли у него email в портале (это разные проблемы). +public sealed record EmployeeLookupResult(bool Found, string? Email, string? Fio); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitionRegistryTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitionRegistryTests.cs new file mode 100644 index 0000000..0fe9619 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitionRegistryTests.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using Tnsu.ApprovalRoute.Application.ListPartitioning; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +/// +/// request_list_partitions хранит ТОЛЬКО архив — партиция, которая ни разу не +/// архивировалась, не должна получать строку просто от того, что её прочитали +/// (см. ListPartitionRegistry). Раньше самозаполняющийся реестр писал строку +/// уже на первое чтение, из-за чего один и тот же список оказывался записан +/// дважды: и в RequestType.ListId, и строкой в этой таблице. +/// +public sealed class ListPartitionRegistryTests(PostgresDatabaseFixture fixture) : IClassFixture +{ + private readonly ApprovalRouteDbContext _db = + TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService(); + + private ListPartitionRegistry BuildRegistry() => + new(_db, Mock.Of(), NullLogger.Instance); + + private async Task CreateTypeAsync(string listId) + { + var type = new RequestType + { + Name = $"Тест {Guid.NewGuid():N}", + PageUrl = $"/test-{Guid.NewGuid():N}", + ListId = listId, + SiteUrl = "https://tnsukz.sharepoint.com/sites/requests", + Section = "General" + }; + _db.RequestTypes.Add(type); + await _db.SaveChangesAsync(); + return type; + } + + [Fact(DisplayName = "Партиция, которую ни разу не архивировали, не пишется в request_list_partitions")] + public async Task BuildCurrentAsync_NeverArchived_DoesNotWriteRow() + { + // Arrange + var registry = BuildRegistry(); + var type = await CreateTypeAsync("/sites/requests/Lists/1"); + + // Action + var archived = await registry.GetArchivedAsync(type, CancellationToken.None); + var current = await registry.BuildCurrentAsync(type, archived, CancellationToken.None); + + // Assert + archived.ShouldBeEmpty(); + current.Number.ShouldBe(1); + current.ListId.ShouldBe(type.ListId); + current.IsCurrent.ShouldBeTrue(); + + var rowsInDb = await _db.RequestListPartitions + .Where(x => x.RequestTypeId == type.Id) + .CountAsync(); + rowsInDb.ShouldBe(0); + } + + [Fact(DisplayName = "ArchiveCurrentAsync пишет ровно одну строку архива под номером 1")] + public async Task ArchiveCurrentAsync_WritesExactlyOneRow() + { + // Arrange + var registry = BuildRegistry(); + var type = await CreateTypeAsync("/sites/requests/Lists/1"); + + // Action + var archived = await registry.ArchiveCurrentAsync(type, CancellationToken.None); + + // Assert + archived.Number.ShouldBe(1); + archived.ListId.ShouldBe("/sites/requests/Lists/1"); + + var rowsInDb = await _db.RequestListPartitions + .Where(x => x.RequestTypeId == type.Id) + .ToListAsync(); + rowsInDb.Count.ShouldBe(1); + rowsInDb[0].ListId.ShouldBe("/sites/requests/Lists/1"); + } + + [Fact(DisplayName = "ArchiveCurrentAsync идемпотентна — повтор не создаёт вторую строку")] + public async Task ArchiveCurrentAsync_CalledTwice_DoesNotDuplicate() + { + // Arrange + var registry = BuildRegistry(); + var type = await CreateTypeAsync("/sites/requests/Lists/1"); + + // Action — как повторная попытка Hangfire после частичного сбоя: пока указатель + // ещё не переключён, GetArchivedAsync только что записанную строку не увидит + // (фильтрует по ListId != RequestType.ListId) — именно поэтому идемпотентность + // ArchiveCurrentAsync не должна зависеть от него (см. её doc-комментарий). + await registry.ArchiveCurrentAsync(type, CancellationToken.None); + await registry.ArchiveCurrentAsync(type, CancellationToken.None); + + // Assert + var rowsInDb = await _db.RequestListPartitions + .Where(x => x.RequestTypeId == type.Id) + .CountAsync(); + rowsInDb.ShouldBe(1); + } + + [Fact(DisplayName = "После архивации и переключения текущая партиция получает следующий номер")] + public async Task BuildCurrentAsync_AfterArchiving_ReturnsNextNumber() + { + // Arrange: тип с одной партицией, которую сейчас архивируем (как + // CreateNextListPartitionJob перед переключением на следующую). + var registry = BuildRegistry(); + var type = await CreateTypeAsync("/sites/requests/Lists/1"); + await registry.ArchiveCurrentAsync(type, CancellationToken.None); + + // Action: переключаем указатель на новую партицию (сама она архивной + // строки ещё не получает — см. doc-комментарий ListPartitionRegistry). + type.ListId = "/sites/requests/Lists/2"; + await _db.SaveChangesAsync(); + + var archived = await registry.GetArchivedAsync(type, CancellationToken.None); + var current = await registry.BuildCurrentAsync(type, archived, CancellationToken.None); + + // Assert + archived.Count.ShouldBe(1); + archived[0].ListId.ShouldBe("/sites/requests/Lists/1"); + current.Number.ShouldBe(2); + current.ListId.ShouldBe("/sites/requests/Lists/2"); + + var rowsInDb = await _db.RequestListPartitions + .Where(x => x.RequestTypeId == type.Id) + .CountAsync(); + rowsInDb.ShouldBe(1); // новая партиция строки ещё не получила + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitioningLockTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitioningLockTests.cs new file mode 100644 index 0000000..932626f --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitioningLockTests.cs @@ -0,0 +1,120 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +/// +/// Блокировка провижининга — единственное, что не даёт двум фоновым задачам создать +/// по партиции каждая, поэтому проверяется именно на живом Postgres: смысл здесь +/// несёт атомарность условного UPDATE, а не код на C#. +/// +public sealed class ListPartitioningLockTests(PostgresDatabaseFixture fixture) + : IClassFixture +{ + private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30); + + private readonly ServiceProvider _provider = TestServiceProviderFactory.Build(fixture.ConnectionString); + + [Fact(DisplayName = "Повторный захват занятой блокировки не удаётся")] + public async Task TryAcquire_WhenAlreadyHeld_ReturnsFalse() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync(); + + // Action + var first = await TryAcquireAsync(requestTypeId); + var second = await TryAcquireAsync(requestTypeId); + + // Assert + first.ShouldBeTrue(); + second.ShouldBeFalse(); + } + + [Fact(DisplayName = "При одновременном захвате блокировку получает ровно одна задача")] + public async Task TryAcquire_Concurrently_OnlyOneWins() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync(); + + // Action — каждый захват из своего scope, то есть своим соединением к БД: + // именно так это и происходит у параллельных задач Hangfire. + var results = await Task.WhenAll( + Enumerable.Range(0, 8).Select(_ => TryAcquireAsync(requestTypeId))); + + // Assert + results.Count(x => x).ShouldBe(1); + } + + [Fact(DisplayName = "После снятия блокировку можно захватить снова")] + public async Task TryAcquire_AfterRelease_Succeeds() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync(); + (await TryAcquireAsync(requestTypeId)).ShouldBeTrue(); + + // Action + using (var scope = _provider.CreateScope()) + { + await scope.ServiceProvider.GetRequiredService() + .ReleaseAsync(requestTypeId, CancellationToken.None); + } + + // Assert + (await TryAcquireAsync(requestTypeId)).ShouldBeTrue(); + } + + [Fact(DisplayName = "Просроченная блокировка перехватывается, свежая — нет")] + public async Task TryAcquire_StaleLock_IsTakenOver() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync(); + (await TryAcquireAsync(requestTypeId)).ShouldBeTrue(); + + // Свежую блокировку с тем же порогом перехватить нельзя. + (await TryAcquireAsync(requestTypeId, TimeSpan.FromMinutes(10))).ShouldBeFalse(); + + // Action — отодвигаем метку в прошлое: так выглядит блокировка, которую + // оставил упавший, не снявший её процесс. + using (var scope = _provider.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.ExecuteSqlInterpolatedAsync( + $"UPDATE request_types SET list_partitioning_started_at = now() - interval '1 hour' WHERE id = {requestTypeId}"); + } + + // Assert + (await TryAcquireAsync(requestTypeId, TimeSpan.FromMinutes(10))).ShouldBeTrue(); + } + + [Fact(DisplayName = "Захват блокировки для несуществующего типа заявки не удаётся")] + public async Task TryAcquire_UnknownRequestType_ReturnsFalse() => + (await TryAcquireAsync(999999)).ShouldBeFalse(); + + private async Task TryAcquireAsync(int requestTypeId, TimeSpan? staleAfter = null) + { + using var scope = _provider.CreateScope(); + return await scope.ServiceProvider.GetRequiredService() + .TryAcquireAsync(requestTypeId, staleAfter ?? StaleAfter, CancellationToken.None); + } + + private async Task CreateRequestTypeAsync() + { + using var scope = _provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var entity = new RequestType + { + Name = $"Тип {Guid.NewGuid():N}", + PageUrl = "/test", + Section = "General" + }; + db.RequestTypes.Add(entity); + await db.SaveChangesAsync(); + + return entity.Id; + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index 57d4861..ce1f2bc 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Shouldly; +using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Infrastructure.Persistence; @@ -102,6 +103,7 @@ public sealed class ReminderServiceTests : IClassFixture(), sharePoint.Object, + _services.GetRequiredService(), Options.Create(new RemindersOptions()), NullLogger.Instance); } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/SharePointWebhookSubscriberTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/SharePointWebhookSubscriberTests.cs new file mode 100644 index 0000000..996c5dd --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/SharePointWebhookSubscriberTests.cs @@ -0,0 +1,189 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Shouldly; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +/// +/// Подписка нового списка данных на уведомления SharePoint (POST /Webhook/Subscribe). +/// +/// Проверяется ровно то, что нельзя увидеть по типам: как складывается строка +/// запроса (ручка принимает всё параметрами, тела нет) и что сбой портала не рушит +/// провижининг — подписка следствие создания списка, а не его условие. +/// +public sealed class SharePointWebhookSubscriberTests +{ + private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests"; + private const string ListTitle = "Обходной лист 3 — данные"; + + /// Перехватывает запрос вместо сети: запоминает его и отдаёт заданный ответ. + private sealed class StubHandler(Func respond) : HttpMessageHandler + { + public HttpRequestMessage? Captured { get; private set; } + public int CallCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Captured = request; + CallCount++; + return Task.FromResult(respond(request)); + } + } + + // Токен вызывающего есть — тогда авторизатор пробрасывает его и не идёт за + // client_credentials (у которого в тестах нет ни секрета, ни сети). + // fromHttpContext=false — путь фоновой задачи: токен положен явно, HttpContext'а + // нет вовсе (см. TnsuPortalCallerToken.Set / CreateNextListPartitionJob). + private static TnsuPortalAuthorizer BuildAuthorizer(bool fromHttpContext = true) + { + var accessor = new HttpContextAccessor(); + var callerToken = new TnsuPortalCallerToken(accessor); + + if (fromHttpContext) + { + var context = new DefaultHttpContext(); + context.Request.Headers.Authorization = "Bearer test-token"; + accessor.HttpContext = context; + } + else + { + callerToken.Set("Bearer test-token"); + } + + return new TnsuPortalAuthorizer( + callerToken, + new TnsuPortalTokenProvider(new HttpClient(), Options.Create(new TnsuPortalOptions()))); + } + + private static (HttpSharePointWebhookSubscriber Subscriber, StubHandler Handler) Build( + Func respond, + SharePointOptions? options = null, + bool tokenFromHttpContext = true) + { + var handler = new StubHandler(respond); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.tnsu.kz") }; + + var subscriber = new HttpSharePointWebhookSubscriber( + httpClient, + BuildAuthorizer(tokenFromHttpContext), + Options.Create(options ?? new SharePointOptions()), + NullLogger.Instance); + + return (subscriber, handler); + } + + private static HttpResponseMessage Ok() => new(System.Net.HttpStatusCode.OK); + + [Fact(DisplayName = "Подписка уходит POST'ом со всеми тремя параметрами в строке запроса")] + public async Task TrySubscribeAsync_SendsPostWithQueryParameters() + { + // Arrange + var (subscriber, handler) = Build(_ => Ok()); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeTrue(); + handler.Captured!.Method.ShouldBe(HttpMethod.Post); + handler.Captured.Content.ShouldBeNull(); // тела у ручки нет + + var query = Uri.UnescapeDataString(handler.Captured.RequestUri!.Query); + handler.Captured.RequestUri.AbsolutePath.ShouldBe("/Webhook/Subscribe"); + query.ShouldContain($"listName={ListTitle}"); + query.ShouldContain("notificationUrl=https://api.tnsu.kz/Webhook/SharePoint"); + // Токен из входящего запроса пробрасывается как есть (см. TnsuPortalAuthorizer). + handler.Captured.Headers.Authorization?.ToString().ShouldBe("Bearer test-token"); + } + + [Fact(DisplayName = "В url уходит ПОЛНЫЙ адрес сайта, а не короткое имя")] + public async Task TrySubscribeAsync_SendsFullSiteUrl() + { + // Arrange + var (subscriber, handler) = Build(_ => Ok()); + + // Action + await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + var query = Uri.UnescapeDataString(handler.Captured!.RequestUri!.Query); + query.ShouldContain($"url={SiteUrl}"); + } + + [Fact(DisplayName = "Токен фоновой задачи (без HttpContext) тоже уходит в заголовок")] + public async Task TrySubscribeAsync_TokenSetExplicitly_IsSentAsAuthorization() + { + // Arrange: так работает провижининг — HttpContext'а нет, токен передан + // аргументом задачи (см. CreateNextListPartitionJob). + var (subscriber, handler) = Build(_ => Ok(), tokenFromHttpContext: false); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeTrue(); + handler.Captured!.Headers.Authorization?.ToString().ShouldBe("Bearer test-token"); + } + + [Fact(DisplayName = "Отказ портала не бросает исключение — провижининг списка не рушится")] + public async Task TrySubscribeAsync_PortalRejects_ReturnsFalseWithoutThrowing() + { + // Arrange + var (subscriber, _) = Build(_ => new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized) + { + Content = new StringContent("no token") + }); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeFalse(); + } + + [Fact(DisplayName = "Недоступный портал (исключение в сети) тоже не бросает наружу")] + public async Task TrySubscribeAsync_NetworkFailure_ReturnsFalseWithoutThrowing() + { + // Arrange + var (subscriber, _) = Build(_ => throw new HttpRequestException("портал недоступен")); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeFalse(); + } + + [Fact(DisplayName = "NotificationUrl не задан — запрос не отправляется вовсе")] + public async Task TrySubscribeAsync_NoNotificationUrl_DoesNotCallPortal() + { + // Arrange: пустой адрес приёмника — единственный выключатель подписки + // (отдельного флага Enabled нет, см. SharePointOptions.NotificationUrl). + var (subscriber, handler) = Build(_ => Ok(), new SharePointOptions { NotificationUrl = "" }); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeFalse(); + handler.CallCount.ShouldBe(0); + } + + [Fact(DisplayName = "Пустой заголовок списка — подписывать нечего, запроса нет")] + public async Task TrySubscribeAsync_EmptyListTitle_DoesNotCallPortal() + { + // Arrange + var (subscriber, handler) = Build(_ => Ok()); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, " ", CancellationToken.None); + + // Assert + subscribed.ShouldBeFalse(); + handler.CallCount.ShouldBe(0); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index cc77636..a33942c 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -31,6 +31,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture x.GetProjectExecutorsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeLookupResult(false, null, null)); _services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); _service = _services.GetRequiredService(); @@ -560,7 +563,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny())) - .ReturnsAsync("lead@tnsu.kz"); + .ReturnsAsync(new EmployeeLookupResult(true, "lead@tnsu.kz", "Иванов Иван Иванович")); var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); var routeService = services.GetRequiredService(); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs index 7461499..0167ffa 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs @@ -22,6 +22,7 @@ internal static class TestServiceProviderFactory .Build(); var services = new ServiceCollection(); + services.AddLogging(); services.AddApprovalRouteInfrastructure(configuration); services.AddApprovalRouteApplication(); services.AddSingleton(new TestCurrentUser(currentUserEmail)); diff --git a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts index 554cd1a..16a8e9b 100644 --- a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts +++ b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import * as ReactDom from 'react-dom'; import { Version } from '@microsoft/sp-core-library'; -import { type IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane'; +import { type IPropertyPaneConfiguration, PropertyPaneDropdown, PropertyPaneTextField } from '@microsoft/sp-property-pane'; import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base'; import ApprovalAdmin from './components/ApprovalAdmin'; @@ -13,6 +13,13 @@ export interface IApprovalAdminWebPartProps { portalBaseUrl: string; } +// Единственные два адреса, которыми реально пользуются (прод и локальный backend, +// см. backend/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json) — селект +// вместо текстового поля, чтобы переключаться одним кликом, а не перепечатывать +// url и рисковать опечаткой в проде. +const API_BASE_URL_PROD = 'https://api.tnsu.kz/approval-route'; +const API_BASE_URL_LOCAL = 'http://localhost:5080'; + export default class ApprovalAdminWebPart extends BaseClientSideWebPart { public render(): void { @@ -28,9 +35,8 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart { // Прод-адрес по умолчанию: пакуем именно под прод (heft package-solution --production). - // Для локальной разработки переопределите значение в панели свойств веб-части - // (например, на http://localhost:5080 — порт из backend/.../launchSettings.json). - this.properties.apiBaseUrl = this.properties.apiBaseUrl || 'https://api.tnsu.kz/approval-route'; + // Для локальной разработки переключите в панели свойств веб-части на «Локально». + this.properties.apiBaseUrl = this.properties.apiBaseUrl || API_BASE_URL_PROD; this.properties.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0'; this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz'; return Promise.resolve(); @@ -55,9 +61,13 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart): React.ReactNode { return list.items.length; } +// Партиций много на серию (архив + текущая) — считаем суммарно по всем типам +// заявок, а не количество самих серий (это и так есть в requestTypesStat). +function partitionsCount(list: AsyncList): React.ReactNode { + if (list.isLoading || list.error) return texts.common.empty; + return list.items.reduce((sum, series) => sum + series.partitions.length, 0); +} + export const DashboardScreen: React.FC = ({ api, onNavigate }) => { const requestTypes = useRequestTypes(api); const routes = useTemplateRoutes(api); + const listPartitions = useRequestTypeListsAll(api); return (
+
{texts.dashboard.sections}
@@ -52,6 +63,12 @@ export const DashboardScreen: React.FC = ({ api, onNaviga description={texts.dashboard.resolveCard.description} onClick={() => onNavigate('resolve')} /> + onNavigate('manageRequests')} + />
); diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss index a3aa861..b1c987e 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss @@ -32,6 +32,21 @@ margin-top: 6px; } +// Ссылка на физический список-партицию — второстепенная деталь под заголовком +// заявки, поэтому приглушённая, а не акцентная (как обычная ссылка $blue-600). +.listLink { + display: inline-block; + margin-top: 4px; + font-size: 12px; + color: $gray-500; + text-decoration: none; + + &:hover { + color: $blue-600; + text-decoration: underline; + } +} + // Колонка с номером шага — узкая, содержимое по центру (важно при rowSpan на // параллельный блок: иначе номер прижимался бы к верхней строке блока). .stepCol { diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx index 757bc97..e14cb84 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx @@ -560,6 +560,13 @@ export const ManageRequestsScreen: React.FC = ({ api {matrix.request.status || texts.common.empty} + {/* Список — партиция (см. ТЗ по архивации SharePoint-списков): по мере + роста заявки одного типа переезжают на новый список, и без этой + ссылки не всегда очевидно, какой именно список сейчас открыт. */} + + {texts.manageRequests.listLinkLabel(matrix.dataListTitle)} + + {readOnly && {texts.manageRequests.fullyClosedNotice}} {!matrix.approvalListTitle && {texts.manageRequests.noApprovalList}} diff --git a/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeListsAll.ts b/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeListsAll.ts new file mode 100644 index 0000000..87b63a5 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeListsAll.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { RequestTypeListSeries } from '../models'; +import { ApprovalRouteApi } from '../services/api'; +import { AsyncList, useAsyncList } from './useAsyncList'; + +/** Серии партиций списков сразу по всем типам заявок — сейчас нужен только дашборду (счётчик партиций). */ +export function useRequestTypeListsAll(api: ApprovalRouteApi): AsyncList { + const load = React.useCallback(() => api.requestTypes.listsAll(), [api]); + return useAsyncList(load); +} diff --git a/frontend/src/webparts/approvalAdmin/models/index.ts b/frontend/src/webparts/approvalAdmin/models/index.ts index 6a3c328..c800bf9 100644 --- a/frontend/src/webparts/approvalAdmin/models/index.ts +++ b/frontend/src/webparts/approvalAdmin/models/index.ts @@ -2,6 +2,7 @@ export * from './enums'; export * from './dictionaries'; export * from './requestTypes'; +export * from './requestTypeLists'; export * from './templateRoutes'; export * from './templateRouteSteps'; export * from './resolve'; diff --git a/frontend/src/webparts/approvalAdmin/models/requestManage.ts b/frontend/src/webparts/approvalAdmin/models/requestManage.ts index c02fec6..19f9078 100644 --- a/frontend/src/webparts/approvalAdmin/models/requestManage.ts +++ b/frontend/src/webparts/approvalAdmin/models/requestManage.ts @@ -127,6 +127,13 @@ export interface ManagedRequestMatrix { siteUrl: string; /** Title списка «… — данные» (для тела запроса к потоку оповещения, см. RequestManageService.notifyRow). */ dataListTitle: string; + /** + * Абсолютный URL списка данных — заявки нумерованные (списки-партиции, см. + * ТЗ по архивации), и по мере роста текущий список меняется. Ссылка нужна, + * чтобы администратор видел, в каком физическом списке лежит открытая + * заявка, и мог перейти туда напрямую, не выясняя это вручную. + */ + dataListUrl: string; /** Title списка «… — согласование» (вычислен из Title списка данных). */ approvalListTitle: string; /** Обе фазы вместе, упорядочены по StepOrder. */ diff --git a/frontend/src/webparts/approvalAdmin/models/requestTypeLists.ts b/frontend/src/webparts/approvalAdmin/models/requestTypeLists.ts new file mode 100644 index 0000000..9462388 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/models/requestTypeLists.ts @@ -0,0 +1,16 @@ +// DTO эндпоинта api/request-types/lists (Application/ListPartitioning/*.cs) — +// нумерованная серия SharePoint-списков (партиций) по типу заявки: архив + текущий. +export interface RequestListPartition { + number: number; + siteUrl: string; + listId: string; + // eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable JSON-контракт бэкенда (Title? в RequestListPartition) + title: string | null; + isCurrent: boolean; + createdAt: string; +} + +export interface RequestTypeListSeries { + requestTypeId: number; + partitions: RequestListPartition[]; +} diff --git a/frontend/src/webparts/approvalAdmin/services/api.ts b/frontend/src/webparts/approvalAdmin/services/api.ts index 42a254f..adc80bc 100644 --- a/frontend/src/webparts/approvalAdmin/services/api.ts +++ b/frontend/src/webparts/approvalAdmin/services/api.ts @@ -7,6 +7,7 @@ import { PatchTemplateRouteInput, PatchTemplateRouteStepInput, RequestType, + RequestTypeListSeries, ResolveTemplateRouteQuery, ResolveTemplateRouteResult, TemplateRoute, @@ -39,7 +40,14 @@ export class ApprovalRouteApi { this.http.patch(`/api/request-types/${id}`, body), remove: (id: number): Promise => - this.http.delete(`/api/request-types/${id}`) + this.http.delete(`/api/request-types/${id}`), + + // Серии партиций СРАЗУ по всем типам заявок (см. + // ListPartitionService.ListAllAsync) — для дашборда (счётчик партиций) и + // портала, которому нужен весь список адресов разом, а не по одному запросу + // на тип. + listsAll: (): Promise => + this.http.get('/api/request-types/lists') }; public readonly templateRoutes = { diff --git a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts index cbdd7e8..47f6b42 100644 --- a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts +++ b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts @@ -207,8 +207,17 @@ export class RequestManageService { this.listTitles(sp, requestType.listId) ]); + const dataListUrl = listAbsUrl(requestType.siteUrl, requestType.listId); + if (!approvalListTitle) { - return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, approvalListTitle: '', rows: [] }; + return { + request: toSummary(requestRow), + siteUrl: requestType.siteUrl, + dataListTitle, + dataListUrl, + approvalListTitle: '', + rows: [] + }; } const approvalList = sp.web.lists.getByTitle(approvalListTitle); @@ -255,7 +264,7 @@ export class RequestManageService { }; }); - return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, approvalListTitle, rows }; + return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows }; } /** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */ diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 6dc7f2b..2b76a51 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -89,6 +89,7 @@ export const texts = { dashboard: { requestTypesStat: 'Типов заявок', routesStat: 'Маршрутов согласования', + listPartitionsStat: 'Партиций списков SharePoint', sections: 'Разделы', requestTypesCard: { title: 'Типы заявок', @@ -101,6 +102,10 @@ export const texts = { resolveCard: { title: 'Проверка маршрута', description: 'Как разрешится маршрут для конкретного пользователя' + }, + manageRequestsCard: { + title: 'Редактирование заявок', + description: 'Правка маршрута согласования конкретной заявки' } }, @@ -385,6 +390,7 @@ export const texts = { requestDisabledHint: 'Сначала выберите тип заявки.', noApprovalList: 'У этого типа заявки список согласования не найден (Title списка данных не оканчивается на «— данные»).', requestStatusLabel: 'Статус заявки', + listLinkLabel: (listTitle: string): string => `Список: ${listTitle}`, fullyClosedNotice: 'Заявка полностью закрыта — маршрут доступен только для просмотра.', approvalPhaseTitle: 'Согласование', executionPhaseTitle: 'Исполнение',