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

+52 -13
View File
@@ -4,7 +4,21 @@
*/
import { getDb } from '@barev/db';
import { createT, formatDateTime, type Locale } from '@barev/i18n';
import { acceptPartnerInvite, addProviderMessage, checkCard, DomainError, linkByInvite, proIdentity, redeemOffer, replyTo, respondToOffer, type Ctx, type Peer } from '@barev/services';
import {
acceptPartnerInvite,
addProviderMessage,
checkCard,
DomainError,
linkByInvite,
peerName,
proIdentity,
redeemOffer,
replyTo,
respondToOffer,
staffSend,
type Ctx,
type Peer,
} from '@barev/services';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier } from '@barev/services/queue';
import { Bot, InlineKeyboard, type Context } from 'grammy';
@@ -53,7 +67,7 @@ const adminOf = (c: Context) => (c.from ? db.adminUser.findFirst({ where: { tele
const adminT = createT('ru');
/** Оператор нажал «Ответить» — следующий его текст уйдёт этому клиенту или исполнителю */
const replyingTo = new Map<number, Peer & { name: string }>();
const replyingTo = new Map<number, { threadId?: string; peer?: Peer; name: string }>();
const LEVEL_NAME: Record<string, string> = { BAREV: 'Barev', BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' };
type Check = Awaited<ReturnType<typeof checkCard>>;
@@ -198,19 +212,31 @@ bot.callbackQuery(/^rd:(\w+):(\w+)$/, async (c) => {
});
/** «Ответить» под уведомлением о сообщении */
bot.callbackQuery(/^ar:(c|p):(\w+)$/, async (c) => {
bot.callbackQuery(/^ar:(c|p|t):(\w+)$/, async (c) => {
const admin = await adminOf(c);
if (!admin) {
await c.answerCallbackQuery({ text: adminT('bot.admin.notAdmin'), show_alert: true });
return;
}
const [, kind, id] = c.match;
const peer: Peer = kind === 'c' ? { clientId: id! } : { providerId: id! };
const name =
kind === 'c'
? ((await db.client.findUnique({ where: { id: id! }, include: { user: true } }))?.user.firstName ?? '')
: ((await db.provider.findUnique({ where: { id: id! } }))?.name ?? '');
replyingTo.set(c.chat!.id, { ...peer, name });
let name = '';
if (kind === 't') {
// ответ в конкретный диалог (поддержка или консьерж)
const thread = await db.chatThread.findUnique({ where: { id: id! }, include: { client: { include: { user: true } }, provider: true } });
if (!thread) {
await c.answerCallbackQuery({ text: adminT('errors.NOT_FOUND'), show_alert: true });
return;
}
name = peerName(thread);
replyingTo.set(c.chat!.id, { threadId: thread.id, name });
} else {
const peer: Peer = kind === 'c' ? { clientId: id! } : { providerId: id! };
name =
kind === 'c'
? ((await db.client.findUnique({ where: { id: id! }, include: { user: true } }))?.user.firstName ?? '')
: ((await db.provider.findUnique({ where: { id: id! } }))?.name ?? '');
replyingTo.set(c.chat!.id, { peer, name });
}
await c.answerCallbackQuery();
await c.reply(adminT('bot.admin.replyPrompt', { name }), { reply_markup: { force_reply: true, input_field_placeholder: adminT('bot.admin.replyPlaceholder') } });
});
@@ -226,7 +252,8 @@ bot.on('message:text', async (c) => {
const admin = await adminOf(c);
if (target && admin) {
try {
await replyTo(ctx, { ...(target.clientId ? { clientId: target.clientId } : { providerId: target.providerId! }), adminUserId: admin.id, text: c.message.text });
if (target.threadId) await staffSend(ctx, { threadId: target.threadId, adminUserId: admin.id, text: c.message.text });
else await replyTo(ctx, { ...target.peer!, adminUserId: admin.id, text: c.message.text });
replyingTo.delete(c.chat.id);
await c.reply(adminT('bot.admin.replySent', { name: target.name }));
} catch (e) {
@@ -238,9 +265,8 @@ bot.on('message:text', async (c) => {
// исполнитель пишет оператору — сообщение появится в админке
const id = await identity(c);
if (id?.provider) {
const first = !(await db.conciergeMessage.count({ where: { providerId: id.provider.id } }));
await addProviderMessage(ctx, id.provider.id, c.message.text);
if (first) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
const res = await addProviderMessage(ctx, id.provider.id, c.message.text);
if (res?.opened) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
return;
}
if (admin) {
@@ -250,6 +276,19 @@ bot.on('message:text', async (c) => {
await greet(c);
});
/** Исполнитель прислал фото или файл — тоже в поддержку */
bot.on(['message:photo', 'message:document'], async (c, next) => {
const id = await identity(c);
if (!id?.provider) return next();
const photo = c.message.photo?.at(-1);
const doc = c.message.document;
const file = photo
? { tgFileId: photo.file_id, tgBot: 'pro' as const, fileName: 'photo.jpg', mime: 'image/jpeg', size: photo.file_size ?? null }
: { tgFileId: doc!.file_id, tgBot: 'pro' as const, fileName: doc!.file_name ?? 'file', mime: doc!.mime_type ?? 'application/octet-stream', size: doc!.file_size ?? null };
const res = await addProviderMessage(ctx, id.provider.id, c.message.caption ?? '', file);
if (res?.opened) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
});
bot.on('message', greet);
bot.catch((err) => console.error('[bot-pro]', err.error));