- 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>
196 lines
13 KiB
TypeScript
196 lines
13 KiB
TypeScript
/**
|
|
* Локальная разработка: демо-переписка для раздела «Чаты» новой админки (этап 2).
|
|
* pnpm --filter @barev/api dev:bo-demo — сначала демо-клиенты
|
|
* pnpm --filter @barev/api dev:chats-demo — потом диалоги
|
|
* Повторный запуск пересоздаёт диалоги демо-клиентов. Добавляет двух сотрудников: консьержа и поддержку.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { getDb } from '@barev/db';
|
|
import { putFile } from '@barev/services/storage';
|
|
import { env } from '../src/env.ts';
|
|
|
|
if (env.isProd) throw new Error('Только для разработки');
|
|
|
|
const db = getDb();
|
|
const MIN = 60_000;
|
|
const now = Date.now();
|
|
const ago = (m: number) => new Date(now - m * MIN);
|
|
|
|
async function clientByTg(tg: bigint) {
|
|
const u = await db.user.findUnique({ where: { telegramId: tg }, include: { client: true } });
|
|
if (!u?.client) throw new Error(`Нет демо-клиента ${tg} — сначала запустите pnpm --filter @barev/api dev:bo-demo`);
|
|
return u.client;
|
|
}
|
|
|
|
async function staff(name: string, roles: ('SUPPORT' | 'CONCIERGE' | 'SUPERADMIN')[]) {
|
|
return (await db.adminUser.findFirst({ where: { name } })) ?? db.adminUser.create({ data: { name, roles } });
|
|
}
|
|
|
|
const [owner, operator, anna, gor] = await Promise.all([
|
|
db.adminUser.findFirst({ where: { roles: { has: 'SUPERADMIN' } } }),
|
|
db.adminUser.findFirst({ where: { roles: { hasEvery: ['SUPPORT', 'CONCIERGE'] } } }),
|
|
staff('Анна Мелконян', ['CONCIERGE']),
|
|
staff('Гор Авакян', ['SUPPORT']),
|
|
]);
|
|
if (!owner || !operator) throw new Error('Нет суперадмина или оператора — запустите seed');
|
|
|
|
const arsen = await clientByTg(100001003n); // Elite
|
|
const dmitry = await clientByTg(100001001n); // Barev+
|
|
const maria = await clientByTg(100001000n); // без подписки
|
|
const sarah = await clientByTg(100001002n); // Barev+, пишет в поддержку
|
|
const alex = await clientByTg(100001005n); // без подписки
|
|
const katya = await clientByTg(100001004n); // Barev+
|
|
const natalia = await clientByTg(100001007n); // без подписки
|
|
const lilit = await clientByTg(100001025n); // Barev+
|
|
const provider = await db.provider.findFirst({ where: { userId: { not: null }, active: true }, orderBy: { createdAt: 'asc' } });
|
|
|
|
const clientIds = [arsen, dmitry, maria, sarah, alex, katya, natalia, lilit].map((c) => c.id);
|
|
await db.chatThread.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, ...(provider ? [{ providerId: provider.id }] : [])] } });
|
|
await db.conciergeMessage.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, ...(provider ? [{ providerId: provider.id }] : [])] } });
|
|
await db.client.updateMany({ where: { id: { in: clientIds } }, data: { chatChannel: null } });
|
|
await db.client.update({ where: { id: sarah.id }, data: { chatChannel: 'SUPPORT' } });
|
|
|
|
// файлы: фото от клиента и PDF от команды — в локальное хранилище
|
|
const root = path.resolve(import.meta.dirname, '../../..');
|
|
const photo = readFileSync(path.join(root, 'flowers/S.jpg'));
|
|
const photoKey = await putFile('chats/demo', photo, 'image/jpeg', 'kitchen.jpg');
|
|
const pdf = Buffer.from(
|
|
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 300 144]/Parent 2 0 R/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj 4 0 obj<</Length 52>>stream\nBT /F1 18 Tf 30 70 Td (Barev Club - checklist) Tj ET\nendstream endobj 5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF',
|
|
);
|
|
const pdfKey = await putFile('chats/demo', pdf, 'application/pdf', 'Чек-лист переезда.pdf');
|
|
|
|
type Msg = {
|
|
author: 'CLIENT' | 'PROVIDER' | 'OPERATOR' | 'SYSTEM';
|
|
text: string;
|
|
at: number;
|
|
by?: string;
|
|
internal?: boolean;
|
|
file?: { fileKey: string; fileName: string; mime: string; size: number };
|
|
param?: string;
|
|
deliveryError?: string;
|
|
source?: string;
|
|
};
|
|
|
|
async function thread(
|
|
peer: { clientId: string } | { providerId: string },
|
|
channel: 'SUPPORT' | 'CONCIERGE',
|
|
opts: { assigneeId?: string; status?: 'OPEN' | 'CLOSED'; waiting?: number },
|
|
messages: Msg[],
|
|
) {
|
|
const last = Math.min(...messages.map((m) => m.at));
|
|
const t = await db.chatThread.create({
|
|
data: {
|
|
...peer,
|
|
channel,
|
|
status: opts.status ?? 'OPEN',
|
|
assigneeId: opts.assigneeId,
|
|
lastMessageAt: ago(last),
|
|
waitingSince: opts.waiting != null ? ago(opts.waiting) : null,
|
|
closedAt: opts.status === 'CLOSED' ? ago(last) : null,
|
|
createdAt: ago(Math.max(...messages.map((m) => m.at))),
|
|
},
|
|
});
|
|
for (const m of messages) {
|
|
const incoming = m.author === 'CLIENT' || m.author === 'PROVIDER';
|
|
await db.conciergeMessage.create({
|
|
data: {
|
|
threadId: t.id,
|
|
...peer,
|
|
author: m.author,
|
|
adminUserId: m.by,
|
|
text: m.text,
|
|
internal: m.internal ?? m.author === 'SYSTEM',
|
|
source: m.source ?? (incoming ? 'BOT' : 'ADMIN'),
|
|
fileName: m.file?.fileName ?? m.param ?? null,
|
|
fileKey: m.file?.fileKey,
|
|
mime: m.file?.mime,
|
|
size: m.file?.size,
|
|
deliveryError: m.deliveryError,
|
|
// ждущие ответа входящие ещё не прочитаны
|
|
readAt: incoming && opts.waiting != null && m.at <= opts.waiting ? null : ago(m.at - 1),
|
|
createdAt: ago(m.at),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const kitchen = { fileKey: photoKey, fileName: 'kitchen.jpg', mime: 'image/jpeg', size: photo.length };
|
|
const checklist = { fileKey: pdfKey, fileName: 'Чек-лист переезда.pdf', mime: 'application/pdf', size: pdf.length };
|
|
|
|
// ── консьерж ──
|
|
await thread({ clientId: arsen.id }, 'CONCIERGE', { assigneeId: anna.id, waiting: 4 }, [
|
|
{ author: 'CLIENT', text: 'Добрый вечер! В субботу у жены день рождения. Можете забронировать столик на четверых где-нибудь с видом на Арарат?', at: 190 },
|
|
{ author: 'SYSTEM', text: 'took', at: 186, by: anna.id },
|
|
{ author: 'OPERATOR', text: 'Добрый вечер, Арсен! Поздравляем вашу супругу 🌸 Во сколько удобно и есть ли пожелания по кухне?', at: 185, by: anna.id },
|
|
{ author: 'CLIENT', text: 'К 19:00. Армянская или грузинская, без громкой музыки.', at: 170 },
|
|
{ author: 'OPERATOR', text: 'Elite: предпочитает звонок после 18:00, в прошлый раз просил столик у окна.', at: 168, by: anna.id, internal: true },
|
|
{ author: 'OPERATOR', text: 'Предлагаю «Lavash» на Туманяна или «Ташир Пицца» на крыше Каскада. В «Lavash» есть тихий зал и вид на Арарат из окон. Бронировать?', at: 150, by: anna.id },
|
|
{ author: 'CLIENT', text: 'Давайте Lavash. И можно торт заказать туда же?', at: 4 },
|
|
]);
|
|
|
|
await thread({ clientId: dmitry.id }, 'CONCIERGE', { waiting: 18 }, [
|
|
{ author: 'CLIENT', text: 'Здравствуйте! Нужен нотариус с переводом паспорта на армянский — на этой неделе. Сколько это стоит и где ближе к Кентрону?', at: 20 },
|
|
{ author: 'CLIENT', text: 'И ещё вопрос: на кухне после клининга остались разводы на фасадах, вот фото.', at: 18, file: kitchen },
|
|
]);
|
|
|
|
await thread({ clientId: katya.id }, 'CONCIERGE', { assigneeId: owner.id }, [
|
|
{ author: 'CLIENT', text: 'Подскажите, что нужно сделать в первую неделю после переезда?', at: 60 * 26 },
|
|
{ author: 'OPERATOR', text: 'Екатерина, собрали для вас чек-лист: SIM-карта, банк, регистрация, соцкарта. Всё по шагам в файле.', at: 60 * 25, by: owner.id, file: checklist },
|
|
{ author: 'CLIENT', text: 'Спасибо огромное, очень удобно!', at: 60 * 24 },
|
|
{ author: 'OPERATOR', text: 'Рады помочь! Если с банком будут сложности — напишите, подскажем отделение, где говорят по-русски.', at: 60 * 24 - 5, by: owner.id },
|
|
]);
|
|
|
|
await thread({ clientId: lilit.id }, 'CONCIERGE', { assigneeId: anna.id, waiting: 9 }, [
|
|
{ author: 'CLIENT', text: 'Բարև ձեզ, ուզում եմ տունը մաքրել շաբաթ օրը', at: 40 },
|
|
{ author: 'SYSTEM', text: 'moved', at: 35, by: gor.id, param: 'CONCIERGE' },
|
|
{ author: 'OPERATOR', text: 'Բարև, Լիլիթ։ Շաբաթ օրը ազատ է 11:00-ից։ Քանի՞ սենյակ է։', at: 30, by: anna.id },
|
|
{ author: 'CLIENT', text: 'Երեք սենյակ, խնդրում եմ 11:00', at: 9, source: 'MINIAPP' },
|
|
]);
|
|
|
|
// ── поддержка ──
|
|
await thread({ clientId: sarah.id }, 'SUPPORT', { waiting: 52 }, [
|
|
{ author: 'CLIENT', text: 'Hi! The cleaner was supposed to come at 10:00 but nobody showed up. Order #1042. Could you check please?', at: 52 },
|
|
{ author: 'CLIENT', text: 'Still waiting…', at: 31 },
|
|
]);
|
|
|
|
await thread({ clientId: maria.id }, 'SUPPORT', { assigneeId: operator.id }, [
|
|
{ author: 'CLIENT', text: 'Можно перенести уборку с четверга на пятницу?', at: 95 },
|
|
{ author: 'SYSTEM', text: 'took', at: 90, by: operator.id },
|
|
{ author: 'OPERATOR', text: 'Мария, да, перенесли на пятницу 12:00. Исполнитель тот же.', at: 88, by: operator.id },
|
|
{ author: 'CLIENT', text: 'Отлично, спасибо!', at: 80 },
|
|
{ author: 'OPERATOR', text: 'Хорошего дня 🙂', at: 79, by: operator.id },
|
|
]);
|
|
|
|
await thread({ clientId: natalia.id }, 'SUPPORT', { assigneeId: gor.id }, [
|
|
{ author: 'CLIENT', text: 'Как получить подарочный месяц Barev+?', at: 60 * 5 },
|
|
{ author: 'OPERATOR', text: 'Наталья, подарочный месяц включается по коду агентства. Отправьте код командой /code.', at: 60 * 4, by: gor.id, deliveryError: 'UNREACHABLE' },
|
|
{ author: 'OPERATOR', text: 'Клиентка заблокировала бота — сообщение не дошло. Позвонить по телефону из карточки.', at: 60 * 4 - 2, by: gor.id, internal: true },
|
|
]);
|
|
|
|
await thread({ clientId: alex.id }, 'SUPPORT', { assigneeId: gor.id, status: 'CLOSED' }, [
|
|
{ author: 'CLIENT', text: 'Hello, do you work on Sundays?', at: 60 * 30 },
|
|
{ author: 'OPERATOR', text: 'Hi Alex! Yes, cleaning is available on Sundays from 10:00 to 18:00.', at: 60 * 29, by: gor.id },
|
|
{ author: 'CLIENT', text: 'Great, thanks', at: 60 * 29 - 10 },
|
|
{ author: 'SYSTEM', text: 'closed', at: 60 * 29 - 12, by: gor.id },
|
|
]);
|
|
|
|
if (provider) {
|
|
await thread({ providerId: provider.id }, 'SUPPORT', { waiting: 7 }, [
|
|
{ author: 'PROVIDER', text: 'Здравствуйте, по заказу на Абовяна нет кода от подъезда, клиент не отвечает. Что делать?', at: 7 },
|
|
]);
|
|
}
|
|
|
|
// ── шаблоны ответов ──
|
|
const TEMPLATES: { channel: 'SUPPORT' | 'CONCIERGE' | null; title: string; text: string }[] = [
|
|
{ channel: null, title: 'Приветствие', text: 'Здравствуйте, {name}! Чем можем помочь?' },
|
|
{ channel: null, title: 'Уточняем', text: '{name}, уточняем детали и вернёмся с ответом в течение 15 минут.' },
|
|
{ channel: 'SUPPORT', title: 'Перенос заказа', text: 'Перенесли заказ. Исполнитель получил новое время, напоминание придёт за час.' },
|
|
{ channel: 'CONCIERGE', title: 'Бронь готова', text: 'Готово! Забронировали на ваше имя. Если планы изменятся — просто напишите сюда.' },
|
|
];
|
|
await db.chatTemplate.deleteMany({ where: { title: { in: TEMPLATES.map((t) => t.title) } } });
|
|
for (const t of TEMPLATES) await db.chatTemplate.create({ data: { ...t, createdById: owner.id } });
|
|
|
|
console.log(`Демо-чаты готовы: ${provider ? 9 : 8} диалогов, ${TEMPLATES.length} шаблона. Сотрудники: ${anna.name} (консьерж), ${gor.name} (поддержка).`);
|
|
process.exit(0);
|