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

+56 -10
View File
@@ -5,7 +5,20 @@
import { getDb } from '@barev/db';
import { createT, formatDateTime, isLocale, LOCALE_NAMES, LOCALES, type Locale } from '@barev/i18n';
import { decodeStartPayload } from '@barev/core';
import { addClientMessage, applyAgencyCode, DomainError, getClientByTelegramId, giveConsent, linkWebRequest, setLocale, startClient, type Ctx } from '@barev/services';
import {
addClientMessage,
applyAgencyCode,
DomainError,
getClientByTelegramId,
giveConsent,
linkWebRequest,
setClientChannel,
setLocale,
startClient,
type ChatChannel,
type ChatFile,
type Ctx,
} from '@barev/services';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier } from '@barev/services/queue';
import { Bot, InlineKeyboard, type Context } from 'grammy';
@@ -140,19 +153,50 @@ bot.command('app', async (c) => {
bot.command('help', async (c) => c.reply(createT(await localeOf(c.from))('bot.client.help')));
/** ONB-6: ручной ввод кода агентства; любой другой текст — консьержу */
/** Сообщение в чат команды: подписчик пишет консьержу, остальные — поддержке. Ответ придёт сюда же */
async function toTeam(c: Context, text: string, file?: ChatFile) {
const locale = await localeOf(c.from);
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
if (!client?.consentAt) return askConsent(c, locale);
awaitingCode.delete(c.chat!.id);
const res = await addClientMessage(ctx, client.id, text, file);
// диалог только что открылся — подтверждаем, что сообщение дошло
if (res?.opened) await c.reply(createT(locale)(res.channel === 'CONCIERGE' ? 'bot.client.conciergeReceived' : 'bot.client.supportReceived'));
}
/** Переключить канал: /support — поддержка, /concierge — консьерж (только с подпиской) */
async function switchChannel(c: Context, channel: ChatChannel) {
const locale = await localeOf(c.from);
const t = createT(locale);
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
if (!client?.consentAt) return askConsent(c, locale);
try {
await setClientChannel(ctx, client.id, channel);
await c.reply(t(channel === 'CONCIERGE' ? 'bot.client.channelConcierge' : 'bot.client.channelSupport'));
} catch (e) {
if (e instanceof DomainError) await c.reply(t(`errors.${e.code}`));
else throw e;
}
}
bot.command('support', (c) => switchChannel(c, 'SUPPORT'));
bot.command('concierge', (c) => switchChannel(c, 'CONCIERGE'));
/** Фото и файлы — тоже в чат команды; берём самое крупное превью фото */
bot.on('message:photo', async (c) => {
const photo = c.message.photo.at(-1)!;
await toTeam(c, c.message.caption ?? '', { tgFileId: photo.file_id, tgBot: 'client', fileName: 'photo.jpg', mime: 'image/jpeg', size: photo.file_size ?? null });
});
bot.on('message:document', async (c) => {
const d = c.message.document;
await toTeam(c, c.message.caption ?? '', { tgFileId: d.file_id, tgBot: 'client', fileName: d.file_name ?? 'file', mime: d.mime_type ?? 'application/octet-stream', size: d.file_size ?? null });
});
/** ONB-6: ручной ввод кода агентства; любой другой текст — в чат команды */
bot.on('message:text', async (c) => {
const locale = await localeOf(c.from);
const t = createT(locale);
const client = await getClientByTelegramId(db, BigInt(c.from.id));
if (!awaitingCode.has(c.chat.id)) {
if (!client?.consentAt) return askConsent(c, locale);
// чат с консьержем: сообщение видит оператор в админке, ответ придёт сюда же
const first = !(await db.conciergeMessage.count({ where: { clientId: client.id } }));
await addClientMessage(ctx, client.id, c.message.text);
if (first) await c.reply(t('bot.client.conciergeReceived'));
return;
}
if (!awaitingCode.has(c.chat.id)) return toTeam(c, c.message.text);
if (!client) return;
try {
const res = await applyAgencyCode(ctx, client.id, c.message.text);
@@ -180,6 +224,8 @@ async function main() {
{ command: 'start', description: t('bot.client.cmdStart') },
{ command: 'app', description: t('bot.client.cmdApp') },
{ command: 'code', description: t('bot.client.cmdCode') },
{ command: 'support', description: t('bot.client.cmdSupport') },
{ command: 'concierge', description: t('bot.client.cmdConcierge') },
{ command: 'language', description: t('bot.client.cmdLanguage') },
{ command: 'help', description: t('bot.client.cmdHelp') },
],