/** * Локальная разработка: демо-переписка для раздела «Чаты» новой админки (этап 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<>endobj 2 0 obj<>endobj 3 0 obj<>>>>>endobj 4 0 obj<>stream\nBT /F1 18 Tf 30 70 Td (Barev Club - checklist) Tj ET\nendstream endobj 5 0 obj<>endobj\ntrailer<>\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);