feat: файлы в чате заявки — библиотека «Файлы чата»

У комментария SharePoint нет вложений, только текст. Поэтому файлы уходят
в отдельную библиотеку на сайте данных (папка на заявку), а в тексте
комментария остаётся пометка с их именами — по ней сообщение и находит свои
файлы. Адрес библиотеки резолвится по заголовку на лету, поэтому ссылки в
переписке не ломаются при её переносе, а чат без файлов к библиотеке вообще
не обращается.
This commit is contained in:
gorbenko.pavel
2026-08-24 14:05:04 +05:00
parent e85a3a239e
commit e8daf0813d
13 changed files with 597 additions and 49 deletions
@@ -0,0 +1,139 @@
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 = 5;
// Адрес библиотеки в пределах сессии не меняется, а нужен при каждой загрузке
// переписки с файлами. Ошибку НЕ кешируем: не отвечавший на холодном старте
// запрос должен повториться, а не залипнуть до конца сессии (тот же приём, что
// в 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;
}