- 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>
190 lines
11 KiB
TypeScript
190 lines
11 KiB
TypeScript
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');
|
|
});
|
|
});
|