Files
request-portal/src/webparts/requestPortal/shared/chat/chatFiles.service.ts
T
gorbenko.pavel bf4c4c517a feat: чат по заявке — только в обходном листе, лимит файлов до 20
Остальным восьми типам заявок чат пока не нужен: сужаем область до
одной формы (isChatEnabledFor), не включая сразу везде. Лимит файлов
на сообщение поднят с 5 до 20 — пяти оказалось мало для реальной
переписки по обходному листу.
2026-08-24 16:16:59 +05:00

140 lines
7.0 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/folders';
import '@pnp/sp/files';
import type { IFolder } from '@pnp/sp/folders';
import { getSP } from '../pnp/pnpjs';
import { CHAT_FILES_LIBRARY_TITLE } from '../listTitles';
import { RequestTypeId } from '../../models/types';
import { fileStamp, storedFileName } from './chatFileNames';
/**
* Файлы, прикреплённые к сообщениям чата, — библиотека «Файлы чата» на сайте
* данных, папка на каждую заявку: «Файлы чата/<тип заявки>-<id>».
*
* Почему не вложения самой заявки (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;
}
/** Имя папки заявки в библиотеке. Тип заявки в имени — чтобы id разных типов не смешивались. */
function requestFolderName(formKey: RequestTypeId, itemId: number): string {
return `${formKey}-${itemId}`;
}
/** Адрес папки заявки. Папки может ещё не существовать — для чтения ссылок это неважно. */
export async function chatFilesFolderUrl(
formKey: RequestTypeId,
itemId: number
): Promise<string> {
const root = await chatLibraryRoot();
return `${root}/${requestFolderName(formKey, itemId)}`;
}
/**
* Папка заявки, гарантированно существующая.
*
* Проверяем существование отдельным запросом, а не создаём «поверх»:
* addSubFolderUsingPath на существующей папке возвращает ошибку, а
* addUsingPath с overwrite=true СНОСИТ содержимое — то есть все ранее
* приложенные к заявке файлы.
*/
async function ensureRequestFolder(formKey: RequestTypeId, itemId: number): Promise<IFolder> {
const root = await chatLibraryRoot();
const name = requestFolderName(formKey, itemId);
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,
files: File[]
): Promise<string[]> {
if (files.length === 0) return [];
const folder = await ensureRequestFolder(formKey, itemId);
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;
}