Files
request-portal/src/webparts/requestPortal/shared/chat/chatFiles.service.ts
T
gorbenko.pavel af8d39152e fix: папка файлов чата называется по RequestUniqueCode, а не formKey-id
Раньше папка в библиотеке «Файлы чата» называлась «clearance-form-42» —
это техническое имя ничего не говорит без открытого портала. Теперь имя
папки — тот же код заявки, что показан в её заголовке («13-2608-42»),
поэтому папку легко узнать прямо в библиотеке.
2026-08-24 16:18:39 +05:00

175 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/folders';
import '@pnp/sp/files';
import type { IFolder } from '@pnp/sp/folders';
import { getSP } from '../pnp/pnpjs';
import { resolveDataList } from '../dataList';
import { CHAT_FILES_LIBRARY_TITLE } from '../listTitles';
import { RequestTypeId } from '../../models/types';
import { fileStamp, sanitizeFileName, storedFileName } from './chatFileNames';
/**
* Файлы, прикреплённые к сообщениям чата, — библиотека «Файлы чата» на сайте
* данных, папка на каждую заявку: «Файлы чата/<RequestUniqueCode>», например
* «Файлы чата/13-2608-42» — тот же код, что показан в заголовке заявки (см.
* FormCard subtitle в <Форма>View.tsx). Так папку легко узнать в самой
* библиотеке, не открывая портал.
*
* Почему не вложения самой заявки (shared/attachments): вложения — это
* документы заявки, они попадают в её карточку и печать. Файл из переписки —
* не документ заявки, а часть разговора; смешивать их значит засорять заявку.
*
* Связь «файл → сообщение» хранится в тексте комментария (см. FILE_MARKER в
* chatMessages.ts), поэтому чтение переписки НЕ обращается к библиотеке вовсе,
* пока в ней действительно нет файлов.
*/
/** Больше 10 МБ через обычную загрузку слать не стоит — это переписка, а не файлообмен. */
export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
/** Сколько файлов можно приложить к одному сообщению. */
export const MAX_FILES_PER_MESSAGE = 20;
// Адрес библиотеки в пределах сессии не меняется, а нужен при каждой загрузке
// переписки с файлами. Ошибку НЕ кешируем: не отвечавший на холодном старте
// запрос должен повториться, а не залипнуть до конца сессии (тот же приём, что
// в shared/approval/siteUser.ts).
let libraryRootPromise: Promise<string> | undefined;
/**
* Серверный относительный адрес корня библиотеки, например
* «/sites/requests/Файлы чата».
*
* Резолвим по ЗАГОЛОВКУ списка, а не собираем строкой из адреса сайта: url
* библиотеки может не совпадать с её названием (и меняться при переносе), а
* заголовок — то, что реально настраивал администратор.
*/
function chatLibraryRoot(): Promise<string> {
if (!libraryRootPromise) {
libraryRootPromise = getSP()
.web.lists.getByTitle(CHAT_FILES_LIBRARY_TITLE)
.rootFolder.select('ServerRelativeUrl')()
.then((folder) => folder.ServerRelativeUrl as string)
.catch((e) => {
libraryRootPromise = undefined;
throw new Error(
`Библиотека «${CHAT_FILES_LIBRARY_TITLE}» недоступна на сайте данных: ${(e as Error).message}`
);
});
}
return libraryRootPromise;
}
/**
* Имя папки заявки в библиотеке — RequestUniqueCode элемента.
*
* Код читаем из самого элемента, а не собираем строкой (formKey+itemId), чтобы
* папка называлась ровно тем номером, который видит пользователь на заявке.
* Код записывается сразу после создания элемента (см. saveRequestItem в
* shared/dataList.ts) и с этого момента не меняется, поэтому лишний раз его
* не кешируем: запрос — это одно select-поле, а не тяжёлая операция.
*
* Пустой код (гипотетическая гонка на только что созданной заявке) — откат на
* старую схему имени, чтобы папка всё равно нашлась однозначно.
*/
async function requestFolderName(
formKey: RequestTypeId,
itemId: number,
listId: string | undefined
): Promise<string> {
const item = (await resolveDataList(formKey, listId)
.items.getById(itemId)
.select('RequestUniqueCode')()) as { RequestUniqueCode?: string };
const code = (item.RequestUniqueCode || '').trim();
return sanitizeFileName(code || `${formKey}-${itemId}`);
}
/** Адрес папки заявки. Папки может ещё не существовать — для чтения ссылок это неважно. */
export async function chatFilesFolderUrl(
formKey: RequestTypeId,
itemId: number,
listId?: string
): Promise<string> {
const [root, name] = await Promise.all([
chatLibraryRoot(),
requestFolderName(formKey, itemId, listId)
]);
return `${root}/${name}`;
}
/**
* Папка заявки, гарантированно существующая.
*
* Проверяем существование отдельным запросом, а не создаём «поверх»:
* addSubFolderUsingPath на существующей папке возвращает ошибку, а
* addUsingPath с overwrite=true СНОСИТ содержимое — то есть все ранее
* приложенные к заявке файлы.
*/
async function ensureRequestFolder(
formKey: RequestTypeId,
itemId: number,
listId: string | undefined
): Promise<IFolder> {
const [root, name] = await Promise.all([
chatLibraryRoot(),
requestFolderName(formKey, itemId, listId)
]);
const folder = getSP().web.getFolderByServerRelativePath(`${root}/${name}`);
const exists = await folder
.select('Exists')()
.then((info) => !!info.Exists)
.catch(() => false);
if (!exists) {
await getSP().web.getFolderByServerRelativePath(root).addSubFolderUsingPath(name);
}
return folder;
}
/**
* Загрузить файлы сообщения в папку заявки. Возвращает СКЛАДСКИЕ имена — их
* сервис чата кладёт в текст комментария (см. buildCommentText).
*
* Вызывается ДО создания комментария: имена файлов нужны, чтобы его собрать.
* Если создать комментарий потом не удастся, файлы останутся в библиотеке
* ничьими — это лучше, чем сообщение со ссылками на незагруженные файлы.
*/
export async function uploadChatFiles(
formKey: RequestTypeId,
itemId: number,
listId: string | undefined,
files: File[]
): Promise<string[]> {
if (files.length === 0) return [];
const folder = await ensureRequestFolder(formKey, itemId, listId);
const stamp = fileStamp(new Date());
const storedNames: string[] = [];
// Последовательно, а не Promise.all: параллельная запись в одну папку чаще
// упирается в throttling SharePoint, а файлов здесь единицы.
for (let i = 0; i < files.length; i++) {
const name = storedFileName(stamp, i + 1, files[i].name);
// File — это Blob, поэтому читать его в ArrayBuffer заранее не нужно.
await folder.files.addUsingPath(name, files[i]);
storedNames.push(name);
}
return storedNames;
}
/** Проверка файлов до отправки. Возвращает текст ошибки или undefined. */
export function validateChatFiles(files: File[]): string | undefined {
if (files.length > MAX_FILES_PER_MESSAGE) {
return `К одному сообщению можно приложить не больше ${MAX_FILES_PER_MESSAGE} файлов.`;
}
const tooBig = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES)[0];
if (tooBig) {
const limitMb = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024));
return `Файл «${tooBig.name}» больше ${limitMb} МБ — приложите его к самой заявке или дайте ссылку.`;
}
return undefined;
}