Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { spForSite } from '../pnp/pnpjs';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
|
||||
/**
|
||||
* Где лежит список согласования каждой партиции — Title и абсолютный адрес.
|
||||
*
|
||||
* Зачем отдельный модуль. Адрес списка «X — согласование» нельзя вывести из
|
||||
* адреса списка «X — данные»: approval-route хранит ссылку только на список
|
||||
* данных, а сам список согласования SharePoint создаёт под произвольным
|
||||
* номером (…/Lists/List37). Раньше это означало ОТДЕЛЬНЫЙ REST-запрос на
|
||||
* КАЖДУЮ партицию при каждой загрузке очереди согласования, а партиций с
|
||||
* архивацией уже больше десятка (трассировка 2026-08-18: 11 партиций).
|
||||
*
|
||||
* Здесь то же самое стоит ОДИН запрос на сайт: /_api/web/lists отдаёт Title и
|
||||
* RootFolder всех списков веба разом, дальше сопоставление идёт в памяти. И
|
||||
* этот запрос делается один раз за сессию — адрес списка не меняется, пока
|
||||
* список существует (партиции только добавляются, а переименование Title не
|
||||
* трогает ServerRelativeUrl).
|
||||
*
|
||||
* Кешируются ТОЛЬКО успехи. Промах кешировать нельзя: новая партиция появляется
|
||||
* асинхронно (см. getCurrentListPartition — он ставит создание следующей пары
|
||||
* списков в очередь), и закешированное «не найдено» скрывало бы заявки нового
|
||||
* списка до перезагрузки страницы.
|
||||
*/
|
||||
|
||||
// Конвенция именования пары списков — та же, что в shared/listTitles.ts и в
|
||||
// админке approval-route. Меняя здесь, менять и там.
|
||||
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||
|
||||
// Потолок на число списков, которое забираем с сайта за раз. Списков на сайте
|
||||
// портала примерно два на партицию плюс системные — запас многократный. При
|
||||
// упоре в потолок пишем предупреждение: молча «не найденная» партиция выглядела
|
||||
// бы как пропавшие заявки.
|
||||
const SITE_LISTS_TOP = 5000;
|
||||
|
||||
/** Список данных партиции: откуда читаем и что о нём уже известно бесплатно. */
|
||||
export interface IDataListRef {
|
||||
/** Серверный относительный url списка ДАННЫХ. */
|
||||
listId: string;
|
||||
siteUrl: string;
|
||||
/** Title списка данных из реестра партиций, если реестр загрузился. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface IApprovalListLocation {
|
||||
approvalListTitle: string;
|
||||
/** Абсолютный префикс пути элементов списка согласования, СО слешем на конце. */
|
||||
approvalListAbsUrl: string;
|
||||
}
|
||||
|
||||
export type ApprovalListLookup =
|
||||
| { kind: 'ok'; value: IApprovalListLocation }
|
||||
| { kind: 'skip'; reason: string }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
/** Ключ и кеша, и возвращаемой карты: партиция однозначна парой сайт+список. */
|
||||
export function approvalListKey(siteUrl: string, listId: string): string {
|
||||
return `${siteUrl}|${listId}`.toLowerCase();
|
||||
}
|
||||
|
||||
const cache = new Map<string, IApprovalListLocation>();
|
||||
|
||||
/** Забыть одну партицию — вызывать, когда чтение по закешированному адресу упало. */
|
||||
export function forgetApprovalList(siteUrl: string, listId: string): void {
|
||||
cache.delete(approvalListKey(siteUrl, listId));
|
||||
}
|
||||
|
||||
/** Полный сброс — на случай перезагрузки реестра партиций. */
|
||||
export function resetApprovalListCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/** Схема одного списка в ответе /_api/web/lists. */
|
||||
interface ISiteListRow {
|
||||
Title?: string;
|
||||
RootFolder?: { ServerRelativeUrl?: string };
|
||||
}
|
||||
|
||||
/** Title и адреса всех списков одного веба — в обе стороны. */
|
||||
interface ISiteListMap {
|
||||
urlByTitle: Map<string, string>;
|
||||
titleByUrl: Map<string, string>;
|
||||
}
|
||||
|
||||
export function siteOrigin(siteUrl: string): string {
|
||||
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
|
||||
return match ? match[0] : siteUrl;
|
||||
}
|
||||
|
||||
// Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с
|
||||
// путями элементов …/Lists/List10 (та же причина, что у listPathPrefix
|
||||
// в features/myRequests/myRequests.service.ts).
|
||||
function absPrefix(siteUrl: string, serverRelativeUrl: string): string {
|
||||
return `${siteOrigin(siteUrl)}${serverRelativeUrl.replace(/\/+$/, '')}/`;
|
||||
}
|
||||
|
||||
async function loadSiteLists(context: WebPartContext, siteUrl: string): Promise<ISiteListMap> {
|
||||
const sp = spForSite(siteUrl, context);
|
||||
const rows = (await sp.web.lists
|
||||
.select('Title', 'RootFolder/ServerRelativeUrl')
|
||||
.expand('RootFolder')
|
||||
.top(SITE_LISTS_TOP)()) as ISiteListRow[];
|
||||
|
||||
if (rows.length === SITE_LISTS_TOP) {
|
||||
console.warn(
|
||||
`[Списки согласования] ${siteUrl}: сайт отдал ${SITE_LISTS_TOP} списков — часть партиций может не найтись`
|
||||
);
|
||||
}
|
||||
|
||||
const urlByTitle = new Map<string, string>();
|
||||
const titleByUrl = new Map<string, string>();
|
||||
rows.forEach((r) => {
|
||||
const title = r.Title || '';
|
||||
const relUrl = r.RootFolder?.ServerRelativeUrl || '';
|
||||
if (!title || !relUrl) return;
|
||||
urlByTitle.set(title.toLowerCase(), relUrl);
|
||||
// По адресу — чтобы узнать Title списка ДАННЫХ, когда реестр партиций не
|
||||
// загрузился и ref.title пуст. Раньше на это уходил ещё один REST на партицию.
|
||||
titleByUrl.set(relUrl.replace(/\/+$/, '').toLowerCase(), title);
|
||||
});
|
||||
|
||||
return { urlByTitle, titleByUrl };
|
||||
}
|
||||
|
||||
function locate(ref: IDataListRef, site: ISiteListMap): ApprovalListLookup {
|
||||
const dataTitle = ref.title ?? site.titleByUrl.get(ref.listId.replace(/\/+$/, '').toLowerCase()) ?? '';
|
||||
if (!dataTitle) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: `Список данных «${ref.listId}» не найден среди списков сайта ${ref.siteUrl} (удалён или нет прав)`
|
||||
};
|
||||
}
|
||||
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
|
||||
return { kind: 'skip', reason: `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем` };
|
||||
}
|
||||
|
||||
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||
const relUrl = site.urlByTitle.get(approvalListTitle.toLowerCase());
|
||||
if (!relUrl) {
|
||||
// Коллекция /_api/web/lists секьюрити-тримится, поэтому «списка нет» и
|
||||
// «нет прав» здесь неотличимы — говорим про оба случая сразу.
|
||||
return {
|
||||
kind: 'error',
|
||||
message: `Список согласования «${approvalListTitle}» не найден на ${ref.siteUrl} (нет такого списка или нет прав на него)`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'ok',
|
||||
value: { approvalListTitle, approvalListAbsUrl: absPrefix(ref.siteUrl, relUrl) }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти списки согласования сразу для всех переданных партиций.
|
||||
*
|
||||
* Сеть — не больше одного запроса на САЙТ, и только за теми партициями, которых
|
||||
* ещё нет в кеше. Отказ по одному сайту не лишает результата партиции других
|
||||
* сайтов: они резолвятся независимо.
|
||||
*/
|
||||
export async function locateApprovalLists(
|
||||
context: WebPartContext,
|
||||
refs: IDataListRef[]
|
||||
): Promise<Map<string, ApprovalListLookup>> {
|
||||
const result = new Map<string, ApprovalListLookup>();
|
||||
|
||||
const missingBySite = new Map<string, IDataListRef[]>();
|
||||
refs.forEach((ref) => {
|
||||
const key = approvalListKey(ref.siteUrl, ref.listId);
|
||||
const hit = cache.get(key);
|
||||
if (hit) {
|
||||
result.set(key, { kind: 'ok', value: hit });
|
||||
return;
|
||||
}
|
||||
const bucket = missingBySite.get(ref.siteUrl.toLowerCase()) ?? [];
|
||||
bucket.push(ref);
|
||||
missingBySite.set(ref.siteUrl.toLowerCase(), bucket);
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Array.from(missingBySite.values()).map(async (siteRefs) => {
|
||||
const siteUrl = siteRefs[0].siteUrl;
|
||||
let site: ISiteListMap;
|
||||
try {
|
||||
site = await loadSiteLists(context, siteUrl);
|
||||
} catch (e) {
|
||||
const message = `Не удалось прочитать списки сайта ${siteUrl}: ${(e as Error).message}`;
|
||||
siteRefs.forEach((ref) => result.set(approvalListKey(ref.siteUrl, ref.listId), { kind: 'error', message }));
|
||||
return;
|
||||
}
|
||||
|
||||
siteRefs.forEach((ref) => {
|
||||
const key = approvalListKey(ref.siteUrl, ref.listId);
|
||||
const lookup = locate(ref, site);
|
||||
// Кешируем только успехи: партиция могла быть создана секунду назад и
|
||||
// ещё не попасть в ответ — закешированный промах спрятал бы её заявки
|
||||
// до перезагрузки страницы.
|
||||
if (lookup.kind === 'ok') cache.set(key, lookup.value);
|
||||
result.set(key, lookup);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export async function getCurrentListPartition(
|
||||
// а не молча повторять один и тот же провал до конца сессии.
|
||||
let requestTypesPromise: Promise<IRequestType[]> | undefined;
|
||||
|
||||
async function getRequestTypesCached(context: WebPartContext): Promise<IRequestType[]> {
|
||||
export async function getRequestTypesCached(context: WebPartContext): Promise<IRequestType[]> {
|
||||
if (!requestTypesPromise) {
|
||||
requestTypesPromise = getRequestTypes(context).catch((e) => {
|
||||
requestTypesPromise = undefined;
|
||||
|
||||
@@ -24,6 +24,7 @@ export type {
|
||||
|
||||
export {
|
||||
getRequestTypes,
|
||||
getRequestTypesCached,
|
||||
getRequestTypeIdByPageUrl,
|
||||
getListPartitions,
|
||||
getCurrentListPartition,
|
||||
@@ -40,6 +41,9 @@ export {
|
||||
registerCurrentPartition,
|
||||
} from './listPartitions';
|
||||
export { resolveWriteListId } from './writeListId';
|
||||
export { locateApprovalLists, approvalListKey, forgetApprovalList, siteOrigin } from './approvalListLocator';
|
||||
export type { IDataListRef, IApprovalListLocation, ApprovalListLookup } from './approvalListLocator';
|
||||
export { currentUserIdFor, ensureUserIdFor } from './siteUser';
|
||||
export { useRequestTypeId } from './useRequestTypeId';
|
||||
export type { IRequestTypeIdState } from './useRequestTypeId';
|
||||
export { ApprovalListService } from './approvalList.service';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getListPartitions, getRequestTypes } from './approvalRoute.service';
|
||||
import { getListPartitions, getRequestTypesCached } from './approvalRoute.service';
|
||||
import { ICurrentListPartition, IListPartition } from './models/IApprovalRouteApi';
|
||||
|
||||
/**
|
||||
@@ -36,7 +36,10 @@ export async function initListPartitions(context: WebPartContext): Promise<void>
|
||||
try {
|
||||
// Серии — по числовому id, а имена списков в формах — по слагу (pageUrl),
|
||||
// поэтому нужен и справочник типов, чтобы связать одно с другим.
|
||||
const [series, types] = await Promise.all([getListPartitions(context), getRequestTypes(context)]);
|
||||
// Кешированный вариант: тот же ответ нужен потом и getLinkedTypes («Мои
|
||||
// заявки»), и useRequestTypeId в каждой форме — здесь он заодно прогревает
|
||||
// общий кеш, чтобы позже за ним никто не ходил повторно.
|
||||
const [series, types] = await Promise.all([getListPartitions(context), getRequestTypesCached(context)]);
|
||||
|
||||
partitionsByTypeId.clear();
|
||||
typeIdByPageUrl.clear();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { spForSite } from '../pnp/pnpjs';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/site-users/web';
|
||||
|
||||
/**
|
||||
* Числовой Id пользователя НА КОНКРЕТНОМ САЙТЕ — с кешом на сессию.
|
||||
*
|
||||
* Зачем: почти все фильтры SharePoint REST по Person-полям строятся по числовому
|
||||
* Id (`AuthorId eq N`, `TansuSiteApproverId eq N`) — фильтр по EMail у Person-поля
|
||||
* ненадёжен. Id свой на каждом сайте, поэтому его приходится резолвить, но
|
||||
* в пределах сессии он не меняется.
|
||||
*
|
||||
* Раньше резолв стоял ВНУТРИ функций, которые вызываются в цикле по типам заявок
|
||||
* (getOwnRowsForType / getRecentlyChangedOwnRowsForType): при восьми типах это
|
||||
* восемь одинаковых GET /_api/web/currentuser подряд, и каждый — лишний
|
||||
* round-trip ПЕРЕД основным запросом. ensureUser в очереди согласования — то же
|
||||
* самое, только POST.
|
||||
*
|
||||
* Ошибку НЕ кешируем: упавший на холодном старте запрос должен повториться,
|
||||
* а не залипнуть до конца сессии (тот же принцип, что у requestTypesPromise
|
||||
* в approvalRoute.service.ts).
|
||||
*/
|
||||
|
||||
const byKey = new Map<string, Promise<number>>();
|
||||
|
||||
function remember(key: string, load: () => Promise<number>): Promise<number> {
|
||||
const pending = byKey.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = load().catch((e) => {
|
||||
byKey.delete(key);
|
||||
throw e;
|
||||
});
|
||||
byKey.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Полный сброс — на случай смены личности (отладочная подмена) или реинициализации. */
|
||||
export function resetSiteUserCache(): void {
|
||||
byKey.clear();
|
||||
}
|
||||
|
||||
/** Id ТЕКУЩЕГО (реального) пользователя на сайте. */
|
||||
export async function currentUserIdFor(context: WebPartContext, siteUrl: string): Promise<number> {
|
||||
return remember(`current|${siteUrl.toLowerCase()}`, async () => {
|
||||
const me = await spForSite(siteUrl, context).web.currentUser.select('Id')();
|
||||
return me.Id as number;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Id пользователя по claims-логину. ensureUser — это POST (он при необходимости
|
||||
* заводит пользователя в коллекции сайта), поэтому повторять его особенно жаль.
|
||||
*/
|
||||
export async function ensureUserIdFor(
|
||||
context: WebPartContext,
|
||||
siteUrl: string,
|
||||
claimsLogin: string
|
||||
): Promise<number> {
|
||||
return remember(`ensure|${siteUrl.toLowerCase()}|${claimsLogin.toLowerCase()}`, async () => {
|
||||
const user = await spForSite(siteUrl, context).web.ensureUser(claimsLogin);
|
||||
return user.Id;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user