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

+4 -4
View File
@@ -554,7 +554,7 @@ describe('приложение: переезд, Elite, заявки операт
const clientId = await onboardedClient(ctx, 504);
await addClientMessage(ctx, clientId, 'Как продлить визу?');
expect((await listConversations(ctx))[0]).toMatchObject({ clientId, unread: 1 });
await conversation(ctx, clientId);
await conversation(ctx, { clientId });
expect((await listConversations(ctx))[0]!.unread).toBe(0);
const admin = await db.adminUser.create({ data: { email: 'op@test', name: 'Оператор', passwordHash: 'x' } });
const msg = await replyToClient(ctx, { clientId, adminUserId: admin.id, text: 'Подскажем завтра утром' });
@@ -582,12 +582,12 @@ describe('шаблоны переезда и переписка с исполн
it('исполнитель пишет боту — операторам уведомление; ответ уходит исполнителю', async () => {
const { ctx, events } = makeCtx(NOW);
const provider = await db.provider.findFirstOrThrow({ where: { userId: { not: null } } });
const msg = await addProviderMessage(ctx, provider.id, 'Клиент не открывает дверь');
expect(events).toContainEqual({ type: 'concierge.message', messageId: msg!.id });
const msg = (await addProviderMessage(ctx, provider.id, 'Клиент не открывает дверь'))!.message;
expect(events).toContainEqual({ type: 'concierge.message', messageId: msg.id });
const admin = await db.adminUser.create({ data: { email: 'op2@test', name: 'Оператор', passwordHash: 'x' } });
const reply = await replyTo(ctx, { providerId: provider.id, adminUserId: admin.id, text: 'Звоним клиенту' });
expect(reply).toMatchObject({ providerId: provider.id, author: 'OPERATOR' });
expect((await db.conciergeMessage.findUniqueOrThrow({ where: { id: msg!.id } })).readAt).not.toBeNull();
expect((await db.conciergeMessage.findUniqueOrThrow({ where: { id: msg.id } })).readAt).not.toBeNull();
const unlinked = await db.provider.create({ data: { name: 'Без бота', role: 'CLEANER', languages: ['ru'], inviteCode: 'X-NOBOT' } });
expect(await code(replyTo(ctx, { providerId: unlinked.id, adminUserId: admin.id, text: 'привет' }))).toBe('PROVIDER_NOT_LINKED');
});