Отдавать в /api/request-type-access список SharePoint-списков типа

Доступ здесь настраивает только видимость в UI approval-route — права
SharePoint остаются ручным шагом, и раньше администратору приходилось
отдельно смотреть, в какие списки данных (архив + текущая партиция)
заводить чтение для email. Теперь RequestTypeAccessDto несёт готовую
серию (переиспользует ListPartitionService), и админка показывает её
колонкой в таблице.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-09-25 15:28:49 +05:00
parent 58691216ed
commit f8f9d20d14
5 changed files with 65 additions and 5 deletions
@@ -1,5 +1,18 @@
using Tnsu.ApprovalRoute.Application.ListPartitioning;
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses; namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
public sealed record RequestTypeAccessDto(int Id, int RequestTypeId, string Email); /// <param name="SharePointLists">
/// Вся серия SharePoint-списков данных (архив + текущая партиция) типа заявки —
/// именно на них нужно выдать email'у право чтения, иначе поиск по порталу
/// продолжит отдавать только его собственные заявки: доступ здесь настраивает
/// видимость в UI approval-route, но не права SharePoint, которые остаются
/// отдельным ручным шагом.
/// </param>
public sealed record RequestTypeAccessDto(
int Id,
int RequestTypeId,
string Email,
IReadOnlyList<RequestListPartitionDto> SharePointLists);
public sealed record CreateRequestTypeAccessRequest(int RequestTypeId, string Email); public sealed record CreateRequestTypeAccessRequest(int RequestTypeId, string Email);
@@ -3,26 +3,53 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses; namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
public sealed class RequestTypeAccessService( public sealed class RequestTypeAccessService(
ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger<RequestTypeAccessService> logger) ApprovalRouteDbContext db,
ListPartitionService partitions,
ICurrentUser currentUser,
ILogger<RequestTypeAccessService> logger)
{ {
public async Task<Result<IReadOnlyList<RequestTypeAccessDto>>> ListAsync( public async Task<Result<IReadOnlyList<RequestTypeAccessDto>>> ListAsync(
int? requestTypeId, CancellationToken cancellationToken) int? requestTypeId, CancellationToken cancellationToken)
{ {
var items = await db.RequestTypeAccesses.AsNoTracking() var entities = await db.RequestTypeAccesses.AsNoTracking()
.Where(x => requestTypeId == null || x.RequestTypeId == requestTypeId) .Where(x => requestTypeId == null || x.RequestTypeId == requestTypeId)
.OrderBy(x => x.RequestTypeId).ThenBy(x => x.Email) .OrderBy(x => x.RequestTypeId).ThenBy(x => x.Email)
.Select(x => new RequestTypeAccessDto(x.Id, x.RequestTypeId, x.Email))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// Партиции запрашиваются РАЗ на тип заявки, а не на строку — иначе список
// из N email по одному типу заново обходил бы SharePoint N раз подряд.
var listsByType = new Dictionary<int, IReadOnlyList<RequestListPartitionDto>>();
var items = new List<RequestTypeAccessDto>(entities.Count);
foreach (var e in entities)
{
var lists = await GetListsCachedAsync(listsByType, e.RequestTypeId, cancellationToken);
items.Add(new RequestTypeAccessDto(e.Id, e.RequestTypeId, e.Email, lists));
}
return Result.Ok<IReadOnlyList<RequestTypeAccessDto>>(items); return Result.Ok<IReadOnlyList<RequestTypeAccessDto>>(items);
} }
private async Task<IReadOnlyList<RequestListPartitionDto>> GetListsCachedAsync(
Dictionary<int, IReadOnlyList<RequestListPartitionDto>> cache,
int requestTypeId,
CancellationToken cancellationToken)
{
if (cache.TryGetValue(requestTypeId, out var cached))
return cached;
var result = await partitions.ListAsync(requestTypeId, cancellationToken);
var lists = result.IsSuccess ? result.Value : Array.Empty<RequestListPartitionDto>();
cache[requestTypeId] = lists;
return lists;
}
/// <summary>Список email'ов с доступом к типу заявок — дёргает Power Automate.</summary> /// <summary>Список email'ов с доступом к типу заявок — дёргает Power Automate.</summary>
public async Task<Result<IReadOnlyList<string>>> ListEmailsAsync(int requestTypeId, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<string>>> ListEmailsAsync(int requestTypeId, CancellationToken cancellationToken)
{ {
@@ -58,7 +85,9 @@ public sealed class RequestTypeAccessService(
"Доступ к типу заявки {RequestTypeId} выдан {Email} пользователем {Author}", "Доступ к типу заявки {RequestTypeId} выдан {Email} пользователем {Author}",
entity.RequestTypeId, entity.Email, currentUser.Email); entity.RequestTypeId, entity.Email, currentUser.Email);
return Result.Ok(new RequestTypeAccessDto(entity.Id, entity.RequestTypeId, entity.Email)); var listsResult = await partitions.ListAsync(entity.RequestTypeId, cancellationToken);
var lists = listsResult.IsSuccess ? listsResult.Value : Array.Empty<RequestListPartitionDto>();
return Result.Ok(new RequestTypeAccessDto(entity.Id, entity.RequestTypeId, entity.Email, lists));
} }
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken) public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
@@ -45,6 +45,14 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`; const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
// Список SharePoint нужно вручную открыть на чтение этому email — иначе доступ
// здесь настраивает только видимость в UI approval-route, но не в самом
// SharePoint (поиск по порталу отфильтрует чужие заявки правами списка).
const formatSharePointLists = (item: RequestTypeAccess): string =>
item.sharePointLists.length === 0
? texts.common.empty
: item.sharePointLists.map(l => l.title ?? l.listId).join(', ');
const openCreate = (): void => { const openCreate = (): void => {
setRequestTypeId(''); setRequestTypeId('');
setEmail(''); setEmail('');
@@ -86,6 +94,7 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
columns={[ columns={[
{ key: 'requestType', header: texts.requestTypeAccess.columnRequestType, render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell> }, { key: 'requestType', header: texts.requestTypeAccess.columnRequestType, render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell> },
{ key: 'email', header: texts.requestTypeAccess.columnEmail, render: r => r.email }, { key: 'email', header: texts.requestTypeAccess.columnEmail, render: r => r.email },
{ key: 'sharePointLists', header: texts.requestTypeAccess.columnSharePointLists, render: formatSharePointLists },
{ {
key: 'actions', key: 'actions',
header: '', header: '',
@@ -1,9 +1,17 @@
// DTO эндпоинта api/request-type-access (Application/RequestTypeAccesses/*.cs). // DTO эндпоинта api/request-type-access (Application/RequestTypeAccesses/*.cs).
import { RequestListPartition } from './requestTypeLists';
export interface RequestTypeAccess { export interface RequestTypeAccess {
id: number; id: number;
requestTypeId: number; requestTypeId: number;
email: string; email: string;
/**
* Вся серия SharePoint-списков данных типа заявки (архив + текущая партиция) —
* именно на них нужно вручную выдать email'у право чтения, иначе поиск по
* порталу продолжит отдавать только его собственные заявки: этот реестр
* настраивает видимость в UI approval-route, а не права SharePoint.
*/
sharePointLists: RequestListPartition[];
} }
export interface CreateRequestTypeAccessInput { export interface CreateRequestTypeAccessInput {
@@ -187,6 +187,7 @@ export const texts = {
emptyList: 'Пока никому не выдан доступ ни к одному типу заявок.', emptyList: 'Пока никому не выдан доступ ни к одному типу заявок.',
columnRequestType: 'Тип заявки', columnRequestType: 'Тип заявки',
columnEmail: 'Сотрудник', columnEmail: 'Сотрудник',
columnSharePointLists: 'Списки SharePoint (выдать доступ вручную)',
createTitle: 'Выдать доступ', createTitle: 'Выдать доступ',
requestTypeLabel: 'Тип заявки', requestTypeLabel: 'Тип заявки',
requestTypePlaceholder: 'Выберите тип заявки', requestTypePlaceholder: 'Выберите тип заявки',