feat: файлы в чате заявки — библиотека «Файлы чата»
У комментария SharePoint нет вложений, только текст. Поэтому файлы уходят в отдельную библиотеку на сайте данных (папка на заявку), а в тексте комментария остаётся пометка с их именами — по ней сообщение и находит свои файлы. Адрес библиотеки резолвится по заголовку на лету, поэтому ссылки в переписке не ломаются при её переносе, а чат без файлов к библиотеке вообще не обращается.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user