Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
/**
* Локальная разработка: демо-клиенты для новой админки (дашборд, список клиентов, подписки, деньги).
* 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 } } });
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, '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();
+103
View File
@@ -0,0 +1,103 @@
/**
* Локальная разработка: демо-данные для двух сценариев клиентского приложения.
* pnpm --filter @barev/api dev:demo
* Анна — переезжает (план «под ключ» с шагами, документы, переписка с консьержем),
* John — живёт в Армении с Barev+, 李明 — с Elite, Ольга — живёт без подписки; партнёры клуба.
* Повторный запуск пересоздаёт демо-состояние этих клиентов.
*/
import { getDb } from '@barev/db';
import { acceptPartnerInvite, createAppLead, createOwnerInvite, createStaffInvite, grantSubscription, savePartnerOffer, startClient, giveConsent, type Ctx } from '@barev/services';
import { env } from '../src/env.ts';
if (env.isProd) throw new Error('Только для разработки');
const db = getDb();
const ctx: Ctx = { db, notify: { emit: () => {} }, now: () => new Date() };
const DAY = 24 * 3600_000;
async function client(id: number, firstName: string, lang: string, username?: string) {
const res = await startClient(ctx, { id, first_name: firstName, language_code: lang, username });
const c = await db.client.findUniqueOrThrow({ where: { id: res.clientId } });
if (!c.consentAt) await giveConsent(ctx, c.id);
// чистое демо-состояние
await db.lead.deleteMany({ where: { clientId: c.id } });
await db.clientRequest.deleteMany({ where: { clientId: c.id } });
await db.conciergeMessage.deleteMany({ where: { clientId: c.id } });
await db.subscription.deleteMany({ where: { clientId: c.id, source: 'MANUAL' } });
return c.id;
}
const anna = await client(100000001, 'Анна', 'ru', 'anna_moves');
await db.client.update({ where: { id: anna }, data: { segment: 'MOVING' } });
const moveDate = new Date(Date.now() + 40 * DAY).toISOString().slice(0, 10);
const lead = await createAppLead(ctx, anna, { moveWhen: 'date', moveDate: new Date(`${moveDate}T00:00:00Z`), package: 'TURNKEY', comment: 'Двое взрослых и кот' });
const notes: Record<string, { status: 'DONE' | 'IN_PROGRESS'; note?: string }> = {
airport: { status: 'DONE', note: 'Водитель Арам встретит с табличкой, +374 91 000 000' },
stay: { status: 'DONE', note: 'Отель Republica, 5 ночей, завтрак включён' },
flat: { status: 'IN_PROGRESS', note: 'Подобрали 3 варианта в Кентроне, просмотры в среду с 15:00' },
};
for (const s of lead.steps) if (notes[s.code]) await db.leadStep.update({ where: { id: s.id }, data: notes[s.code]! });
await db.lead.update({ where: { id: lead.id }, data: { status: 'IN_WORK' } });
await db.leadDoc.updateMany({ where: { leadId: lead.id, code: { in: ['passport', 'passportCopies', 'photos'] } }, data: { checked: true } });
const admin = await db.adminUser.findFirst();
const msgs: [string, 'CLIENT' | 'OPERATOR', number][] = [
['Здравствуйте! Можно ли взять кота в отель?', 'CLIENT', 50],
['Добрый день! Да, в Republica можно с животными, предупредили отель 🐈', 'OPERATOR', 45],
['Спасибо! А что с апостилем на дипломе — делать до переезда?', 'CLIENT', 5],
];
for (const [text, author, minAgo] of msgs) {
await db.conciergeMessage.create({
data: { clientId: anna, author, text, adminUserId: author === 'OPERATOR' ? admin?.id : undefined, readAt: minAgo > 10 ? new Date() : null, createdAt: new Date(Date.now() - minAgo * 60_000) },
});
}
const john = await client(100000002, 'John', 'en', 'john_yerevan');
await db.client.update({ where: { id: john }, data: { segment: 'LIVING' } });
await grantSubscription(ctx, { clientId: john, plan: 'BAREV_PLUS', days: 30 });
await db.clientRequest.create({ data: { clientId: john, kind: 'SERVICE', serviceCode: 'repair', comment: 'Kitchen tap is leaking, after 6 pm please' } });
await db.conciergeMessage.create({ data: { clientId: john, author: 'CLIENT', text: 'Hi! Can you recommend an English-speaking dentist?' } });
const li = await client(100000003, '李明', 'zh', 'li_ming');
await db.client.update({ where: { id: li }, data: { segment: 'LIVING' } });
await grantSubscription(ctx, { clientId: li, plan: 'ELITE', days: 30 });
const olga = await client(100000004, 'Ольга', 'ru', 'olga_local');
await db.client.update({ where: { id: olga }, data: { segment: 'LIVING' } });
await db.clientRequest.create({ data: { clientId: olga, kind: 'SUBSCRIPTION', plan: 'ELITE' } });
// новый клиент — увидит выбор сценария при первом входе
await db.client.updateMany({ where: { user: { telegramId: 100000099n } }, data: { segment: null } });
if ((await db.partner.count()) === 0) {
const offer = (ru: string, en: string, zh: string, hy: string, es: string) => ({ ru, en, zh, hy, es });
await db.partner.createMany({
data: [
{ category: 'cafe', name: 'Lumen Coffee', address: 'Ереван, ул. Абовяна 18', offer: offer('−15% на кофе и завтраки', '−15% on coffee and breakfast', '咖啡和早餐 −15%', '−15% սուրճի և նախաճաշի համար', '−15% en café y desayunos') },
{ category: 'restaurant', name: 'Dolmama', address: 'Ереван, ул. Пушкина 10', offer: offer('Десерт в подарок к ужину', 'Free dessert with dinner', '晚餐赠送甜点', 'Աղանդեր նվեր ընթրիքին', 'Postre de regalo con la cena') },
{ category: 'restaurant', name: 'Lavash', address: 'Ереван, ул. Туманяна 21', offer: offer('−10% на всё меню', '−10% on the whole menu', '全部菜单 −10%', '−10% ամբողջ մենյուի համար', '−10% en toda la carta') },
{ category: 'shop', name: 'Gum Market', address: 'Ереван, ул. Московяна 35', offer: offer('Подарочный набор специй', 'Free spice gift set', '赠送香料礼盒', 'Համեմունքների նվեր հավաքածու', 'Set de especias de regalo') },
{ category: 'beauty', name: 'Barber Yerevan', address: 'Ереван, пр. Маштоца 7', offer: offer('−20% на первую стрижку', '−20% on the first haircut', '首次理发 −20%', '−20% առաջին սանրվածքի համար', '−20% en el primer corte') },
],
});
}
// предложения партнёров по уровням и лимитам; Арам — собственник Lumen Coffee, Гаяне — сотрудник
const lumen =
(await db.partner.findFirst({ where: { name: 'Lumen Coffee' } })) ??
(await db.partner.create({ data: { category: 'cafe', name: 'Lumen Coffee', address: 'Ереван, ул. Абовяна 18', offer: { ru: 'Кофейня у Каскада', en: 'Café near the Cascade', zh: '', hy: '', es: '' } } }));
if (lumen && (await db.partnerOffer.count({ where: { partnerId: lumen.id } })) === 0) {
const L = (ru: string, en: string) => ({ ru, en, zh: '', hy: '', es: '' });
await savePartnerOffer(ctx, { partnerId: lumen.id, title: L('−10% на кофе и завтраки', '−10% on coffee and breakfast'), minLevel: null, limit: 'NONE', limitCount: 1, active: true });
await savePartnerOffer(ctx, { partnerId: lumen.id, title: L('Десерт на первый визит', 'Dessert on the first visit'), minLevel: null, limit: 'ONCE', limitCount: 1, active: true });
await savePartnerOffer(ctx, { partnerId: lumen.id, title: L('Кофе в подарок', 'Free coffee'), minLevel: 'BAREV_PLUS', limit: 'WEEK', limitCount: 1, active: true });
await savePartnerOffer(ctx, { partnerId: lumen.id, title: L('Завтрак для двоих', 'Breakfast for two'), minLevel: 'ELITE', limit: 'MONTH', limitCount: 2, active: true });
}
if (lumen && (await db.partnerMember.count({ where: { partnerId: lumen.id } })) === 0) {
const { url } = await createOwnerInvite(ctx, lumen.id);
await acceptPartnerInvite(ctx, { id: 300000010, first_name: 'Арам', language_code: 'ru' }, url.split('start=')[1]!);
const staff = await createStaffInvite(ctx, 300000010n, lumen.id);
await acceptPartnerInvite(ctx, { id: 300000011, first_name: 'Гаяне', language_code: 'ru' }, staff.url.split('start=')[1]!);
}
console.log('Демо готово: Анна — переезд, John — Barev+, 李明 — Elite, Ольга — без подписки, «Новый клиент» — выбор сценария.');
await db.$disconnect();
+37
View File
@@ -0,0 +1,37 @@
/**
* Локальная разработка без Telegram: печатает ссылки на Mini App с подписанным initData.
*
* pnpm dev:initdata — ссылки для всех тестовых пользователей из seed
* pnpm dev:initdata -- client 100000001 — конкретный пользователь
*
* Подпись делается тем же токеном, которым API проверяет initData (настоящий или DEV_*).
*/
import { env } from '../src/env.ts';
import { signInitData } from '../src/auth/telegram.ts';
if (env.isProd) throw new Error('Только для разработки');
const users: { app: 'client' | 'pro'; id: number; name: string; lang: string }[] = [
{ app: 'client', id: 100000001, name: 'Анна', lang: 'ru' },
{ app: 'client', id: 100000002, name: 'John', lang: 'en' },
{ app: 'client', id: 100000003, name: '李明', lang: 'zh' },
{ app: 'client', id: 100000004, name: 'Ольга', lang: 'ru' },
{ app: 'client', id: 100000099, name: 'Новый клиент', lang: 'ru' },
{ app: 'pro', id: 200000001, name: 'Мария (клинер)', lang: 'ru' },
{ app: 'pro', id: 200000003, name: 'Elena (клинер)', lang: 'es' },
{ app: 'pro', id: 200000011, name: 'Лусине (сотрудник компании)', lang: 'hy' },
{ app: 'pro', id: 300000001, name: 'Карен (диспетчер компании)', lang: 'ru' },
{ app: 'pro', id: 300000010, name: 'Арам (собственник Lumen Coffee)', lang: 'ru' },
{ app: 'pro', id: 300000011, name: 'Гаяне (сотрудник Lumen Coffee)', lang: 'ru' },
];
const [appArg, idArg] = process.argv.slice(2).filter((a) => a !== '--');
const selected = appArg && idArg ? [{ app: appArg as 'client' | 'pro', id: Number(idArg), name: `user ${idArg}`, lang: 'ru' }] : users;
for (const u of selected) {
const token = u.app === 'client' ? env.clientBotToken : env.proBotToken;
// initData живёт TG_INIT_DATA_MAX_AGE секунд — для разработки этого достаточно
const initData = signInitData(token, { id: u.id, first_name: u.name.split(' ')[0], language_code: u.lang });
const base = u.app === 'client' ? env.CLIENT_MINIAPP_URL : env.PRO_MINIAPP_URL;
console.log(`${u.app.padEnd(6)} ${u.name}\n ${base}/#tgWebAppData=${encodeURIComponent(initData)}\n`);
}