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'; /** * Файлы, прикреплённые к сообщениям чата, — библиотека «Файлы чата» на сайте * данных, папка на каждую заявку: «Файлы чата/<тип заявки>-». * * Почему не вложения самой заявки (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 | undefined; /** * Серверный относительный адрес корня библиотеки, например * «/sites/requests/Файлы чата». * * Резолвим по ЗАГОЛОВКУ списка, а не собираем строкой из адреса сайта: url * библиотеки может не совпадать с её названием (и меняться при переносе), а * заголовок — то, что реально настраивал администратор. */ function chatLibraryRoot(): Promise { 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 { const root = await chatLibraryRoot(); return `${root}/${requestFolderName(formKey, itemId)}`; } /** * Папка заявки, гарантированно существующая. * * Проверяем существование отдельным запросом, а не создаём «поверх»: * addSubFolderUsingPath на существующей папке возвращает ошибку, а * addUsingPath с overwrite=true СНОСИТ содержимое — то есть все ранее * приложенные к заявке файлы. */ async function ensureRequestFolder(formKey: RequestTypeId, itemId: number): Promise { 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 { 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; }