Files
BarevClub/apps/api/scripts/dev-chats-demo.ts
T
zabudko-ruandClaude Opus 5.5 2772837e29 Chats: show which order a provider writes about, or that there is no order
- Provider messages store the order that was current when they wrote: a started
  order first, otherwise the nearest assigned one within -4h..+12h; none — "no order".
- Admin chat: order tag above each provider message, "now / next" order strip in
  the conversation header, order number (or "no order") in the thread list.
- Telegram notification to the team gets an order line or "no current order".
- Test for the order-picking rule; demo shows both cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 02:02:31 +04:00

219 lines
14 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 { providerCurrentOrder, silentNotifier, type Ctx } from '@barev/services';
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 busy = await db.provider.findFirst({ where: { userId: { not: null }, active: true, orders: { some: { status: { in: ['EN_ROUTE', 'CLEANING', 'ON_SITE', 'REPAIRING'] } } } } });
const providerIds = [provider?.id, busy?.id].filter((x): x is string => !!x);
const svc: Ctx = { db, notify: silentNotifier, now: () => new Date() };
// демо-даты: у «занятого» исполнителя заказ идёт сейчас, у первого — следующий заказ завтра
if (busy) {
const o = await db.order.findFirst({ where: { providerId: busy.id, status: { in: ['EN_ROUTE', 'CLEANING', 'ON_SITE', 'REPAIRING'] } } });
if (o) await db.order.update({ where: { id: o.id }, data: { scheduledAt: ago(90) } });
}
if (provider) {
const o = await db.order.findFirst({ where: { providerId: provider.id, status: 'ASSIGNED' } });
if (o) await db.order.update({ where: { id: o.id }, data: { scheduledAt: new Date(now + 20 * 60 * MIN) } });
}
const clientIds = [arsen, dmitry, maria, sarah, alex, katya, natalia, lilit].map((c) => c.id);
await db.chatThread.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, { providerId: { in: providerIds } }] } });
await db.conciergeMessage.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, { providerId: { in: providerIds } }] } });
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';
// исполнитель: заказ, который был текущим в момент сообщения
const order = 'providerId' in peer && m.author === 'PROVIDER' ? await providerCurrentOrder(svc, peer.providerId, ago(m.at)) : null;
await db.conciergeMessage.create({
data: {
threadId: t.id,
...peer,
orderId: order?.id ?? null,
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 (busy && busy.id !== provider?.id) {
await thread({ providerId: busy.id }, 'SUPPORT', { waiting: 3 }, [
{ author: 'PROVIDER', text: 'Клиентка просит дополнительно помыть балкон — это входит в заказ или считать отдельно?', at: 3 },
]);
}
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(`Демо-чаты готовы: ${8 + providerIds.length} диалогов, ${TEMPLATES.length} шаблона. Сотрудники: ${anna.name} (консьерж), ${gor.name} (поддержка).`);
process.exit(0);