Backoffice stage 2: chats (support + concierge, files)

- ChatThread per client/provider and channel; messages get files, internal notes,
  source (bot/mini app) and delivery errors; reply templates. Migration moves old
  concierge messages into threads.
- Routing: subscribers write to the concierge, others to support; /support and
  /concierge in the client bot; staff can take, transfer, close and move threads.
- Telegram: photos/documents from clients and providers, replies with the channel
  header, files sent from storage, notifications only to the assignee or the
  channel's staff, "Reply" into a specific thread.
- New admin: /chats with list, conversation, notes, files, templates, client panel;
  "Message" button on the client card; dashboard counts threads awaiting reply.
- Client mini app: in-app chat screen sharing the same conversation.
- Old admin keeps working on the same data (notes hidden there).
- Tests for routing, notes, assign/close/move, lists, recipients; separate test DB
  via TEST_DB_NAME; dev:chats-demo script.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:50:37 +04:00
1 parent d452824c62
commit 23ffb2bf1d
46 files changed
+4176 -144

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import {
addClientMessage,
assignThread,
chatRecipients,
clientConversation,
closeThread,
conversation,
DomainError,
getThreadForStaff,
giveConsent,
grantSubscription,
listThreads,
moveThread,
openThreadFor,
receiveMessage,
replyTo,
setClientChannel,
staffSend,
startClient,
threadCounts,
} from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, tgUser } from './helpers.ts';
beforeEach(async () => {
await fixtures();
});
afterAll(() => db.$disconnect());
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
async function client(ctx: ReturnType<typeof makeCtx>['ctx'], id: number, subscriber = false) {
const s = await startClient(ctx, tgUser(id));
await giveConsent(ctx, s.clientId);
if (subscriber) await grantSubscription(ctx, { clientId: s.clientId, plan: 'BAREV_PLUS', days: 30 });
return s.clientId;
}
const staff = (name: string, roles: ('SUPERADMIN' | 'SUPPORT' | 'CONCIERGE')[], telegramId?: number) =>
db.adminUser.create({ data: { name, roles, telegramId: telegramId ? BigInt(telegramId) : undefined } });
describe('чаты: поддержка и консьерж', () => {
it('подписчик пишет консьержу, остальные — в поддержку; бот и мини-апп — одна переписка', async () => {
const { ctx, events } = makeCtx(NOW);
const plain = await client(ctx, 701);
const member = await client(ctx, 702, true);
const a = await addClientMessage(ctx, plain, 'Где мой заказ?');
expect(a).toMatchObject({ channel: 'SUPPORT', opened: true });
const b = await addClientMessage(ctx, member, 'Нужен нотариус');
expect(b).toMatchObject({ channel: 'CONCIERGE', opened: true });
// второе сообщение из мини-аппа — в тот же диалог, уже открытый
const c = await receiveMessage(ctx, { clientId: member }, { text: 'И переводчик', source: 'MINIAPP' });
expect(c).toMatchObject({ opened: false });
expect(c!.thread.id).toBe(b!.thread.id);
expect(c!.message.source).toBe('MINIAPP');
expect(events.filter((e) => e.type === 'concierge.message')).toHaveLength(3);
// пустое сообщение без файла не сохраняется
expect(await receiveMessage(ctx, { clientId: plain }, { text: ' ' })).toBeNull();
});
it('клиент сам выбирает канал; консьерж — только с подпиской', async () => {
const { ctx } = makeCtx(NOW);
const plain = await client(ctx, 711);
const member = await client(ctx, 712, true);
expect(await code(setClientChannel(ctx, plain, 'CONCIERGE'))).toBe('CONCIERGE_SUBSCRIBERS_ONLY');
await setClientChannel(ctx, member, 'SUPPORT');
expect((await addClientMessage(ctx, member, 'Вопрос по оплате'))!.channel).toBe('SUPPORT');
await setClientChannel(ctx, member, 'CONCIERGE');
expect((await addClientMessage(ctx, member, 'Столик на субботу'))!.channel).toBe('CONCIERGE');
});
it('ответ: уходит клиенту, снимает «ждёт ответа», диалог за ответившим; заметка клиенту не уходит', async () => {
const { ctx, events } = makeCtx(NOW);
const clientId = await client(ctx, 721);
const op = await staff('Гор', ['SUPPORT']);
const { thread } = (await addClientMessage(ctx, clientId, 'Привет'))!;
expect(thread.waitingSince).toEqual(NOW);
const note = await staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: 'VIP', internal: true });
expect(events).not.toContainEqual({ type: 'concierge.reply', messageId: note.id });
expect((await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } })).waitingSince).toEqual(NOW);
const reply = await staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: 'Здравствуйте!' });
expect(events).toContainEqual({ type: 'concierge.reply', messageId: reply.id });
const t = await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } });
expect(t).toMatchObject({ waitingSince: null, assigneeId: op.id });
expect(await code(staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: ' ' }))).toBe('INVALID_PARAMS');
// клиент видит ответ, но не заметку; старая админка — тоже без заметок
const mine = await clientConversation(ctx, clientId);
expect(mine.messages.map((m) => m.text)).toEqual(['Привет', 'Здравствуйте!']);
expect((await conversation(ctx, { clientId })).map((m) => m.text)).toEqual(['Привет', 'Здравствуйте!']);
});
it('взять, передать коллеге с доступом, закрыть; закрытый открывается, когда клиент пишет снова', async () => {
const { ctx, events } = makeCtx(NOW);
const clientId = await client(ctx, 731, true);
const anna = await staff('Анна', ['CONCIERGE']);
const gor = await staff('Гор', ['SUPPORT']);
const boss = await staff('Босс', ['SUPERADMIN']);
const { thread } = (await addClientMessage(ctx, clientId, 'Нужен столик'))!;
expect(thread.channel).toBe('CONCIERGE');
await assignThread(ctx, { threadId: thread.id, assigneeId: boss.id, byAdminId: boss.id });
// у поддержки нет доступа к консьержу
expect(await code(assignThread(ctx, { threadId: thread.id, assigneeId: gor.id, byAdminId: boss.id }))).toBe('ASSIGNEE_NO_ACCESS');
await assignThread(ctx, { threadId: thread.id, assigneeId: anna.id, byAdminId: boss.id });
expect(events).toContainEqual({ type: 'chat.assigned', threadId: thread.id, assigneeId: anna.id, byAdminId: boss.id });
await closeThread(ctx, { threadId: thread.id, byAdminId: anna.id });
const closed = await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } });
expect(closed).toMatchObject({ status: 'CLOSED', waitingSince: null });
const again = await addClientMessage(ctx, clientId, 'Ещё вопрос');
expect(again).toMatchObject({ opened: true });
expect(again!.thread).toMatchObject({ id: thread.id, status: 'OPEN', assigneeId: anna.id });
// служебные строки видны команде, но не клиенту
const full = await getThreadForStaff(ctx, thread.id);
expect(full.messages.filter((m) => m.author === 'SYSTEM').map((m) => m.text)).toEqual(['took', 'transferred', 'closed']);
expect((await clientConversation(ctx, clientId)).messages).toHaveLength(2);
});
it('перенос в другой канал: переписка переезжает, клиент дальше пишет туда; в консьерж — только подписчика', async () => {
const { ctx } = makeCtx(NOW);
const member = await client(ctx, 741, true);
const plain = await client(ctx, 742);
const op = await staff('Оператор', ['SUPPORT', 'CONCIERGE']);
const t1 = (await addClientMessage(ctx, member, 'Вопрос про оплату подписки'))!.thread;
const moved = await moveThread(ctx, { threadId: t1.id, channel: 'SUPPORT', byAdminId: op.id });
expect(moved.channel).toBe('SUPPORT');
expect(await db.chatThread.findUnique({ where: { id: t1.id } })).toBeNull();
expect(await db.conciergeMessage.count({ where: { threadId: moved.id, author: 'CLIENT' } })).toBe(1);
expect((await addClientMessage(ctx, member, 'Спасибо'))!.thread.id).toBe(moved.id);
const t2 = (await addClientMessage(ctx, plain, 'Хочу консьержа'))!.thread;
expect(await code(moveThread(ctx, { threadId: t2.id, channel: 'CONCIERGE', byAdminId: op.id }))).toBe('CONCIERGE_SUBSCRIBERS_ONLY');
});
it('списки и счётчики: только свои каналы, ждущие ответа — сверху; пустой диалог из карточки не виден', async () => {
const { ctx, clock } = makeCtx(NOW);
const op = await staff('Гор', ['SUPPORT']);
const a = await client(ctx, 751);
const b = await client(ctx, 752);
const m = await client(ctx, 753, true);
await addClientMessage(ctx, a, 'первый');
clock.now = new Date(NOW.getTime() + 60_000);
const tb = (await addClientMessage(ctx, b, 'второй'))!.thread;
await staffSend(ctx, { threadId: tb.id, adminUserId: op.id, text: 'ответили' });
await addClientMessage(ctx, m, 'консьержу');
await openThreadFor(ctx, { clientId: a }, 'CONCIERGE');
const list = await listThreads(ctx, { channels: ['SUPPORT'], filter: 'open', adminUserId: op.id });
expect(list.map((x) => x.clientId)).toEqual([a, b]);
expect(list[0]).toMatchObject({ unread: 1, waitingSince: NOW });
expect(await listThreads(ctx, { channels: ['SUPPORT'], filter: 'mine', adminUserId: op.id })).toHaveLength(1);
expect(await listThreads(ctx, { channels: ['SUPPORT', 'CONCIERGE'], filter: 'open', adminUserId: op.id, search: 'консьерж' })).toHaveLength(1);
const counts = await threadCounts(ctx, { channels: ['SUPPORT', 'CONCIERGE'], adminUserId: op.id });
expect(counts).toMatchObject({ waiting: { SUPPORT: 1, CONCIERGE: 1 }, mine: { SUPPORT: 1, CONCIERGE: 0 }, unassigned: { SUPPORT: 1, CONCIERGE: 1 }, total: 2 });
});
it('уведомления: ответственному, иначе — сотрудникам канала', async () => {
const { ctx } = makeCtx(NOW);
const anna = await staff('Анна', ['CONCIERGE'], 9001);
const gor = await staff('Гор', ['SUPPORT'], 9002);
const boss = await staff('Босс', ['SUPERADMIN'], 9003);
const ids = async (t: { channel: 'SUPPORT' | 'CONCIERGE'; assigneeId: string | null }) => (await chatRecipients(ctx, t)).map((s) => s.name).sort();
expect(await ids({ channel: 'CONCIERGE', assigneeId: null })).toEqual(['Анна', 'Босс']);
expect(await ids({ channel: 'SUPPORT', assigneeId: null })).toEqual(['Босс', 'Гор']);
expect(await ids({ channel: 'SUPPORT', assigneeId: gor.id })).toEqual(['Гор']);
void anna;
void boss;
});
it('ответ из старой админки / из Telegram попадает в последний диалог клиента', async () => {
const { ctx } = makeCtx(NOW);
const clientId = await client(ctx, 761, true);
const op = await staff('Оператор', ['SUPPORT', 'CONCIERGE']);
const { thread } = (await addClientMessage(ctx, clientId, 'Привет'))!;
const msg = await replyTo(ctx, { clientId, adminUserId: op.id, text: 'Добрый день' });
expect(msg.threadId).toBe(thread.id);
// клиент без переписки — диалог создаётся в его канале
const fresh = await client(ctx, 762);
const first = await replyTo(ctx, { clientId: fresh, adminUserId: op.id, text: 'Здравствуйте, это поддержка' });
expect((await db.chatThread.findUniqueOrThrow({ where: { id: first.threadId! } })).channel).toBe('SUPPORT');
});
});