diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/BasisRequestsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/BasisRequestsController.cs new file mode 100644 index 0000000..c3011f9 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/BasisRequestsController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Auth; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.BasisRequests; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +/// +/// Заявки, поданные по одному основанию (СТ, потребность). Видны все, независимо +/// от прав вызывающего на элементы; CanOpen говорит, может ли он открыть заявку. +/// Только пользовательский токен: без email права проверять не для кого. +/// +[ApiController] +[Authorize(Policy = AuthPolicies.Delegated)] +[Route("api/basis-requests")] +[Produces("application/json")] +public sealed class BasisRequestsController(BasisRequestsService service) : ControllerBase +{ + /// Заявки типа requestTypeId с BasisKey = basisKey по всем партициям. + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + public async Task Get( + [FromQuery] int requestTypeId, + // string? — пустой параметр фронтенд вырезает из URL целиком (см. RequestManageController.Search). + [FromQuery] string? basisKey, + CancellationToken ct) => + (await service.GetByBasisAsync(requestTypeId, basisKey ?? string.Empty, ct)).ToActionResult(); +} diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index 64527fc..f556e81 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.Approvals; +using Tnsu.ApprovalRoute.Application.BasisRequests; using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.Reports; @@ -21,6 +22,7 @@ public static class ApplicationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); // Джоба резолвится Hangfire'ом из scope на каждое выполнение. diff --git a/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestDtos.cs b/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestDtos.cs new file mode 100644 index 0000000..210152c --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestDtos.cs @@ -0,0 +1,20 @@ +namespace Tnsu.ApprovalRoute.Application.BasisRequests; + +/// +/// Заявка, поданная по тому же основанию (СТ, потребность). Номер, статус и сумму +/// видят все: от них зависит проверка «сумма оплат не больше основания». Ссылку +/// на саму заявку фронтенд показывает только при CanOpen. +/// +/// Id элемента списка данных. +/// Серверный относительный URL списка партиции, где лежит заявка. +/// RequestUniqueCode; null — номер ещё не присвоен. +/// RequestStatus заявки. +/// Сумма заявки (PayAmount). +/// У вызывающего есть право просмотра элемента. +public sealed record BasisRequestDto( + int Id, + string ListId, + string? UniqueCode, + string Status, + double Amount, + bool CanOpen); diff --git a/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestsService.cs b/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestsService.cs new file mode 100644 index 0000000..77cd100 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestsService.cs @@ -0,0 +1,116 @@ +using System.Globalization; +using System.Text.Json; +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; +using Tnsu.ApprovalRoute.Application.ListPartitioning; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +namespace Tnsu.ApprovalRoute.Application.BasisRequests; + +/// +/// Заявки, поданные по одному основанию (колонка BasisKey: «ct:<Id>», +/// «need:<oid>») — для таблицы прежних оплат в «Заявке на оплату». +/// +/// Читаем от имени ПРИЛОЖЕНИЯ: права на элементы заявок разорваны поштучно, и +/// под своей учёткой заявитель видел бы только собственные заявки — сумма +/// прежних оплат по СТ занижалась бы, а проверка превышения пропускала бы +/// переплату. Открыть чужую заявку при этом может только тот, у кого на неё +/// есть права: CanOpen проверяется по каждому элементу для вызывающего. +/// +public sealed class BasisRequestsService( + ApprovalRouteDbContext db, + ISharePointClient sharePoint, + ListPartitionRegistry partitionRegistry, + ICurrentUser currentUser) +{ + private const string BasisKeyField = "BasisKey"; + private const string AmountField = "PayAmount"; + + // Больше по одному основанию не бывает: СТ оплачивают частями, но не сотнями. + private const int MaxItemsPerPartition = 500; + + // Проверка прав — отдельный запрос на элемент; ограничиваем параллельность, + // чтобы не упереться в троттлинг SharePoint (429). + private const int MaxParallelPermissionChecks = 4; + + public async Task>> GetByBasisAsync( + int requestTypeId, string basisKey, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(basisKey)) + return Result.Fail>(new ValidationError("Не указано основание (basisKey).")); + + var requestType = await db.RequestTypes.AsNoTracking() + .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.Ok>([]); + + var siteUrl = requestType.SiteUrl; + var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken); + var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken); + var listIds = archived.Select(x => x.ListId).Append(current.ListId).ToList(); + + // Кавычка в OData-литерале удваивается; сам литерал кодируем — в ключе бывают «:» и прочее. + var literal = Uri.EscapeDataString(basisKey.Trim().Replace("'", "''")); + var query = $"$select=Id,RequestUniqueCode,RequestStatus,{AmountField}" + + $"&$filter={BasisKeyField} eq '{literal}'&$orderby=Id&$top={MaxItemsPerPartition}"; + + var found = new List<(JsonElement Item, string ListId)>(); + foreach (var listId in listIds) + { + var items = await sharePoint.GetItemsByListUrlAsync(siteUrl, listId, query, cancellationToken); + found.AddRange(items.Select(i => (i, listId))); + } + + var email = currentUser.Email; + using var gate = new SemaphoreSlim(MaxParallelPermissionChecks); + var result = await Task.WhenAll(found.Select(async x => + { + var id = GetInt(x.Item, "Id") ?? 0; + var canOpen = false; + if (!string.IsNullOrWhiteSpace(email) && id > 0) + { + await gate.WaitAsync(cancellationToken); + try + { + canOpen = await sharePoint.CanUserViewItemAsync(siteUrl, x.ListId, id, email, cancellationToken); + } + finally + { + gate.Release(); + } + } + + return new BasisRequestDto( + id, + x.ListId, + GetString(x.Item, "RequestUniqueCode"), + GetString(x.Item, "RequestStatus") ?? string.Empty, + GetDouble(x.Item, AmountField) ?? 0, + canOpen); + })); + + return Result.Ok>(result); + } + + private static string? GetString(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + private static int? GetInt(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null; + + private static double? GetDouble(JsonElement e, string name) + { + if (!e.TryGetProperty(name, out var v)) return null; + if (v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var d)) return d; + if (v.ValueKind == JsonValueKind.String && + double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + return parsed; + return null; + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs index db6a2fb..dd5668b 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs @@ -54,6 +54,14 @@ public interface ISharePointClient Task> GetItemsByListTitleAsync( string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken); + /// + /// Может ли пользователь (по email) открыть элемент списка: эффективные права + /// человека на элемент с учётом разорванного наследования, флаг ViewListItems. + /// Неизвестный SharePoint логин — не ошибка, а «прав нет». + /// + Task CanUserViewItemAsync( + string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken); + /// /// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ /// элементы, а не только доступные пользователю. Возвращает строки результата @@ -229,6 +237,22 @@ public sealed class SharePointClient( $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}", cancellationToken); + public async Task CanUserViewItemAsync( + string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken) + { + // Claims-логин Entra ID в SharePoint Online. Передаём через параметр-псевдоним + // @u: в пути функции «|» и «#» SharePoint не разбирает. + var login = Uri.EscapeDataString($"i:0#.f|membership|{userEmail.Trim().ToLowerInvariant()}"); + var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})" + + $"/getUserEffectivePermissions(@u)?@u='{login}'"; + var json = await GetAsync(siteUrl, url, cancellationToken); + + // BasePermissions приходит как {High, Low} — числа строками. ViewListItems — бит 0x1 в Low. + return json.TryGetProperty("Low", out var low) + && ulong.TryParse(low.ValueKind == JsonValueKind.String ? low.GetString() : low.GetRawText(), out var bits) + && (bits & ViewListItemsMask) != 0; + } + public async Task>> SearchAsync( string siteUrl, string queryText, IReadOnlyList selectProperties, CancellationToken cancellationToken) { @@ -426,6 +450,8 @@ public sealed class SharePointClient( } } + private const ulong ViewListItemsMask = 0x1; + // Одинарная кавычка в OData-литерале экранируется её удвоением. Заголовки // списков содержат тире и кириллицу, но кавычка теоретически возможна — // без экранирования она сломала бы запрос. diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/BasisRequestsServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/BasisRequestsServiceTests.cs new file mode 100644 index 0000000..f41094b --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/BasisRequestsServiceTests.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Shouldly; +using Tnsu.ApprovalRoute.Application.BasisRequests; +using Tnsu.ApprovalRoute.Application.ListPartitioning; +using Tnsu.ApprovalRoute.Application.RequestTypes; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +/// +/// Заявки по одному основанию: видны все (чтение от имени приложения), а +/// CanOpen — только там, где у вызывающего есть права на элемент. +/// +public sealed class BasisRequestsServiceTests : IClassFixture +{ + private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests"; + private const string CallerEmail = "initiator@tnsu.kz"; + private const string BasisKey = "need:8028c7ac-ac23-8dd8-11f0-946facc42464"; + + private readonly ServiceProvider _services; + + public BasisRequestsServiceTests(PostgresDatabaseFixture fixture) + { + _services = TestServiceProviderFactory.Build(fixture.ConnectionString, CallerEmail); + } + + private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value); + + private async Task<(BasisRequestsService Service, Mock SharePoint, int TypeId)> BuildAsync( + string name, string listUrl, IReadOnlyList items, IReadOnlySet openableIds) + { + var type = await _services.GetRequiredService().CreateAsync( + new CreateRequestTypeRequest($"Оплата {name}", $"/оплата-{name}", listUrl, SiteUrl, "General"), + CancellationToken.None); + + var sharePoint = new Mock(); + sharePoint + .Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny(), It.IsAny())) + .ReturnsAsync(items.Select(Json).ToList()); + sharePoint + .Setup(x => x.CanUserViewItemAsync(SiteUrl, listUrl, It.IsAny(), CallerEmail, It.IsAny())) + .ReturnsAsync((string _, string _, int id, string _, CancellationToken _) => openableIds.Contains(id)); + + var service = new BasisRequestsService( + _services.GetRequiredService(), + sharePoint.Object, + _services.GetRequiredService(), + new TestCurrentUser(CallerEmail)); + + return (service, sharePoint, type.Value.Id); + } + + [Fact] + public async Task Returns_all_requests_and_marks_only_permitted_as_openable() + { + const string list = "/sites/requests/Lists/Оплата-all"; + var (service, sharePoint, typeId) = await BuildAsync("all", list, + [ + // Number-колонка приходит дробной («50000.0») — тест держит её такой же. + new { Id = 1, RequestUniqueCode = "24-2610-1", RequestStatus = "Согласовано", PayAmount = 50000.0 }, + new { Id = 2, RequestUniqueCode = "24-2610-2", RequestStatus = "На согласовании", PayAmount = 35000.5 } + ], new HashSet { 2 }); + + var result = await service.GetByBasisAsync(typeId, BasisKey, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Count.ShouldBe(2); + result.Value.Single(x => x.Id == 1).ShouldBe(new BasisRequestDto(1, list, "24-2610-1", "Согласовано", 50000, false)); + result.Value.Single(x => x.Id == 2).ShouldBe(new BasisRequestDto(2, list, "24-2610-2", "На согласовании", 35000.5, true)); + + // Фильтр по основанию уходит в SharePoint, а не применяется в памяти. + sharePoint.Verify(x => x.GetItemsByListUrlAsync( + SiteUrl, list, + It.Is(q => q.Contains("BasisKey eq 'need%3A8028c7ac-ac23-8dd8-11f0-946facc42464'")), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Empty_basis_key_is_a_validation_error() + { + var (service, _, typeId) = await BuildAsync("empty", "/sites/requests/Lists/Оплата-empty", [], new HashSet()); + + var result = await service.GetByBasisAsync(typeId, " ", CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + } +}