чат по заявке через комментарии к строке списка (#14)
Tests / frontend-test (push) Successful in 1m15s

Обсуждение заявки поверх штатных комментариев SharePoint (PnPjs
item.comments) — без новых колонок и провижининга, права на переписку
раздаёт сам SharePoint по правам на элемент.

Встроено в ApproveFeature и EditFeature (единственные входы в просмотр
заявки для всех типов), поэтому доступно автору, всем согласующим
(включая уже решивших и ещё не дошедших до своего шага) и исполнителям,
а не только текущему шагу маршрута, как DecisionPanel.

Автора комментария всегда ставит реальная учётка — отладочная подмена
личности на запись в SharePoint не влияет, в UI есть предупреждение.

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
2026-08-24 11:37:34 +00:00
parent a146877327
commit 0ecb1ded78
19 changed files with 1357 additions and 5 deletions
@@ -0,0 +1,128 @@
import { IChatAttachment, IChatMessage } from './models/IChatMessage';
import { displayFileName } from './chatFileNames';
/**
* Часть комментария SharePoint, из которой строится сообщение чата.
*
* Объявлена своим типом, а не взята как ICommentInfo из @pnp/sp/comments:
* этот модуль намеренно НЕ знает про PnPjs (он чистый и покрыт тестами), а
* ICommentInfo структурно подходит под этот интерфейс — сервис передаёт его
* без приведения типов.
*/
export interface ICommentSource {
id: string;
text: string;
createdDate: string;
author?: { name?: string; email?: string };
}
/**
* Служебная пометка о прикреплённых файлах в КОНЦЕ текста комментария:
* Проверьте, пожалуйста, акт
* [rp-files]20260824-153012-1__акт.pdf|20260824-153012-2__скан.jpg
*
* Почему в тексте, а не отдельным хранилищем: у комментария SharePoint нет
* своих полей — только текст. Класть связь «сообщение → файлы» больше некуда,
* не заводя отдельный список и не превращая каждую загрузку чата в лишний
* запрос к библиотеке. Имена файлов складские (с префиксом), но не полные
* адреса — адрес библиотеки резолвится на лету, поэтому её можно переместить,
* и ссылки в старой переписке не сломаются.
*
* Разделитель имён — «|»: этот символ SharePoint в именах файлов запрещает
* (см. FORBIDDEN_CHARS), значит в имени он встретиться не может.
*/
const FILE_MARKER = '[rp-files]';
/** Пометка всегда последняя и всегда в одну строку — отсюда привязка к концу. */
const FILE_MARKER_PATTERN = /\s*\[rp-files\]([^\r\n]*)$/;
/**
* Собрать текст комментария из сообщения и складских имён файлов.
* Сообщение может быть пустым — тогда комментарий состоит из одной пометки
* (так выглядит отправка «только файл»).
*/
export function buildCommentText(text: string, storedFileNames: string[]): string {
const trimmed = text.trim();
if (storedFileNames.length === 0) return trimmed;
const marker = `${FILE_MARKER}${storedFileNames.join('|')}`;
return trimmed ? `${trimmed}\n${marker}` : marker;
}
function parseStoredNames(raw: string): string[] {
const found = FILE_MARKER_PATTERN.exec(raw);
if (!found) return [];
return found[1]
.split('|')
.map((name) => name.trim())
.filter((name) => !!name);
}
/** Есть ли в переписке файлы — по этому признаку сервис решает, нужен ли адрес библиотеки. */
export function hasAttachments(comments: ICommentSource[]): boolean {
return comments.some((c) => !!c.text && FILE_MARKER_PATTERN.test(c.text));
}
/**
* Дата комментария приходит строкой ISO. Если часового пояса в ней нет,
* new Date() трактует строку как МЕСТНОЕ время, а SharePoint отдаёт UTC —
* сообщения уехали бы на 5 часов (Астана — UTC+5). Поэтому при отсутствии
* пояса дописываем Z явно.
*/
function parseCommentDate(raw: string): Date {
const hasTimeZone = /(Z|[+-]\d{2}:\d{2})$/.test(raw);
return new Date(hasTimeZone ? raw : `${raw}Z`);
}
function toAttachments(storedNames: string[], folderUrl: string): IChatAttachment[] {
return storedNames.map((storedName) => ({
fileName: displayFileName(storedName),
url: folderUrl ? `${folderUrl}/${storedName}` : ''
}));
}
function toMessage(
comment: ICommentSource,
currentUserEmail: string,
folderUrl: string
): IChatMessage {
const authorEmail = comment.author?.email || '';
const authorName = comment.author?.name || authorEmail || 'Неизвестный автор';
const storedNames = parseStoredNames(comment.text);
return {
id: comment.id,
text: comment.text.replace(FILE_MARKER_PATTERN, '').trim(),
authorName,
authorEmail,
createdAt: parseCommentDate(comment.createdDate),
isOwn:
!!authorEmail &&
!!currentUserEmail &&
authorEmail.toLowerCase() === currentUserEmail.toLowerCase(),
attachments: toAttachments(storedNames, folderUrl)
};
}
/**
* Комментарии SharePoint → сообщения чата, от старых к новым (как в любой
* переписке: свежее внизу).
*
* Порядок ответа API не документирован и мы на него не опираемся — сортируем
* по дате сами. Комментарии без текста и без файлов отбрасываем: показывать
* пустой «пузырь» нечего, а в списке они появляются, если кто-то отправил
* комментарий не из портала (например, из стандартной формы SharePoint).
*
* folderUrl — папка заявки в библиотеке «Файлы чата»; пустая строка допустима,
* когда в переписке файлов нет (тогда адрес библиотеки и не запрашивался).
*/
export function toChatMessages(
comments: ICommentSource[],
currentUserEmail: string,
folderUrl: string = ''
): IChatMessage[] {
return comments
.filter((c) => !!c.text && !!c.text.trim())
.map((c) => toMessage(c, currentUserEmail, folderUrl))
.filter((m) => !!m.text || m.attachments.length > 0)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}