- 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>
209 lines
10 KiB
TypeScript
209 lines
10 KiB
TypeScript
/**
|
||
* Локальная разработка: демо-клиенты для новой админки (дашборд, список клиентов, подписки, деньги).
|
||
* pnpm --filter @barev/api dev:bo-demo
|
||
* Клиенты помечены тегом «демо» и telegramId 100001000+. Повторный запуск пересоздаёт их.
|
||
* Цены условные — реальных цен подписки пока нет.
|
||
*/
|
||
import { getDb } from '@barev/db';
|
||
import { env } from '../src/env.ts';
|
||
|
||
if (env.isProd) throw new Error('Только для разработки');
|
||
|
||
const db = getDb();
|
||
const DAY = 24 * 3600_000;
|
||
const now = Date.now();
|
||
const TG_FROM = 100001000n;
|
||
|
||
// детерминированный «случай» — одинаковые данные при каждом запуске
|
||
let seed = 42;
|
||
const rnd = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
|
||
const pick = <T,>(a: readonly T[]) => a[Math.floor(rnd() * a.length)]!;
|
||
const between = (a: number, b: number) => Math.round(a + rnd() * (b - a));
|
||
|
||
// ── чистим прошлое демо ──
|
||
const old = await db.user.findMany({ where: { telegramId: { gte: TG_FROM, lt: TG_FROM + 1000n } }, include: { client: true } });
|
||
const oldClientIds = old.map((u) => u.client?.id).filter((x): x is string => !!x);
|
||
await db.rating.deleteMany({ where: { clientId: { in: oldClientIds } } });
|
||
await db.order.deleteMany({ where: { clientId: { in: oldClientIds } } });
|
||
await db.subscription.deleteMany({ where: { clientId: { in: oldClientIds } } });
|
||
await db.cardUsage.deleteMany({ where: { clientId: { in: oldClientIds } } });
|
||
await db.auditLog.deleteMany({ where: { entityId: { in: oldClientIds } } });
|
||
// демо-чаты (dev:chats-demo) ссылаются на клиентов
|
||
await db.chatThread.deleteMany({ where: { clientId: { in: oldClientIds } } });
|
||
await db.conciergeMessage.deleteMany({ where: { clientId: { in: oldClientIds } } });
|
||
await db.client.deleteMany({ where: { id: { in: oldClientIds } } });
|
||
await db.user.deleteMany({ where: { id: { in: old.map((u) => u.id) } } });
|
||
await db.lead.deleteMany({ where: { contact: { startsWith: '+374 99 00' } } });
|
||
|
||
const [services, agencies, providers, partners, owner] = await Promise.all([
|
||
db.service.findMany({ where: { active: true } }),
|
||
db.agency.findMany({ where: { active: true } }),
|
||
db.provider.findMany({ where: { active: true } }),
|
||
db.partner.findMany({ where: { active: true } }),
|
||
db.adminUser.findFirst({ where: { roles: { has: 'SUPERADMIN' } } }),
|
||
]);
|
||
|
||
const PEOPLE: [string, string | null, string | null, 'ru' | 'en' | 'hy' | 'zh' | 'es'][] = [
|
||
['Мария', 'Иванова', 'masha_iv', 'ru'],
|
||
['Дмитрий', 'Козлов', 'dkozlov', 'ru'],
|
||
['Sarah', 'Miller', 'sarah_m', 'en'],
|
||
['Арсен', 'Григорян', 'arsen_g', 'hy'],
|
||
['Екатерина', 'Смирнова', 'katya_sm', 'ru'],
|
||
['Alex', 'Novak', 'alexnovak', 'en'],
|
||
['Павел', 'Орлов', 'p_orlov', 'ru'],
|
||
['Наталья', 'Белова', 'nbelova', 'ru'],
|
||
['Tom', 'Becker', 'tbecker', 'en'],
|
||
['Ольга', 'Лебедева', 'olga_leb', 'ru'],
|
||
['Игорь', 'Соколов', 'igor_s', 'ru'],
|
||
['Lucía', 'Fernández', 'lucia_f', 'es'],
|
||
['Анна', 'Морозова', 'annamoroz', 'ru'],
|
||
['王', '伟', 'wangwei', 'zh'],
|
||
['Сергей', 'Волков', 'svolkov', 'ru'],
|
||
['Елена', 'Кузнецова', 'elenakuz', 'ru'],
|
||
['Карен', 'Арутюнян', 'karen_a', 'hy'],
|
||
['Max', 'Fischer', 'maxfi', 'en'],
|
||
['Юлия', 'Попова', 'yulia_pop', 'ru'],
|
||
['Андрей', 'Зайцев', 'azaitsev', 'ru'],
|
||
['Ирина', 'Павлова', 'irina_p', 'ru'],
|
||
['Никита', 'Семёнов', 'nik_sem', 'ru'],
|
||
['Emma', 'Wilson', 'emmaw', 'en'],
|
||
['Светлана', 'Голубева', 'sveta_g', 'ru'],
|
||
['Михаил', 'Виноградов', 'mvinogradov', 'ru'],
|
||
['Лилит', 'Карапетян', 'lilit_k', 'hy'],
|
||
['Артём', 'Богданов', 'artem_b', 'ru'],
|
||
['Ксения', 'Фёдорова', 'ksu_f', 'ru'],
|
||
['Daniel', 'Costa', 'dcosta', 'es'],
|
||
['Виктор', 'Михайлов', 'v_mikh', 'ru'],
|
||
['Алина', 'Комарова', 'alina_k', 'ru'],
|
||
['Роман', 'Новиков', 'r_novikov', 'ru'],
|
||
['Полина', 'Егорова', null, 'ru'],
|
||
['Григорий', 'Ткаченко', 'grisha_t', 'ru'],
|
||
['Hannah', 'Schmidt', 'hannah_s', 'en'],
|
||
['Вера', 'Андреева', 'vera_a', 'ru'],
|
||
['Олег', 'Николаев', 'olegnik', 'ru'],
|
||
['Дарья', 'Ковалёва', 'dasha_k', 'ru'],
|
||
];
|
||
const TAGS = ['VIP', 'Кентрон', 'с детьми', 'IT', 'просит звонок', 'жалоба'];
|
||
const UTMS = ['instagram', 'telegram_ads', 'google'];
|
||
const PRICE = { BAREV_PLUS: 4900, ELITE: 19900 } as const;
|
||
|
||
let created = 0;
|
||
for (const [i, [firstName, lastName, username, locale]] of PEOPLE.entries()) {
|
||
const ageDays = between(0, 58);
|
||
const createdAt = new Date(now - ageDays * DAY - between(0, 20) * 3600_000);
|
||
const user = await db.user.create({
|
||
data: { telegramId: TG_FROM + BigInt(i), firstName, lastName, username: username ?? undefined, locale, phone: rnd() < 0.5 ? `+374 9${between(1, 9)} ${between(100, 999)} ${between(100, 999)}` : undefined, createdAt },
|
||
});
|
||
const srcRoll = rnd();
|
||
const agency = srcRoll < 0.35 && agencies.length ? pick(agencies) : null;
|
||
const utm = !agency && srcRoll < 0.6 ? { utm_source: pick(UTMS) } : undefined;
|
||
const tags = rnd() < 0.35 ? [pick(TAGS), ...(rnd() < 0.3 ? [pick(TAGS)] : [])] : [];
|
||
const client = await db.client.create({
|
||
data: {
|
||
userId: user.id,
|
||
cardCode: `D${String(1000 + i)}${Math.floor(rnd() * 90 + 10)}`,
|
||
source: agency ? (rnd() < 0.7 ? 'deeplink' : 'manual_code') : 'organic',
|
||
agencyId: agency?.id,
|
||
agencyAttachedAt: agency ? createdAt : undefined,
|
||
utm,
|
||
consentAt: createdAt,
|
||
consentVersion: '1',
|
||
segment: rnd() < 0.4 ? 'MOVING' : 'LIVING',
|
||
tags: [...new Set(['демо', ...tags])],
|
||
createdAt,
|
||
},
|
||
});
|
||
created++;
|
||
|
||
// подписки
|
||
const subRoll = rnd();
|
||
if (agency && rnd() < 0.8) {
|
||
const s = new Date(createdAt.getTime() + 3600_000);
|
||
await db.subscription.create({ data: { clientId: client.id, plan: 'BAREV_PLUS', source: 'AGENCY_GIFT', agencyId: agency.id, startsAt: s, endsAt: new Date(s.getTime() + 30 * DAY), createdAt: s } });
|
||
// часть подарков превращается в покупку
|
||
if (rnd() < 0.45 && ageDays > 20) {
|
||
const ps = new Date(s.getTime() + 30 * DAY);
|
||
await db.subscription.create({
|
||
data: { clientId: client.id, plan: 'BAREV_PLUS', source: 'PAID', amount: PRICE.BAREV_PLUS * 3, payMethod: pick(['cash', 'transfer', 'card']), grantedByAdminId: owner?.id, startsAt: ps, endsAt: new Date(ps.getTime() + 90 * DAY), createdAt: new Date(ps.getTime() - 2 * DAY) },
|
||
});
|
||
}
|
||
} else if (subRoll < 0.45) {
|
||
const plan = rnd() < 0.25 ? 'ELITE' : 'BAREV_PLUS';
|
||
const months = pick([1, 1, 3, 6, 12]);
|
||
const bought = new Date(createdAt.getTime() + between(0, Math.max(1, ageDays - 1)) * DAY);
|
||
// часть подписок истекает на этой неделе
|
||
const endsAt = rnd() < 0.25 ? new Date(now + between(1, 6) * DAY) : new Date(bought.getTime() + months * 30 * DAY);
|
||
await db.subscription.create({
|
||
data: { clientId: client.id, plan, source: 'PAID', amount: PRICE[plan] * months, payMethod: pick(['cash', 'transfer', 'card']), grantedByAdminId: owner?.id, startsAt: bought, endsAt, createdAt: bought },
|
||
});
|
||
} else if (subRoll < 0.6) {
|
||
const s = new Date(createdAt.getTime() + DAY);
|
||
await db.subscription.create({
|
||
data: { clientId: client.id, plan: 'BAREV_PLUS', source: 'MANUAL', grantedByAdminId: owner?.id, note: 'Подарок за отзыв', startsAt: s, endsAt: new Date(s.getTime() + 30 * DAY), createdAt: s },
|
||
});
|
||
}
|
||
|
||
// оплаченные заказы
|
||
const nOrders = rnd() < 0.55 ? between(1, 4) : 0;
|
||
for (let k = 0; k < nOrders && services.length; k++) {
|
||
const svc = pick(services);
|
||
const at = new Date(now - between(0, Math.max(0, ageDays)) * DAY - between(1, 10) * 3600_000);
|
||
const price = pick([12000, 15000, 18000, 25000, 30000, 45000, 60000]);
|
||
const provider = providers.length ? pick(providers) : null;
|
||
const order = await db.order.create({
|
||
data: {
|
||
clientId: client.id,
|
||
serviceId: svc.id,
|
||
params: {},
|
||
scheduledAt: at,
|
||
durationMinutes: 120,
|
||
locale,
|
||
basePrice: price,
|
||
totalPrice: price,
|
||
status: 'DONE',
|
||
providerId: provider?.id,
|
||
paymentStatus: 'PAID',
|
||
paidAt: new Date(at.getTime() + 3 * 3600_000),
|
||
completedAt: at,
|
||
createdAt: new Date(at.getTime() - DAY),
|
||
},
|
||
});
|
||
if (provider && rnd() < 0.6) {
|
||
await db.rating.create({ data: { orderId: order.id, clientId: client.id, providerId: provider.id, score: pick([5, 5, 5, 4, 4, 3, 2]), createdAt: new Date(at.getTime() + 5 * 3600_000) } });
|
||
}
|
||
}
|
||
|
||
// сканы клубной карты у заведений
|
||
if (partners.length && rnd() < 0.5) {
|
||
for (let k = between(1, 5); k > 0; k--) {
|
||
await db.cardUsage.create({ data: { clientId: client.id, partnerId: pick(partners).id, level: 'BAREV_PLUS', createdAt: new Date(now - between(0, 29) * DAY) } });
|
||
}
|
||
}
|
||
}
|
||
|
||
// заявки с сайта для воронки
|
||
for (let k = 0; k < 14; k++) {
|
||
await db.lead.create({
|
||
data: {
|
||
key: `demo-${k}`,
|
||
name: pick(PEOPLE)[0],
|
||
contact: `+374 99 00 ${String(k).padStart(2, '0')} 00`,
|
||
contactType: 'PHONE',
|
||
locale: 'ru',
|
||
consentAt: new Date(),
|
||
consentVersion: '1',
|
||
status: k < 3 ? 'NEW' : 'DONE',
|
||
createdAt: new Date(now - between(0, 29) * DAY),
|
||
},
|
||
});
|
||
}
|
||
|
||
// пересчёт рейтингов исполнителей
|
||
for (const p of providers) {
|
||
const agg = await db.rating.aggregate({ where: { providerId: p.id }, _sum: { score: true }, _count: true });
|
||
await db.provider.update({ where: { id: p.id }, data: { ratingSum: agg._sum.score ?? 0, ratingCount: agg._count } });
|
||
}
|
||
|
||
console.log(`Демо для новой админки: ${created} клиентов. Открывайте http://localhost:5176`);
|
||
await db.$disconnect();
|