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

+50 -11
View File
@@ -4,7 +4,8 @@
*/
import { getDb } from '@barev/db';
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
import { dueReminders, EVENTS_QUEUE, expireOffers, type Ctx, type DomainEvent } from '@barev/services';
import { chatRecipients, dueReminders, EVENTS_QUEUE, expireOffers, type Ctx, type DomainEvent } from '@barev/services';
import { getFile } from '@barev/services/storage';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
import { compactRows, escapeHtml, TelegramSender, webAppButton, type InlineButton } from '@barev/services/telegram';
@@ -30,6 +31,12 @@ async function toAdmins(text: string, buttons: InlineButton[][] = []) {
const adminT = createT('ru');
/** Ссылка на раздел админки — Telegram принимает url-кнопки только с https */
const adminButton = (path: string): InlineButton | null => (ADMIN_URL.startsWith('https://') ? { text: adminT('bot.admin.open'), url: `${ADMIN_URL}/#${path}` } : null);
/** Новая админка, если уже на сервере; иначе — раздел переписки в старой */
const BACKOFFICE_URL = process.env.BACKOFFICE_URL ?? '';
const chatButton = (threadId: string | null, peer: string): InlineButton | null =>
BACKOFFICE_URL.startsWith('https://') && threadId
? { text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/chats/${threadId}` }
: adminButton(`/concierge?${peer.startsWith('c:') ? 'client' : 'provider'}=${peer.slice(2)}`);
const who = (u: { firstName: string | null; lastName?: string | null; username: string | null }) =>
`${escapeHtml(`${u.firstName ?? ''} ${u.lastName ?? ''}`.trim())}${u.username ? ` (@${escapeHtml(u.username)})` : ''}`;
@@ -204,23 +211,55 @@ async function handle(event: DomainEvent) {
return;
}
case 'concierge.reply': {
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } } } });
if (!msg) return;
if (msg.client) {
const t = createT(msg.client.user.locale as Locale);
await clientBot.send({ chatId: msg.client.user.telegramId, text: `<b>${t('notify.concierge')}</b>\n${escapeHtml(msg.text)}` });
} else if (msg.provider?.user) {
const t = createT(msg.provider.user.locale as Locale);
await proBot.send({ chatId: msg.provider.user.telegramId, text: `<b>${t('notify.operator')}</b>\n${escapeHtml(msg.text)}` });
const msg = await db.conciergeMessage.findUnique({
where: { id: event.messageId },
include: { client: { include: { user: true } }, provider: { include: { user: true } }, thread: { select: { channel: true } } },
});
if (!msg || msg.internal) return;
const to = msg.client ? { bot: clientBot, user: msg.client.user } : msg.provider?.user ? { bot: proBot, user: msg.provider.user } : null;
if (!to) return;
const t = createT(to.user.locale as Locale);
// клиенту — от кого ответ: консьерж или поддержка; исполнителю — оператор
const head = msg.client ? t(msg.thread?.channel === 'CONCIERGE' ? 'notify.concierge' : 'notify.support') : t('notify.operator');
const text = `<b>${head}</b>${msg.text ? `\n${escapeHtml(msg.text)}` : ''}`;
let delivered: boolean;
if (msg.fileKey) {
const body = await getFile(msg.fileKey);
delivered = await to.bot.sendFile({ chatId: to.user.telegramId, caption: text, body, fileName: msg.fileName ?? 'file', mime: msg.mime ?? 'application/octet-stream' });
} else {
delivered = await to.bot.send({ chatId: to.user.telegramId, text });
}
if (!delivered) await db.conciergeMessage.update({ where: { id: msg.id }, data: { deliveryError: 'UNREACHABLE' } });
return;
}
case 'concierge.message': {
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } } } });
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } }, thread: true } });
if (!msg) return;
const from = msg.client ? who(msg.client.user) : `${escapeHtml(msg.provider?.name ?? '')} · ${adminT('bot.admin.provider')}`;
const peer = msg.clientId ? `c:${msg.clientId}` : `p:${msg.providerId}`;
await toAdmins(`💬 <b>${from}</b>\n${escapeHtml(msg.text)}`, compactRows([[{ text: adminT('bot.admin.reply'), callback_data: `ar:${peer}` }, adminButton(`/concierge?${msg.clientId ? 'client' : 'provider'}=${msg.clientId ?? msg.providerId}`)]]));
const channel = msg.thread ? `${adminT(msg.thread.channel === 'CONCIERGE' ? 'bot.admin.channelConcierge' : 'bot.admin.channelSupport')} · ` : '';
const body = msg.text ? escapeHtml(msg.text) : '';
const file = msg.tgFileId || msg.fileKey ? `📎 ${escapeHtml(msg.fileName ?? adminT('bot.admin.file'))}` : '';
const text = `💬 ${channel}<b>${from}</b>\n${[file, body].filter(Boolean).join('\n')}`;
const buttons = compactRows([[{ text: adminT('bot.admin.reply'), callback_data: msg.threadId ? `ar:t:${msg.threadId}` : `ar:${peer}` }, chatButton(msg.threadId, peer)]]);
const recipients = msg.thread ? await chatRecipients(ctx, msg.thread) : await db.adminUser.findMany({ where: { active: true, tgNotify: true, telegramId: { not: null } } });
for (const a of recipients) await proBot.send({ chatId: a.telegramId!, text, buttons });
return;
}
case 'chat.assigned': {
const [thread, assignee, by] = await Promise.all([
db.chatThread.findUnique({ where: { id: event.threadId }, include: { client: { include: { user: true } }, provider: true } }),
db.adminUser.findUnique({ where: { id: event.assigneeId } }),
db.adminUser.findUnique({ where: { id: event.byAdminId }, select: { name: true } }),
]);
if (!thread || !assignee?.telegramId || !assignee.tgNotify) return;
const name = thread.client ? who(thread.client.user) : escapeHtml(thread.provider?.name ?? '');
const peer = thread.clientId ? `c:${thread.clientId}` : `p:${thread.providerId}`;
await proBot.send({
chatId: assignee.telegramId,
text: `👋 ${adminT('bot.admin.chatAssigned', { by: escapeHtml(by?.name ?? ''), name })}`,
buttons: compactRows([[{ text: adminT('bot.admin.reply'), callback_data: `ar:t:${thread.id}` }, chatButton(thread.id, peer)]]),
});
return;
}
case 'lead.submitted': {