Files
BarevClub/apps/api/scripts/dev-backoffice-demo.ts
T
zabudko-ruandClaude Opus 5.5 23ffb2bf1d 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>
2026-10-21 01:50:37 +04:00

209 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Локальная разработка: демо-клиенты для новой админки (дашборд, список клиентов, подписки, деньги).
* 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();