Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1

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-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
{
"name": "@barev/api",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/router.ts",
"./telegram": "./src/auth/telegram.ts"
},
"scripts": {
"dev": "tsx watch --clear-screen=false src/server.ts",
"start": "tsx src/server.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"dev:initdata": "tsx scripts/dev-initdata.ts",
"dev:demo": "tsx scripts/dev-demo.ts",
"dev:bo-demo": "tsx scripts/dev-backoffice-demo.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1152.0",
"@aws-sdk/s3-request-presigner": "^3.1152.0",
"@barev/core": "workspace:*",
"@barev/db": "workspace:*",
"@barev/services": "workspace:*",
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^11.1.0",
"@fastify/multipart": "^10.0.0",
"@node-rs/argon2": "^2.0.2",
"@sentry/node": "^10.0.0",
"@trpc/server": "^11.19.0",
"dotenv": "^17.0.0",
"fastify": "^5.12.0",
"superjson": "^2.2.6",
"zod": "^4.3.0"
},
"devDependencies": {
"tsx": "^4.20.0",
"vitest": "^4.1.0"
}
}
+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`);
}
+58
View File
@@ -0,0 +1,58 @@
import { createHmac, randomBytes } from 'node:crypto';
import type { AdminUser, Db } from '@barev/db';
import { env } from '../env.ts';
export const ADMIN_COOKIE = 'barev_admin';
const SESSION_HOURS = 12;
/** В БД хранится только HMAC токена — утечка таблицы не даёт войти */
const tokenHash = (token: string) => createHmac('sha256', env.ADMIN_SESSION_SECRET).update(token).digest('hex');
export async function createSession(db: Db, adminUserId: string) {
const token = randomBytes(32).toString('base64url');
await db.adminSession.create({
data: { adminUserId, tokenHash: tokenHash(token), expiresAt: new Date(Date.now() + SESSION_HOURS * 3600_000) },
});
return token;
}
export async function findSession(db: Db, token: string | undefined) {
if (!token) return null;
const session = await db.adminSession.findUnique({ where: { tokenHash: tokenHash(token) }, include: { adminUser: true } });
if (!session || session.expiresAt < new Date() || !session.adminUser.active) return null;
return session;
}
export async function markVerified(db: Db, token: string) {
await db.adminSession.update({ where: { tokenHash: tokenHash(token) }, data: { verified: true } });
}
export async function destroySession(db: Db, token: string | undefined) {
if (token) await db.adminSession.deleteMany({ where: { tokenHash: tokenHash(token) } });
}
export const cookieOptions = {
httpOnly: true,
sameSite: 'strict' as const,
secure: env.isProd,
path: '/',
maxAge: SESSION_HOURS * 3600,
};
/** Простой лимит попыток входа: 10 за 15 минут на email+ip */
const attempts = new Map<string, { n: number; until: number }>();
export function loginThrottled(key: string): boolean {
const now = Date.now();
const a = attempts.get(key);
if (!a || a.until < now) {
attempts.set(key, { n: 1, until: now + 15 * 60_000 });
return false;
}
a.n++;
return a.n > 10;
}
export function publicAdmin(u: AdminUser) {
return { id: u.id, email: u.email, name: u.name, role: u.role, totpEnabled: u.totpEnabled };
}
+65
View File
@@ -0,0 +1,65 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Проверка подписи Telegram Mini App initData (раздел 6, критерий приёмки).
* https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
*
* secret = HMAC_SHA256(key = "WebAppData", data = bot_token)
* hash = hex(HMAC_SHA256(key = secret, data = data_check_string))
*/
export interface TelegramInitUser {
id: number;
first_name?: string;
last_name?: string;
username?: string;
language_code?: string;
}
export type InitDataResult =
| { ok: true; user: TelegramInitUser; authDate: Date; startParam?: string }
| { ok: false; reason: 'MISSING' | 'BAD_HASH' | 'EXPIRED' | 'NO_USER' | 'NO_TOKEN' };
function dataCheckString(params: URLSearchParams): string {
return [...params.entries()]
.filter(([k]) => k !== 'hash')
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${k}=${v}`)
.join('\n');
}
function sign(botToken: string, dcs: string): string {
const secret = createHmac('sha256', 'WebAppData').update(botToken).digest();
return createHmac('sha256', secret).update(dcs).digest('hex');
}
export function validateInitData(raw: string | undefined, botToken: string, maxAgeSeconds: number, now = new Date()): InitDataResult {
if (!botToken) return { ok: false, reason: 'NO_TOKEN' };
if (!raw) return { ok: false, reason: 'MISSING' };
const params = new URLSearchParams(raw);
const hash = params.get('hash');
if (!hash || !/^[0-9a-f]{64}$/.test(hash)) return { ok: false, reason: 'BAD_HASH' };
const expected = sign(botToken, dataCheckString(params));
if (!timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(expected, 'hex'))) return { ok: false, reason: 'BAD_HASH' };
const authDate = new Date(Number(params.get('auth_date')) * 1000);
if (!Number.isFinite(authDate.getTime()) || now.getTime() - authDate.getTime() > maxAgeSeconds * 1000) {
return { ok: false, reason: 'EXPIRED' };
}
try {
const user = JSON.parse(params.get('user') ?? '') as TelegramInitUser;
if (!user?.id) return { ok: false, reason: 'NO_USER' };
return { ok: true, user, authDate, startParam: params.get('start_param') ?? undefined };
} catch {
return { ok: false, reason: 'NO_USER' };
}
}
/** Подписать initData — для локальной разработки и тестов */
export function signInitData(botToken: string, user: TelegramInitUser, opts: { authDate?: Date; startParam?: string } = {}): string {
const params = new URLSearchParams();
params.set('auth_date', String(Math.floor((opts.authDate ?? new Date()).getTime() / 1000)));
params.set('query_id', 'dev');
params.set('user', JSON.stringify(user));
if (opts.startParam) params.set('start_param', opts.startParam);
params.set('hash', sign(botToken, dataCheckString(params)));
return params.toString();
}
+63
View File
@@ -0,0 +1,63 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
/** TOTP (RFC 6238, SHA-1, 6 цифр, 30 с) — совместим с Google Authenticator, 1Password и т.п. */
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32Encode(buf: Buffer): string {
let bits = 0;
let value = 0;
let out = '';
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += B32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
return out;
}
export function base32Decode(s: string): Buffer {
const clean = s.replace(/=+$/, '').toUpperCase();
let bits = 0;
let value = 0;
const out: number[] = [];
for (const ch of clean) {
const idx = B32.indexOf(ch);
if (idx < 0) throw new Error('bad base32');
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(out);
}
export const generateTotpSecret = () => base32Encode(randomBytes(20));
export function totpCode(secret: string, time = Date.now(), step = 30): string {
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(Math.floor(time / 1000 / step)));
const h = createHmac('sha1', base32Decode(secret)).update(counter).digest();
const offset = h[h.length - 1]! & 0xf;
const bin = (h.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
return String(bin).padStart(6, '0');
}
/** Допускаем ±1 шаг на рассинхрон часов */
export function verifyTotp(secret: string, code: string, time = Date.now()): boolean {
if (!/^\d{6}$/.test(code)) return false;
return [-1, 0, 1].some((w) => {
const expected = totpCode(secret, time + w * 30_000);
return timingSafeEqual(Buffer.from(expected), Buffer.from(code));
});
}
export function otpauthUri(secret: string, account: string, issuer = 'Barev Club Admin') {
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
}
+49
View File
@@ -0,0 +1,49 @@
import path from 'node:path';
import { config } from 'dotenv';
import { z } from 'zod';
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
API_PORT: z.coerce.number().default(4000),
DATABASE_URL: z.string(),
REDIS_URL: z.string().default('redis://localhost:6379'),
CLIENT_BOT_TOKEN: z.string().default(''),
PRO_BOT_TOKEN: z.string().default(''),
DEV_CLIENT_BOT_TOKEN: z.string().default(''),
DEV_PRO_BOT_TOKEN: z.string().default(''),
CLIENT_BOT_USERNAME: z.string().default('barev_club_bot'),
PRO_BOT_USERNAME: z.string().default('barev_pro_bot'),
TG_INIT_DATA_MAX_AGE: z.coerce.number().default(86400),
LANDING_URL: z.string().default('http://localhost:3000'),
CLIENT_MINIAPP_URL: z.string().default('http://localhost:5174'),
PRO_MINIAPP_URL: z.string().default('http://localhost:5175'),
ADMIN_URL: z.string().default('http://localhost:5173'),
ADMIN_SESSION_SECRET: z.string().min(16),
S3_ENDPOINT: z.string().default('http://localhost:9000'),
S3_REGION: z.string().default('us-east-1'),
S3_BUCKET: z.string().default('barev-private'),
S3_ACCESS_KEY: z.string().default(''),
S3_SECRET_KEY: z.string().default(''),
SENTRY_DSN: z.string().default(''),
});
const parsed = schema.parse(process.env);
const isProd = parsed.NODE_ENV === 'production';
if (isProd && (!parsed.CLIENT_BOT_TOKEN || !parsed.PRO_BOT_TOKEN)) {
throw new Error('CLIENT_BOT_TOKEN и PRO_BOT_TOKEN обязательны в production');
}
export const env = {
...parsed,
isProd,
/**
* Токены для проверки initData. Без настоящих токенов в разработке используются DEV_*,
* чтобы Mini App можно было открыть в браузере (pnpm dev:initdata).
*/
clientBotToken: parsed.CLIENT_BOT_TOKEN || (isProd ? '' : parsed.DEV_CLIENT_BOT_TOKEN),
proBotToken: parsed.PRO_BOT_TOKEN || (isProd ? '' : parsed.DEV_PRO_BOT_TOKEN),
corsOrigins: [parsed.LANDING_URL, parsed.CLIENT_MINIAPP_URL, parsed.PRO_MINIAPP_URL, parsed.ADMIN_URL],
};
+53
View File
@@ -0,0 +1,53 @@
import { randomUUID } from 'node:crypto';
import { CreateBucketCommand, GetObjectCommand, HeadBucketCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getDb } from '@barev/db';
import type { Ctx } from '@barev/services';
import { createQueueNotifier } from '@barev/services/queue';
import { env } from './env.ts';
export const db = getDb();
// ───── события → воркер ─────
export const notifier = createQueueNotifier(env.REDIS_URL);
export const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
// ───── приватное объектное хранилище ─────
export const s3 = new S3Client({
endpoint: env.S3_ENDPOINT,
region: env.S3_REGION,
forcePathStyle: true,
credentials: { accessKeyId: env.S3_ACCESS_KEY, secretAccessKey: env.S3_SECRET_KEY },
});
export async function ensureBucket() {
try {
await s3.send(new HeadBucketCommand({ Bucket: env.S3_BUCKET }));
} catch {
await s3.send(new CreateBucketCommand({ Bucket: env.S3_BUCKET }));
}
}
/** Файлы шифруются на стороне хранилища (SSE); бакет приватный, доступ — только по временной ссылке */
export async function putObject(prefix: string, body: Buffer, mime: string) {
const ext = mime === 'image/png' ? 'png' : mime === 'image/webp' ? 'webp' : mime === 'application/pdf' ? 'pdf' : 'jpg';
const key = `${prefix}/${randomUUID()}.${ext}`;
await s3.send(
new PutObjectCommand({
Bucket: env.S3_BUCKET,
Key: key,
Body: body,
ContentType: mime,
...(env.isProd ? { ServerSideEncryption: 'AES256' as const } : {}),
}),
);
return key;
}
/** Временная ссылка на 5 минут */
export function signedUrl(key: string) {
return getSignedUrl(s3, new GetObjectCommand({ Bucket: env.S3_BUCKET, Key: key }), { expiresIn: 300 });
}
+16
View File
@@ -0,0 +1,16 @@
import { adminRouter } from './routers/admin.ts';
import { backofficeRouter } from './routers/backoffice/index.ts';
import { clientRouter } from './routers/client.ts';
import { proRouter } from './routers/pro.ts';
import { publicRouter } from './routers/public.ts';
import { router } from './trpc.ts';
export const appRouter = router({
public: publicRouter,
client: clientRouter,
pro: proRouter,
admin: adminRouter,
bo: backofficeRouter,
});
export type AppRouter = typeof appRouter;
+572
View File
@@ -0,0 +1,572 @@
import { randomBytes } from 'node:crypto';
import { hash, verify } from '@node-rs/argon2';
import {
getFlow,
LOCALES,
normalizeAgencyCode,
ORDER_STATUSES,
paramsSchemaSchema,
payoutRulesSchema,
pricingSchema,
PROVIDER_ROLES,
SETTING_KEYS,
settingsSchema,
slotConfigSchema,
startOfYerevanDay,
type ServiceFlow,
} from '@barev/core';
import {
adminOrder,
assignmentCandidates,
getSettings,
kanban,
listLeads,
conversation,
grantSubscription,
listClientRequests,
listConversations,
replyTo,
addLeadDoc,
addLeadStep,
listRelocationItems,
moveRelocationItem,
peerInfo,
relocationNames,
removeLeadDoc,
removeLeadStep,
saveRelocationItem,
updateLeadDoc,
createOwnerInvite,
savePartnerOffer,
unreadConciergeCount,
updateClientRequest,
updateLeadStep,
listWebRequests,
markPaid,
offerOrder,
summary,
transitionOrder,
unassignOrder,
updateLeadByAdmin,
updateWebRequest,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { ADMIN_COOKIE, cookieOptions, createSession, destroySession, loginThrottled, markVerified, publicAdmin } from '../auth/admin.ts';
import { generateTotpSecret, otpauthUri, verifyTotp } from '../auth/totp.ts';
import { env } from '../env.ts';
import { signedUrl } from '../infra.ts';
import { adminPreAuthProcedure, adminProcedure, ownerProcedure, publicProcedure, router } from '../trpc.ts';
const invite = (prefix: string) => `${prefix}-${randomBytes(5).toString('hex').toUpperCase()}`;
const weekSchedule = z.array(z.object({ weekday: z.number().int().min(0).max(6), startMinute: z.number().int().min(0).max(1440), endMinute: z.number().int().min(0).max(1440) }));
const localized = z.object({ ru: z.string().min(1), en: z.string(), zh: z.string(), hy: z.string(), es: z.string() });
const authRouter = router({
/** Шаг 1: email + пароль. Сессия создаётся «непроверенной» до прохождения 2FA. */
login: publicProcedure.input(z.object({ email: z.string().trim().min(3).max(120), password: z.string().min(1) })).mutation(async ({ ctx, input }) => {
if (loginThrottled(`${input.email}|${ctx.req.ip}`)) throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'LOGIN_THROTTLED' });
const admin = await ctx.db.adminUser.findUnique({ where: { email: input.email.toLowerCase() } });
if (!admin || !admin.active || !admin.passwordHash || !(await verify(admin.passwordHash, input.password))) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'BAD_CREDENTIALS' });
}
const token = await createSession(ctx.db, admin.id);
ctx.res.setCookie(ADMIN_COOKIE, token, cookieOptions);
return { next: admin.totpEnabled ? ('totp' as const) : ('totp_setup' as const) };
}),
/** 2FA обязательна: при первом входе — выдаём секрет для приложения-аутентификатора */
totpSetup: adminPreAuthProcedure.mutation(async ({ ctx }) => {
if (ctx.admin.totpEnabled) throw new TRPCError({ code: 'BAD_REQUEST', message: 'TOTP_ALREADY_ENABLED' });
const secret = generateTotpSecret();
await ctx.db.adminUser.update({ where: { id: ctx.admin.id }, data: { totpSecret: secret } });
return { secret, uri: otpauthUri(secret, ctx.admin.email ?? ctx.admin.name) };
}),
/** Шаг 2: код из приложения. При первой настройке — включает 2FA. */
totpVerify: adminPreAuthProcedure.input(z.object({ code: z.string().regex(/^\d{6}$/) })).mutation(async ({ ctx, input }) => {
const admin = await ctx.db.adminUser.findUniqueOrThrow({ where: { id: ctx.admin.id } });
if (!admin.totpSecret || !verifyTotp(admin.totpSecret, input.code)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'BAD_TOTP' });
if (!admin.totpEnabled) await ctx.db.adminUser.update({ where: { id: admin.id }, data: { totpEnabled: true } });
await markVerified(ctx.db, ctx.token);
return publicAdmin(admin);
}),
me: adminPreAuthProcedure.query(({ ctx }) => ({ ...publicAdmin(ctx.admin), verified: ctx.session.verified })),
/** Уведомления в Telegram через рабочий бот: статус привязки */
telegram: adminProcedure.query(async ({ ctx }) => {
const a = await ctx.db.adminUser.findUniqueOrThrow({ where: { id: ctx.admin.id } });
return { linked: !!a.telegramId, notify: a.tgNotify, bot: env.PRO_BOT_USERNAME };
}),
/** Одноразовая ссылка t.me/<рабочий бот>?start=adm-<код>: открыл — Telegram привязан к этому администратору */
telegramLink: adminProcedure.mutation(async ({ ctx }) => {
const token = randomBytes(9).toString('base64url').replace(/[-_]/g, 'x');
await ctx.db.adminUser.update({ where: { id: ctx.admin.id }, data: { tgLinkToken: token } });
return { url: `https://t.me/${env.PRO_BOT_USERNAME}?start=adm-${token}` };
}),
telegramSettings: adminProcedure.input(z.object({ notify: z.boolean().optional(), unlink: z.boolean().optional() })).mutation(async ({ ctx, input }) => {
await ctx.db.adminUser.update({
where: { id: ctx.admin.id },
data: { tgNotify: input.notify, ...(input.unlink ? { telegramId: null, tgLinkToken: null } : {}) },
});
}),
logout: publicProcedure.mutation(async ({ ctx }) => {
await destroySession(ctx.db, ctx.req.cookies[ADMIN_COOKIE]);
ctx.res.clearCookie(ADMIN_COOKIE, { path: '/' });
}),
});
const ordersRouter = router({
/** ADM-1 */
kanban: adminProcedure
.input(
z.object({
category: z.string().optional(),
serviceCode: z.string().optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
search: z.string().max(100).optional(),
includeCancelled: z.boolean().optional(),
}),
)
.query(({ ctx, input }) => {
let dateFrom: Date | undefined;
let dateTo: Date | undefined;
if (input.date) {
dateFrom = startOfYerevanDay(new Date(`${input.date}T12:00:00+04:00`));
dateTo = new Date(dateFrom.getTime() + 24 * 3600_000);
}
return kanban(ctx.svc, { ...input, dateFrom, dateTo });
}),
/** ADM-2 */
summary: adminProcedure.query(({ ctx }) => summary(ctx.svc)),
/** ADM-4 */
get: adminProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const o = await adminOrder(ctx.db, input.id);
const flow = getFlow(o.service.flow as ServiceFlow);
const allowed = [...new Set(flow.transitions.filter((t) => t.from === o.status && t.by.includes('ADMIN')).map((t) => t.to))];
return {
...o,
client: { ...o.client, user: { ...o.client.user, telegramId: o.client.user.telegramId.toString() } },
adminTransitions: allowed,
flowSteps: flow.steps,
canCancel: o.status !== 'CANCELLED' && !flow.done.includes(o.status),
};
}),
/** ADM-3 */
candidates: adminProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => assignmentCandidates(ctx.svc, input.id)),
assign: adminProcedure
.input(z.object({ orderId: z.string(), target: z.object({ kind: z.enum(['provider', 'company']), id: z.string() }) }))
.mutation(({ ctx, input }) => offerOrder(ctx.svc, { orderId: input.orderId, adminId: ctx.admin.id, target: input.target })),
unassign: adminProcedure.input(z.object({ orderId: z.string() })).mutation(({ ctx, input }) => unassignOrder(ctx.svc, { orderId: input.orderId, adminId: ctx.admin.id })),
/** Ручная смена статуса — с теми же проверками state machine */
transition: adminProcedure
.input(z.object({ orderId: z.string(), to: z.enum(ORDER_STATUSES), comment: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
await transitionOrder(ctx.svc, { orderId: input.orderId, to: input.to, actor: 'ADMIN', actorId: ctx.admin.id, comment: input.comment });
}),
/** Отмена оператором; удержание задаётся вручную (по умолчанию 0) */
cancel: adminProcedure
.input(z.object({ orderId: z.string(), reason: z.string().max(500).optional(), fee: z.number().int().min(0).default(0) }))
.mutation(async ({ ctx, input }) => {
await transitionOrder(ctx.svc, {
orderId: input.orderId,
to: 'CANCELLED',
actor: 'ADMIN',
actorId: ctx.admin.id,
comment: input.reason,
data: { cancelFee: input.fee, cancelReason: input.reason },
});
await ctx.db.orderOffer.updateMany({ where: { orderId: input.orderId, status: 'OFFERED' }, data: { status: 'REVOKED' } });
}),
/** PAY-1 */
markPaid: adminProcedure
.input(z.object({ orderId: z.string(), paymentMethodId: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
await markPaid(ctx.svc, { orderId: input.orderId, by: { type: 'ADMIN', id: ctx.admin.id }, paymentMethodId: input.paymentMethodId });
}),
fileUrl: adminProcedure.input(z.object({ fileId: z.string() })).query(async ({ ctx, input }) => {
const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId } });
if (!file || file.deletedAt) throw new TRPCError({ code: 'NOT_FOUND' });
await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'ADMIN', actorId: ctx.admin.id } });
return { url: await signedUrl(file.storageKey) };
}),
});
const servicesRouter = router({
list: adminProcedure.query(({ ctx }) =>
ctx.db.category.findMany({
orderBy: { sortOrder: 'asc' },
include: { services: { orderBy: { sortOrder: 'asc' }, include: { checklist: { orderBy: { sortOrder: 'asc' } } } } },
}),
),
update: adminProcedure
.input(
z.object({
id: z.string(),
active: z.boolean().optional(),
lateCancelFeePct: z.number().int().min(0).max(100).optional(),
pricing: pricingSchema.optional(),
paramsSchema: paramsSchemaSchema.optional(),
slotConfig: slotConfigSchema.optional(),
}),
)
.mutation(({ ctx, input }) => {
const { id, ...data } = input;
return ctx.db.service.update({ where: { id }, data });
}),
updateCategory: ownerProcedure
.input(z.object({ id: z.string(), barevPlusDiscountPct: z.number().int().min(0).max(100).optional(), eliteDiscountPct: z.number().int().min(0).max(100).optional() }))
.mutation(({ ctx, input }) => {
const { id, ...data } = input;
return ctx.db.category.update({ where: { id }, data });
}),
});
const providerInput = z.object({
name: z.string().min(1).max(100),
phone: z.string().max(30).optional().nullable(),
role: z.enum(PROVIDER_ROLES),
companyId: z.string().optional().nullable(),
languages: z.array(z.enum(LOCALES)).min(1),
districtIds: z.array(z.string()),
schedule: weekSchedule,
rateType: z.enum(['FIXED', 'PERCENT']),
rateValue: z.number().int().min(0),
/** пресет ставок; null — индивидуальная ставка */
payoutPresetId: z.string().optional().nullable(),
active: z.boolean().default(true),
});
/** Пресеты ставок (джун / мидл / прайват): фикс или % по каждой строке цены */
const presetsRouter = router({
list: adminProcedure.query(async ({ ctx }) => {
const presets = await ctx.db.payoutPreset.findMany({ orderBy: [{ role: 'asc' }, { sortOrder: 'asc' }], include: { _count: { select: { providers: true } } } });
return presets.map((p) => ({ ...p, rules: payoutRulesSchema.parse(p.rules), providers: p._count.providers }));
}),
save: ownerProcedure
.input(z.object({ id: z.string().optional(), name: z.string().min(1).max(60), role: z.enum(PROVIDER_ROLES), rules: payoutRulesSchema, active: z.boolean(), sortOrder: z.number().int().default(0) }))
.mutation(({ ctx, input }) => {
const { id, ...data } = input;
return id ? ctx.db.payoutPreset.update({ where: { id }, data }) : ctx.db.payoutPreset.create({ data });
}),
});
const providersRouter = router({
list: adminProcedure.query(({ ctx }) =>
ctx.db.provider.findMany({
orderBy: [{ active: 'desc' }, { name: 'asc' }],
include: { company: { select: { id: true, name: true } }, payoutPreset: { select: { id: true, name: true } }, districts: { include: { district: true } }, schedule: true },
}),
),
create: adminProcedure.input(providerInput).mutation(({ ctx, input }) => {
const { districtIds, schedule, ...data } = input;
return ctx.db.provider.create({
data: {
...data,
inviteCode: invite('PRO'),
districts: { create: districtIds.map((districtId) => ({ districtId })) },
schedule: { create: schedule },
},
});
}),
update: adminProcedure.input(providerInput.extend({ id: z.string() })).mutation(({ ctx, input }) => {
const { id, districtIds, schedule, ...data } = input;
return ctx.db.provider.update({
where: { id },
data: {
...data,
districts: { deleteMany: {}, create: districtIds.map((districtId) => ({ districtId })) },
schedule: { deleteMany: {}, create: schedule },
},
});
}),
/** Новый инвайт — старая привязка к Telegram снимается */
resetInvite: adminProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) =>
ctx.db.provider.update({ where: { id: input.id }, data: { inviteCode: invite('PRO'), userId: null } }),
),
});
const companyInput = z.object({
name: z.string().min(1).max(100),
roles: z.array(z.enum(PROVIDER_ROLES)).min(1),
rateType: z.enum(['FIXED', 'PERCENT']),
rateValue: z.number().int().min(0),
contactPhone: z.string().max(30).optional().nullable(),
active: z.boolean().default(true),
});
const companiesRouter = router({
list: adminProcedure.query(({ ctx }) =>
ctx.db.providerCompany.findMany({
orderBy: { name: 'asc' },
include: { employees: { select: { id: true, name: true, active: true } }, dispatchers: { include: { user: { select: { firstName: true, username: true } } } } },
}),
),
create: adminProcedure.input(companyInput).mutation(({ ctx, input }) => ctx.db.providerCompany.create({ data: { ...input, inviteCode: invite('CO') } })),
update: adminProcedure.input(companyInput.extend({ id: z.string() })).mutation(({ ctx, input }) => {
const { id, ...data } = input;
return ctx.db.providerCompany.update({ where: { id }, data });
}),
resetInvite: adminProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) =>
ctx.db.providerCompany.update({ where: { id: input.id }, data: { inviteCode: invite('CO') } }),
),
removeDispatcher: adminProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => ctx.db.companyDispatcher.delete({ where: { id: input.id } })),
});
const agencyInput = z.object({
code: z.string().min(2).max(32),
name: z.string().min(1).max(100),
commissionPct: z.number().int().min(0).max(100),
contact: z.string().max(200).optional().nullable(),
active: z.boolean().default(true),
});
const agenciesRouter = router({
list: adminProcedure.query(async ({ ctx }) => {
const agencies = await ctx.db.agency.findMany({ orderBy: { name: 'asc' }, include: { _count: { select: { clients: true } } } });
return agencies.map((a) => ({ ...a, leads: a._count.clients }));
}),
create: ownerProcedure.input(agencyInput).mutation(({ ctx, input }) => {
const code = normalizeAgencyCode(input.code);
if (!code) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_FORMAT' });
return ctx.db.agency.create({ data: { ...input, code } });
}),
update: ownerProcedure.input(agencyInput.extend({ id: z.string() })).mutation(({ ctx, input }) => {
const { id, ...data } = input;
const code = normalizeAgencyCode(data.code);
if (!code) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_FORMAT' });
return ctx.db.agency.update({ where: { id }, data: { ...data, code } });
}),
});
const clientsRouter = router({
/** ADM-6 (базовый): список, источник, статус Barev+ */
list: adminProcedure.input(z.object({ search: z.string().max(100).optional() })).query(async ({ ctx, input }) => {
const q = input.search?.trim();
const now = ctx.svc.now();
const clients = await ctx.db.client.findMany({
where: q ? { user: { OR: [{ firstName: { contains: q, mode: 'insensitive' } }, { username: { contains: q, mode: 'insensitive' } }, { phone: { contains: q } }] } } : {},
include: {
user: { select: { firstName: true, lastName: true, username: true, locale: true } },
agency: { select: { name: true, code: true } },
subscriptions: { where: { status: 'ACTIVE', startsAt: { lte: now }, endsAt: { gt: now } }, orderBy: { plan: 'desc' }, take: 1 },
_count: { select: { orders: true } },
},
orderBy: { createdAt: 'desc' },
take: 200,
});
return clients.map((c) => ({
id: c.id,
user: c.user,
agency: c.agency,
source: c.source,
utm: c.utm,
consented: !!c.consentAt,
barevPlusUntil: c.subscriptions[0]?.endsAt ?? null,
plan: c.subscriptions[0]?.plan ?? null,
segment: c.segment,
orders: c._count.orders,
createdAt: c.createdAt,
}));
}),
/** Выдать Barev+ или Elite вручную (онлайн-оплаты пока нет) */
grantBarevPlus: ownerProcedure
.input(z.object({ clientId: z.string(), days: z.number().int().min(1).max(366), plan: z.enum(['BAREV_PLUS', 'ELITE']).default('BAREV_PLUS') }))
.mutation(({ ctx, input }) => grantSubscription(ctx.svc, input)),
});
const dictionariesRouter = router({
/** ссылки для инвайтов и агентств */
config: adminProcedure.query(() => ({ clientBot: env.CLIENT_BOT_USERNAME, proBot: env.PRO_BOT_USERNAME, landingUrl: env.LANDING_URL })),
districts: adminProcedure.query(({ ctx }) => ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' } })),
paymentMethods: adminProcedure.query(({ ctx }) => ctx.db.paymentMethod.findMany({ orderBy: { sortOrder: 'asc' } })),
savePaymentMethod: ownerProcedure
.input(z.object({ id: z.string().optional(), code: z.string().regex(/^[a-z0-9_]+$/), name: localized, active: z.boolean(), sortOrder: z.number().int() }))
.mutation(({ ctx, input }) => {
const { id, ...data } = input;
return id ? ctx.db.paymentMethod.update({ where: { id }, data }) : ctx.db.paymentMethod.create({ data });
}),
});
const settingsRouter = router({
get: adminProcedure.query(({ ctx }) => getSettings(ctx.db)),
update: ownerProcedure.input(settingsSchema.partial()).mutation(async ({ ctx, input }) => {
for (const key of SETTING_KEYS) {
if (input[key] === undefined) continue;
await ctx.db.setting.upsert({ where: { key }, create: { key, value: input[key] }, update: { value: input[key] } });
}
return getSettings(ctx.db);
}),
});
const adminsRouter = router({
list: ownerProcedure.query(async ({ ctx }) => (await ctx.db.adminUser.findMany({ orderBy: { createdAt: 'asc' } })).map((a) => ({ ...publicAdmin(a), active: a.active }))),
create: ownerProcedure
.input(z.object({ email: z.string().trim().min(3).max(120), name: z.string().min(1), role: z.enum(['OWNER', 'OPERATOR']), password: z.string().min(10) }))
.mutation(async ({ ctx, input }) =>
publicAdmin(
await ctx.db.adminUser.create({ data: { email: input.email.toLowerCase(), name: input.name, role: input.role, roles: input.role === 'OWNER' ? ['SUPERADMIN'] : ['SUPPORT', 'CONCIERGE'], passwordHash: await hash(input.password) } }),
),
),
setActive: ownerProcedure.input(z.object({ id: z.string(), active: z.boolean() })).mutation(async ({ ctx, input }) => {
if (input.id === ctx.admin.id) throw new TRPCError({ code: 'BAD_REQUEST', message: 'CANNOT_DISABLE_SELF' });
await ctx.db.adminUser.update({ where: { id: input.id }, data: { active: input.active } });
if (!input.active) await ctx.db.adminSession.deleteMany({ where: { adminUserId: input.id } });
}),
});
/** Заявки с сайта (barevclub.com/clean) */
const webRequestsRouter = router({
list: adminProcedure
.input(z.object({ status: z.enum(['NEW', 'LINKED', 'ORDERED', 'CANCELLED']).optional() }))
.query(({ ctx, input }) => listWebRequests(ctx.svc, input)),
/** счётчик для бейджа в меню: заявки, по которым ещё нет заказа */
openCount: adminProcedure.query(({ ctx }) => ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } })),
update: adminProcedure
.input(z.object({ id: z.string(), status: z.enum(['CANCELLED', 'REOPEN']).optional(), adminNote: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => updateWebRequest(ctx.svc, input)),
});
/** Заявки из квиза на главной («Переезжаю в Армению») */
const LEAD_STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const;
const leadsRouter = router({
list: adminProcedure.input(z.object({ status: z.enum(LEAD_STATUSES).optional() })).query(({ ctx, input }) => listLeads(ctx.svc, input)),
openCount: adminProcedure.query(({ ctx }) => ctx.db.lead.count({ where: { status: 'NEW' } })),
update: adminProcedure
.input(z.object({ id: z.string(), status: z.enum(LEAD_STATUSES).optional(), adminNote: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => updateLeadByAdmin(ctx.svc, input)),
/** шаг плана переезда: статус, комментарий для клиента, своё название */
updateStep: adminProcedure
.input(z.object({ stepId: z.string(), status: z.enum(['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED']).optional(), note: z.string().max(500).optional(), title: z.string().max(120).optional() }))
.mutation(({ ctx, input }) => updateLeadStep(ctx.svc, input)),
addStep: adminProcedure
.input(z.object({ leadId: z.string(), code: z.string().max(40).optional(), title: z.string().max(120).optional() }))
.mutation(({ ctx, input }) => addLeadStep(ctx.svc, input)),
removeStep: adminProcedure.input(z.object({ stepId: z.string() })).mutation(({ ctx, input }) => removeLeadStep(ctx.svc, input.stepId)),
/** документы клиента */
addDoc: adminProcedure
.input(z.object({ leadId: z.string(), code: z.string().max(40).optional(), title: z.string().max(120).optional() }))
.mutation(({ ctx, input }) => addLeadDoc(ctx.svc, input)),
updateDoc: adminProcedure
.input(z.object({ docId: z.string(), title: z.string().max(120).optional(), checked: z.boolean().optional() }))
.mutation(({ ctx, input }) => updateLeadDoc(ctx.svc, input)),
removeDoc: adminProcedure.input(z.object({ docId: z.string() })).mutation(({ ctx, input }) => removeLeadDoc(ctx.svc, input.docId)),
/** общий список шагов и документов */
names: adminProcedure.query(({ ctx }) => relocationNames(ctx.db)),
templates: adminProcedure.query(({ ctx }) => listRelocationItems(ctx.svc)),
saveTemplate: adminProcedure
.input(
z.object({
id: z.string().optional(),
kind: z.enum(['STEP', 'DOC']),
name: localized,
hint: z.object({ ru: z.string(), en: z.string(), zh: z.string(), hy: z.string(), es: z.string() }).optional().nullable(),
active: z.boolean(),
inTurnkey: z.boolean().optional(),
}),
)
.mutation(({ ctx, input }) => saveRelocationItem(ctx.svc, input)),
moveTemplate: adminProcedure.input(z.object({ id: z.string(), dir: z.union([z.literal(-1), z.literal(1)]) })).mutation(({ ctx, input }) => moveRelocationItem(ctx.svc, input)),
});
/** Заявки из приложения: подписка или «перезвоните» по услуге */
const requestsRouter = router({
list: adminProcedure.input(z.object({ status: z.enum(['NEW', 'DONE', 'CANCELLED']).optional() })).query(({ ctx, input }) => listClientRequests(ctx.svc, input)),
openCount: adminProcedure.query(({ ctx }) => ctx.db.clientRequest.count({ where: { status: 'NEW' } })),
update: adminProcedure
.input(z.object({ id: z.string(), status: z.enum(['NEW', 'DONE', 'CANCELLED']).optional(), adminNote: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => updateClientRequest(ctx.svc, input)),
/** включить подписку по заявке: создаёт подписку и закрывает заявку */
activate: adminProcedure
.input(z.object({ id: z.string(), days: z.number().int().min(1).max(366) }))
.mutation(async ({ ctx, input }) => {
const r = await ctx.db.clientRequest.findUniqueOrThrow({ where: { id: input.id } });
if (r.kind !== 'SUBSCRIPTION' || !r.plan) throw new TRPCError({ code: 'BAD_REQUEST' });
return grantSubscription(ctx.svc, { clientId: r.clientId, plan: r.plan, days: input.days, requestId: r.id });
}),
});
/** Переписка: клиенты пишут клиентскому боту, исполнители — рабочему; оператор отвечает отсюда */
const peerInput = z.union([z.object({ clientId: z.string() }), z.object({ providerId: z.string() })]);
const conciergeRouter = router({
list: adminProcedure.query(({ ctx }) => listConversations(ctx.svc)),
unreadCount: adminProcedure.query(({ ctx }) => unreadConciergeCount(ctx.svc)),
get: adminProcedure.input(peerInput).query(async ({ ctx, input }) => ({ peer: await peerInfo(ctx.svc, input), messages: await conversation(ctx.svc, input) })),
reply: adminProcedure
.input(z.intersection(peerInput, z.object({ text: z.string().min(1).max(4000) })))
.mutation(({ ctx, input }) => replyTo(ctx.svc, { ...input, adminUserId: ctx.admin.id })),
});
/** Партнёры клуба: скидки и подарки по клубной карте */
const partnersRouter = router({
list: adminProcedure.query(({ ctx }) =>
ctx.db.partner.findMany({
orderBy: [{ active: 'desc' }, { name: 'asc' }],
include: {
offers: { orderBy: { sortOrder: 'asc' } },
members: { include: { user: { select: { firstName: true, lastName: true, username: true } } } },
_count: { select: { usages: { where: { offerId: null } } } },
},
}),
),
/** одноразовая ссылка для собственника: открыл рабочий бот — стал собственником партнёра */
ownerInvite: adminProcedure.input(z.object({ partnerId: z.string() })).mutation(({ ctx, input }) => createOwnerInvite(ctx.svc, input.partnerId)),
saveOffer: adminProcedure
.input(
z.object({
id: z.string().optional(),
partnerId: z.string(),
title: localized,
minLevel: z.enum(['BAREV_PLUS', 'ELITE']).nullable(),
limit: z.enum(['NONE', 'ONCE', 'WEEK', 'MONTH']),
limitCount: z.number().int().min(1).max(100),
active: z.boolean(),
}),
)
.mutation(({ ctx, input }) => savePartnerOffer(ctx.svc, input)),
removeMember: adminProcedure.input(z.object({ memberId: z.string() })).mutation(({ ctx, input }) => ctx.db.partnerMember.delete({ where: { id: input.memberId } })),
save: adminProcedure
.input(
z.object({
id: z.string().optional(),
category: z.enum(['cafe', 'restaurant', 'shop', 'beauty', 'other']),
name: z.string().min(1).max(100),
offer: localized,
address: z.string().max(200).optional().nullable(),
active: z.boolean(),
}),
)
.mutation(({ ctx, input }) => {
const { id, ...data } = input;
return id ? ctx.db.partner.update({ where: { id }, data }) : ctx.db.partner.create({ data });
}),
});
export const adminRouter = router({
webRequests: webRequestsRouter,
leads: leadsRouter,
requests: requestsRouter,
concierge: conciergeRouter,
partners: partnersRouter,
auth: authRouter,
orders: ordersRouter,
services: servicesRouter,
providers: providersRouter,
presets: presetsRouter,
companies: companiesRouter,
agencies: agenciesRouter,
clients: clientsRouter,
dictionaries: dictionariesRouter,
settings: settingsRouter,
admins: adminsRouter,
});
+103
View File
@@ -0,0 +1,103 @@
import { randomBytes } from 'node:crypto';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { ADMIN_COOKIE, cookieOptions, createSession, destroySession, markVerified } from '../../auth/admin.ts';
import { env } from '../../env.ts';
import { adminPreAuthProcedure, publicProcedure, router, staffProcedure, superProcedure } from '../../trpc.ts';
const LOGIN_TTL_MS = 5 * 60_000;
const code = () => randomBytes(9).toString('base64url').replace(/[-_]/g, 'x');
export const STAFF_ROLES = ['SUPERADMIN', 'SUPPORT', 'CONCIERGE', 'SALES'] as const;
function staffView(a: { id: string; name: string; roles: string[]; telegramId: bigint | null; active: boolean; createdAt: Date; tgLinkToken?: string | null }) {
return { id: a.id, name: a.name, roles: a.roles, telegramLinked: !!a.telegramId, active: a.active, createdAt: a.createdAt };
}
async function openSession(ctx: { db: Parameters<typeof createSession>[0]; res: { setCookie: (n: string, v: string, o: typeof cookieOptions) => unknown } }, adminUserId: string) {
const token = await createSession(ctx.db, adminUserId);
await markVerified(ctx.db, token);
ctx.res.setCookie(ADMIN_COOKIE, token, cookieOptions);
}
export const boAuthRouter = router({
/** Шаг 1: страница получает ссылку в рабочий бот */
start: publicProcedure.mutation(async ({ ctx }) => {
const req = await ctx.db.adminLoginRequest.create({
data: { code: code(), pollKey: randomBytes(16).toString('hex'), expiresAt: new Date(Date.now() + LOGIN_TTL_MS) },
});
return { code: req.code, pollKey: req.pollKey, url: `https://t.me/${env.PRO_BOT_USERNAME}?start=lg-${req.code}`, expiresAt: req.expiresAt, devLogin: !env.isProd };
}),
/** Шаг 2: страница опрашивает, подтвердил ли бот вход */
poll: publicProcedure.input(z.object({ code: z.string().max(40), pollKey: z.string().max(64) })).mutation(async ({ ctx, input }) => {
const req = await ctx.db.adminLoginRequest.findUnique({ where: { code: input.code } });
if (!req || req.pollKey !== input.pollKey) throw new TRPCError({ code: 'NOT_FOUND' });
if (req.consumedAt) return { status: 'expired' as const };
if (!req.approvedAt || !req.adminUserId) return { status: req.expiresAt < new Date() ? ('expired' as const) : ('pending' as const) };
await ctx.db.adminLoginRequest.update({ where: { id: req.id }, data: { consumedAt: new Date() } });
await openSession(ctx, req.adminUserId);
return { status: 'ok' as const };
}),
/** Только локально: войти любым сотрудником без Telegram */
devUsers: publicProcedure.query(async ({ ctx }) => {
if (env.isProd) return [];
const list = await ctx.db.adminUser.findMany({ where: { active: true, NOT: { roles: { isEmpty: true } } }, orderBy: { createdAt: 'asc' } });
return list.map((a) => ({ id: a.id, name: a.name, roles: a.roles }));
}),
devLogin: publicProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
if (env.isProd) throw new TRPCError({ code: 'FORBIDDEN' });
await openSession(ctx, input.id);
}),
me: adminPreAuthProcedure.query(({ ctx }) => ({
id: ctx.admin.id,
name: ctx.admin.name,
roles: ctx.admin.roles,
verified: ctx.session.verified,
telegramLinked: !!ctx.admin.telegramId,
})),
logout: publicProcedure.mutation(async ({ ctx }) => {
await destroySession(ctx.db, ctx.req.cookies[ADMIN_COOKIE]);
ctx.res.clearCookie(ADMIN_COOKIE, { path: '/' });
}),
/** Привязать свой Telegram (нужно для входа и уведомлений) */
telegramLink: staffProcedure.mutation(async ({ ctx }) => {
const token = code();
await ctx.db.adminUser.update({ where: { id: ctx.admin.id }, data: { tgLinkToken: token } });
return { url: `https://t.me/${env.PRO_BOT_USERNAME}?start=adm-${token}` };
}),
});
/** Команда: суперадмин добавляет людей и раздаёт роли */
export const boTeamRouter = router({
list: superProcedure.query(async ({ ctx }) => (await ctx.db.adminUser.findMany({ orderBy: { createdAt: 'asc' } })).filter((a) => a.roles.length > 0 || a.active).map(staffView)),
/** Новый сотрудник: имя и роли; в ответе ссылка, по которой он привяжет свой Telegram */
create: superProcedure.input(z.object({ name: z.string().trim().min(1).max(80), roles: z.array(z.enum(STAFF_ROLES)).min(1) })).mutation(async ({ ctx, input }) => {
const token = code();
const a = await ctx.db.adminUser.create({ data: { name: input.name, roles: input.roles, tgLinkToken: token } });
return { ...staffView(a), linkUrl: `https://t.me/${env.PRO_BOT_USERNAME}?start=adm-${token}` };
}),
update: superProcedure
.input(z.object({ id: z.string(), name: z.string().trim().min(1).max(80).optional(), roles: z.array(z.enum(STAFF_ROLES)).optional(), active: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
if (input.id === ctx.admin.id && (input.active === false || (input.roles && !input.roles.includes('SUPERADMIN')))) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CANNOT_DEMOTE_SELF' });
}
const a = await ctx.db.adminUser.update({ where: { id: input.id }, data: { name: input.name, roles: input.roles, active: input.active } });
if (input.active === false) await ctx.db.adminSession.deleteMany({ where: { adminUserId: a.id } });
return staffView(a);
}),
/** Новая ссылка для привязки Telegram (старая перестаёт работать) */
linkUrl: superProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const token = code();
await ctx.db.adminUser.update({ where: { id: input.id }, data: { tgLinkToken: token } });
return { url: `https://t.me/${env.PRO_BOT_USERNAME}?start=adm-${token}` };
}),
});
+364
View File
@@ -0,0 +1,364 @@
import { activeSubscription, DAY } from '@barev/core';
import type { Db, Prisma } from '@barev/db';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, staffProcedure, superProcedure } from '../../trpc.ts';
const PLANS = ['BAREV_PLUS', 'ELITE'] as const;
const PAY_METHODS = ['cash', 'transfer', 'card', 'link'] as const;
/** Фильтры списка клиентов — их же сохраняем как сегмент */
export const clientFilters = z.object({
search: z.string().trim().max(100).optional(),
/** none — без активной подписки, any — с любой активной */
plan: z.enum(['any', 'none', 'BAREV_PLUS', 'ELITE']).optional(),
/** как получена текущая подписка */
subSource: z.enum(['PAID', 'MANUAL', 'AGENCY_GIFT']).optional(),
/** откуда пришёл: organic | deeplink | manual_code */
source: z.string().max(20).optional(),
agencyId: z.string().optional(),
tag: z.string().max(40).optional(),
/** подписка истекает в ближайшие N дней */
expiringDays: z.number().int().min(1).max(90).optional(),
segment: z.enum(['MOVING', 'LIVING']).optional(),
/** не платили ни разу / платили */
paying: z.enum(['yes', 'no']).optional(),
});
export type ClientFilters = z.infer<typeof clientFilters>;
const clientInclude = {
user: true,
agency: { select: { id: true, name: true, code: true } },
subscriptions: { orderBy: { endsAt: 'desc' } },
} satisfies Prisma.ClientInclude;
type ClientRow = Prisma.ClientGetPayload<{ include: typeof clientInclude }>;
/** Деньги по клиентам: оплаченные заказы + подписки − возвраты */
async function revenueByClient(db: Db, clientIds: string[]) {
const [orders, subs] = await Promise.all([
db.order.groupBy({ by: ['clientId'], where: { clientId: { in: clientIds }, paymentStatus: 'PAID' }, _sum: { totalPrice: true }, _count: true }),
db.subscription.groupBy({ by: ['clientId'], where: { clientId: { in: clientIds } }, _sum: { amount: true, refundAmount: true } }),
]);
const out = new Map<string, { orders: number; subs: number; total: number; paidOrders: number }>();
for (const id of clientIds) out.set(id, { orders: 0, subs: 0, total: 0, paidOrders: 0 });
for (const o of orders) {
const r = out.get(o.clientId)!;
r.orders = o._sum.totalPrice ?? 0;
r.paidOrders = o._count;
}
for (const s of subs) {
const r = out.get(s.clientId)!;
r.subs = (s._sum.amount ?? 0) - (s._sum.refundAmount ?? 0);
}
for (const r of out.values()) r.total = r.orders + r.subs;
return out;
}
function displayName(u: { firstName: string | null; lastName: string | null; username: string | null }) {
return [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
}
function utmSource(utm: unknown): string | null {
if (utm && typeof utm === 'object' && 'utm_source' in utm) return String((utm as Record<string, unknown>).utm_source);
if (utm && typeof utm === 'object' && 'source' in utm) return String((utm as Record<string, unknown>).source);
return null;
}
function currentSub(c: ClientRow, now: Date) {
return activeSubscription(c.subscriptions, now);
}
function rowView(c: ClientRow, now: Date, money: { total: number; paidOrders: number } | undefined, showMoney: boolean) {
const sub = currentSub(c, now);
return {
id: c.id,
name: displayName(c.user),
username: c.user.username,
phone: c.user.phone,
locale: c.user.locale,
createdAt: c.createdAt,
segment: c.segment,
tags: c.tags,
source: c.source,
utmSource: utmSource(c.utm),
agency: c.agency,
sub: sub ? { id: sub.id, plan: sub.plan, source: sub.source, endsAt: sub.endsAt } : null,
revenue: showMoney ? (money?.total ?? 0) : null,
paying: (money?.paidOrders ?? 0) > 0 || c.subscriptions.some((s) => s.source === 'PAID'),
};
}
/** where для того, что фильтруется в базе; остальное — после загрузки */
function baseWhere(f: ClientFilters): Prisma.ClientWhereInput {
const and: Prisma.ClientWhereInput[] = [];
if (f.search) {
const s = f.search.replace(/^@/, '');
and.push({
OR: [
{ user: { firstName: { contains: s, mode: 'insensitive' } } },
{ user: { lastName: { contains: s, mode: 'insensitive' } } },
{ user: { username: { contains: s, mode: 'insensitive' } } },
{ user: { phone: { contains: s } } },
{ cardCode: { equals: s.toUpperCase() } },
],
});
}
if (f.source) and.push({ source: f.source });
if (f.agencyId) and.push({ agencyId: f.agencyId });
if (f.tag) and.push({ tags: { has: f.tag } });
if (f.segment) and.push({ segment: f.segment });
return and.length ? { AND: and } : {};
}
async function filteredClients(db: Db, f: ClientFilters, now: Date) {
const rows = await db.client.findMany({ where: baseWhere(f), include: clientInclude, orderBy: { createdAt: 'desc' }, take: 5000 });
const money = await revenueByClient(
db,
rows.map((r) => r.id),
);
const list = rows.filter((c) => {
const sub = currentSub(c, now);
if (f.plan === 'any' && !sub) return false;
if (f.plan === 'none' && sub) return false;
if ((f.plan === 'BAREV_PLUS' || f.plan === 'ELITE') && sub?.plan !== f.plan) return false;
if (f.subSource && sub?.source !== f.subSource) return false;
if (f.expiringDays && !(sub && sub.endsAt.getTime() - now.getTime() <= f.expiringDays * DAY)) return false;
const m = money.get(c.id)!;
const paying = m.paidOrders > 0 || c.subscriptions.some((s) => s.source === 'PAID');
if (f.paying === 'yes' && !paying) return false;
if (f.paying === 'no' && paying) return false;
return true;
});
return { list, money };
}
const csvCell = (v: unknown) => {
const s = v === null || v === undefined ? '' : v instanceof Date ? v.toISOString().slice(0, 10) : String(v);
return /[;"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
/** Новая подписка того же тарифа начинается после окончания текущей */
async function nextStart(db: Db, clientId: string, plan: (typeof PLANS)[number], now: Date) {
const current = await db.subscription.findFirst({
where: { clientId, plan, status: 'ACTIVE', endsAt: { gt: now } },
orderBy: { endsAt: 'desc' },
});
return current?.endsAt ?? now;
}
async function subOf(db: Db, clientId: string, subscriptionId: string) {
const s = await db.subscription.findUnique({ where: { id: subscriptionId } });
if (!s || s.clientId !== clientId) throw new TRPCError({ code: 'NOT_FOUND' });
return s;
}
/** Подписки: подарить/продать/продлить/отменить/сменить тариф — суперадмин и поддержка; возврат — только суперадмин */
const subsProcedure = rolesProcedure('SUPPORT');
export const boClientsRouter = router({
list: staffProcedure
.input(clientFilters.extend({ sort: z.enum(['new', 'revenue', 'ends']).default('new'), page: z.number().int().min(0).default(0) }))
.query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const { list, money } = await filteredClients(ctx.db, input, now);
const showMoney = ctx.isSuper;
let views = list.map((c) => rowView(c, now, money.get(c.id), showMoney));
if (input.sort === 'revenue' && showMoney) views = views.sort((a, b) => (b.revenue ?? 0) - (a.revenue ?? 0));
if (input.sort === 'ends') views = views.sort((a, b) => (a.sub?.endsAt.getTime() ?? Infinity) - (b.sub?.endsAt.getTime() ?? Infinity));
const PAGE = 50;
return {
total: views.length,
totalRevenue: showMoney ? views.reduce((s, v) => s + (v.revenue ?? 0), 0) : null,
items: views.slice(input.page * PAGE, (input.page + 1) * PAGE),
pageSize: PAGE,
};
}),
/** Справочники для фильтров */
facets: staffProcedure.query(async ({ ctx }) => {
const [agencies, tagsRaw, sources] = await Promise.all([
ctx.db.agency.findMany({ select: { id: true, name: true }, orderBy: { name: 'asc' } }),
ctx.db.$queryRaw<{ tag: string; n: bigint }[]>`SELECT unnest(tags) AS tag, count(*) AS n FROM "Client" GROUP BY 1 ORDER BY 2 DESC, 1`,
ctx.db.client.groupBy({ by: ['source'], _count: true }),
]);
return { agencies, tags: tagsRaw.map((t) => ({ tag: t.tag, count: Number(t.n) })), sources: sources.map((s) => s.source) };
}),
/** CSV для Excel: разделитель «;», BOM — чтобы кириллица открылась без танцев */
exportCsv: staffProcedure.input(clientFilters).mutation(async ({ ctx, input }) => {
const now = ctx.svc.now();
const { list, money } = await filteredClients(ctx.db, input, now);
const head = ['Имя', 'Username', 'Телефон', 'Язык', 'Создан', 'Источник', 'Агентство', 'UTM', 'Тариф', 'Как получена', 'До', 'Теги'];
if (ctx.isSuper) head.push('Принёс, AMD');
const lines = list.map((c) => {
const v = rowView(c, now, money.get(c.id), ctx.isSuper);
const cells: unknown[] = [v.name, v.username, v.phone, v.locale, v.createdAt, v.source, v.agency?.name, v.utmSource, v.sub?.plan, v.sub?.source, v.sub?.endsAt, v.tags.join(', ')];
if (ctx.isSuper) cells.push(v.revenue);
return cells.map(csvCell).join(';');
});
return { filename: `barev-clients-${now.toISOString().slice(0, 10)}.csv`, content: '' + [head.join(';'), ...lines].join('\r\n') };
}),
get: staffProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const c = await ctx.db.client.findUnique({
where: { id: input.id },
include: {
...clientInclude,
orders: { orderBy: { createdAt: 'desc' }, take: 100, include: { service: { select: { code: true } }, provider: { select: { name: true } }, rating: { select: { score: true } } } },
_count: { select: { cardUsages: true, leads: true, requests: true, concierge: true } },
},
});
if (!c) throw new TRPCError({ code: 'NOT_FOUND' });
const money = (await revenueByClient(ctx.db, [c.id])).get(c.id)!;
const adminIds = [...new Set(c.subscriptions.map((s) => s.grantedByAdminId).filter((x): x is string => !!x))];
const agencyIds = [...new Set(c.subscriptions.map((s) => s.agencyId).filter((x): x is string => !!x))];
const [admins, agencies, history] = await Promise.all([
ctx.db.adminUser.findMany({ where: { id: { in: adminIds } }, select: { id: true, name: true } }),
ctx.db.agency.findMany({ where: { id: { in: agencyIds } }, select: { id: true, name: true } }),
ctx.db.auditLog.findMany({ where: { entityId: c.id }, orderBy: { createdAt: 'desc' }, take: 50, include: { adminUser: { select: { name: true } } } }),
]);
const adminName = new Map(admins.map((a) => [a.id, a.name]));
const agencyName = new Map(agencies.map((a) => [a.id, a.name]));
const showMoney = ctx.isSuper;
return {
...rowView(c, now, money, showMoney),
cardCode: c.cardCode,
adminNote: c.adminNote,
utm: c.utm,
agencyAttachedAt: c.agencyAttachedAt,
consentAt: c.consentAt,
money: showMoney ? money : null,
counts: c._count,
subscriptions: c.subscriptions.map((s) => ({
id: s.id,
plan: s.plan,
source: s.source,
status: s.status,
startsAt: s.startsAt,
endsAt: s.endsAt,
amount: showMoney ? s.amount : null,
payMethod: s.payMethod,
note: s.note,
grantedBy: s.grantedByAdminId ? (adminName.get(s.grantedByAdminId) ?? '—') : null,
agency: s.agencyId ? (agencyName.get(s.agencyId) ?? null) : s.source === 'AGENCY_GIFT' ? (c.agency?.name ?? null) : null,
cancelledAt: s.cancelledAt,
refundAmount: showMoney ? s.refundAmount : null,
refundedAt: s.refundedAt,
createdAt: s.createdAt,
active: s.status === 'ACTIVE' && s.startsAt <= now && s.endsAt > now,
})),
orders: c.orders.map((o) => ({
id: o.id,
number: o.number,
service: o.service.code,
scheduledAt: o.scheduledAt,
status: o.status,
paymentStatus: o.paymentStatus,
total: showMoney ? o.totalPrice : null,
provider: o.provider?.name ?? null,
rating: o.rating?.score ?? null,
})),
history: history.map((h) => ({ id: h.id, action: h.action, by: h.adminUser?.name ?? null, at: h.createdAt, data: h.data })),
};
}),
setTags: staffProcedure.input(z.object({ id: z.string(), tags: z.array(z.string().trim().min(1).max(40)).max(20) })).mutation(async ({ ctx, input }) => {
await ctx.db.client.update({ where: { id: input.id }, data: { tags: [...new Set(input.tags)] } });
}),
setNote: staffProcedure.input(z.object({ id: z.string(), note: z.string().max(4000) })).mutation(async ({ ctx, input }) => {
await ctx.db.client.update({ where: { id: input.id }, data: { adminNote: input.note || null } });
}),
// ── подписки (id — клиент, чтобы журнал собирался в карточке) ──
gift: subsProcedure
.input(z.object({ id: z.string(), plan: z.enum(PLANS), days: z.number().int().min(1).max(3660), note: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
const now = ctx.svc.now();
const startsAt = await nextStart(ctx.db, input.id, input.plan, now);
const sub = await ctx.db.subscription.create({
data: { clientId: input.id, plan: input.plan, source: 'MANUAL', startsAt, endsAt: new Date(startsAt.getTime() + input.days * DAY), grantedByAdminId: ctx.admin.id, note: input.note },
});
await ctx.svc.notify.emit({ type: 'subscription.activated', subscriptionId: sub.id });
return { subscriptionId: sub.id };
}),
/** Продажа: ручная отметка оплаты. Ссылка на оплату — когда подключим платёжку. */
sell: subsProcedure
.input(
z.object({
id: z.string(),
plan: z.enum(PLANS),
days: z.number().int().min(1).max(3660),
amount: z.number().int().min(0).max(100_000_000),
payMethod: z.enum(PAY_METHODS),
note: z.string().max(500).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
if (input.payMethod === 'link') throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'PAYMENTS_NOT_CONFIGURED' });
const now = ctx.svc.now();
const startsAt = await nextStart(ctx.db, input.id, input.plan, now);
const sub = await ctx.db.subscription.create({
data: {
clientId: input.id,
plan: input.plan,
source: 'PAID',
startsAt,
endsAt: new Date(startsAt.getTime() + input.days * DAY),
amount: input.amount,
payMethod: input.payMethod,
grantedByAdminId: ctx.admin.id,
note: input.note,
},
});
await ctx.svc.notify.emit({ type: 'subscription.activated', subscriptionId: sub.id });
return { subscriptionId: sub.id };
}),
extend: subsProcedure.input(z.object({ id: z.string(), subscriptionId: z.string(), days: z.number().int().min(-3660).max(3660) })).mutation(async ({ ctx, input }) => {
const s = await subOf(ctx.db, input.id, input.subscriptionId);
const endsAt = new Date(s.endsAt.getTime() + input.days * DAY);
if (endsAt <= s.startsAt) throw new TRPCError({ code: 'BAD_REQUEST', message: 'ENDS_BEFORE_START' });
await ctx.db.subscription.update({ where: { id: s.id }, data: { endsAt, status: endsAt > ctx.svc.now() && s.status === 'EXPIRED' ? 'ACTIVE' : s.status } });
}),
cancel: subsProcedure.input(z.object({ id: z.string(), subscriptionId: z.string(), reason: z.string().max(500).optional() })).mutation(async ({ ctx, input }) => {
const s = await subOf(ctx.db, input.id, input.subscriptionId);
await ctx.db.subscription.update({
where: { id: s.id },
data: { status: 'CANCELLED', cancelledAt: ctx.svc.now(), note: input.reason ? [s.note, `Отмена: ${input.reason}`].filter(Boolean).join('\n') : s.note },
});
}),
changePlan: subsProcedure.input(z.object({ id: z.string(), subscriptionId: z.string(), plan: z.enum(PLANS) })).mutation(async ({ ctx, input }) => {
const s = await subOf(ctx.db, input.id, input.subscriptionId);
await ctx.db.subscription.update({ where: { id: s.id }, data: { plan: input.plan } });
}),
refund: superProcedure
.input(z.object({ id: z.string(), subscriptionId: z.string(), amount: z.number().int().min(1), cancel: z.boolean().default(true) }))
.mutation(async ({ ctx, input }) => {
const s = await subOf(ctx.db, input.id, input.subscriptionId);
const refunded = (s.refundAmount ?? 0) + input.amount;
if (s.amount === null || refunded > s.amount) throw new TRPCError({ code: 'BAD_REQUEST', message: 'REFUND_TOO_BIG' });
const now = ctx.svc.now();
await ctx.db.subscription.update({
where: { id: s.id },
data: { refundAmount: refunded, refundedAt: now, ...(input.cancel ? { status: 'CANCELLED', cancelledAt: now } : {}) },
});
}),
});
/** Сохранённые выборки — у каждого сотрудника свои */
export const boSegmentsRouter = router({
list: staffProcedure.query(({ ctx }) => ctx.db.savedSegment.findMany({ where: { adminUserId: ctx.admin.id }, orderBy: { createdAt: 'asc' } })),
save: staffProcedure.input(z.object({ name: z.string().trim().min(1).max(60), filters: clientFilters })).mutation(({ ctx, input }) =>
ctx.db.savedSegment.create({ data: { name: input.name, filters: input.filters, adminUserId: ctx.admin.id } }),
),
remove: staffProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await ctx.db.savedSegment.deleteMany({ where: { id: input.id, adminUserId: ctx.admin.id } });
}),
});
@@ -0,0 +1,157 @@
import { DAY, startOfYerevanDay } from '@barev/core';
import { unreadConciergeCount } from '@barev/services';
import { router, staffProcedure } from '../../trpc.ts';
/** День по Еревану (UTC+4, без перехода на летнее время) */
const dayKey = (d: Date) => new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 10);
function name(u: { firstName: string | null; lastName: string | null; username: string | null } | null | undefined) {
if (!u) return '—';
return [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
}
export const boDashboardRouter = router({
/** Что требует внимания — видят все роли */
attention: staffProcedure.query(async ({ ctx }) => {
const now = ctx.svc.now();
const [unassigned, unreadChats, webRequests, leads, appRequests, lowRatings, expiring] = await Promise.all([
ctx.db.order.count({ where: { providerId: null, companyId: null, status: { in: ['NEW', 'ENROLLED'] }, offers: { none: { status: 'OFFERED' } } } }),
unreadConciergeCount(ctx.svc),
ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } }),
ctx.db.lead.count({ where: { status: 'NEW' } }),
ctx.db.clientRequest.count({ where: { status: 'NEW' } }),
ctx.db.rating.count({ where: { score: { lte: 3 }, createdAt: { gte: new Date(now.getTime() - 7 * DAY) } } }),
ctx.db.subscription.count({ where: { status: 'ACTIVE', endsAt: { gt: now, lte: new Date(now.getTime() + 7 * DAY) } } }),
]);
return { unassigned, unreadChats, webRequests, leads, appRequests, lowRatings, expiring };
}),
/** Полная сводка — деньги и команда видит только суперадмин */
overview: staffProcedure.query(async ({ ctx }) => {
const now = ctx.svc.now();
const today = startOfYerevanDay(now);
const from60 = new Date(today.getTime() - 59 * DAY);
const from30 = new Date(today.getTime() - 29 * DAY);
const [paidOrders, soldSubs, refunds, activeSubs, expiringSubs, newClients, leads30, giftClients, paidSubClients, cardUsages, providers, recentSubs, recentOrders, recentAudit] =
await Promise.all([
ctx.db.order.findMany({ where: { paymentStatus: 'PAID', paidAt: { gte: from60 } }, select: { paidAt: true, totalPrice: true } }),
ctx.db.subscription.findMany({ where: { amount: { not: null }, createdAt: { gte: from60 } }, select: { createdAt: true, amount: true } }),
ctx.db.subscription.findMany({ where: { refundedAt: { gte: from60 } }, select: { refundedAt: true, refundAmount: true } }),
ctx.db.subscription.findMany({ where: { status: 'ACTIVE', startsAt: { lte: now }, endsAt: { gt: now } }, select: { clientId: true, plan: true, source: true } }),
ctx.db.subscription.findMany({
where: { status: 'ACTIVE', endsAt: { gt: now, lte: new Date(now.getTime() + 7 * DAY) } },
orderBy: { endsAt: 'asc' },
take: 6,
include: { client: { include: { user: true } } },
}),
ctx.db.client.findMany({ where: { createdAt: { gte: from60 } }, select: { createdAt: true, source: true, utm: true, agency: { select: { name: true } } } }),
ctx.db.lead.count({ where: { createdAt: { gte: from30 } } }),
ctx.db.subscription.findMany({ where: { source: { in: ['MANUAL', 'AGENCY_GIFT'] } }, select: { clientId: true }, distinct: ['clientId'] }),
ctx.db.subscription.findMany({ where: { source: 'PAID' }, select: { clientId: true }, distinct: ['clientId'] }),
ctx.db.cardUsage.groupBy({ by: ['partnerId'], where: { createdAt: { gte: from30 }, partnerId: { not: null } }, _count: true, orderBy: { _count: { partnerId: 'desc' } }, take: 5 }),
ctx.db.provider.findMany({ where: { active: true, ratingCount: { gt: 0 } }, select: { id: true, name: true, ratingSum: true, ratingCount: true, role: true } }),
ctx.db.subscription.findMany({ orderBy: { createdAt: 'desc' }, take: 8, include: { client: { include: { user: true } } } }),
ctx.db.order.findMany({ where: { paymentStatus: 'PAID', paidAt: { not: null } }, orderBy: { paidAt: 'desc' }, take: 8, include: { client: { include: { user: true } }, service: { select: { code: true } } } }),
ctx.db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 8, include: { adminUser: { select: { name: true } } } }),
]);
// ── деньги по дням ──
const days: string[] = [];
for (let i = 59; i >= 0; i--) days.push(dayKey(new Date(today.getTime() - i * DAY + 12 * 3600_000)));
const byDay = new Map(days.map((d) => [d, { orders: 0, subs: 0 }]));
for (const o of paidOrders) byDay.get(dayKey(o.paidAt!))!.orders += o.totalPrice;
for (const s of soldSubs) {
const b = byDay.get(dayKey(s.createdAt));
if (b) b.subs += s.amount ?? 0;
}
for (const r of refunds) {
const b = byDay.get(dayKey(r.refundedAt!));
if (b) b.subs -= r.refundAmount ?? 0;
}
const series = days.map((d) => ({ day: d, ...byDay.get(d)! }));
const sum = (from: number, to: number) => series.slice(from, to).reduce((a, x) => ({ orders: a.orders + x.orders, subs: a.subs + x.subs }), { orders: 0, subs: 0 });
const money = {
today: sum(59, 60),
yesterday: sum(58, 59),
week: sum(53, 60),
prevWeek: sum(46, 53),
month: sum(30, 60),
prevMonth: sum(0, 30),
series: series.slice(30),
};
// ── подписки ──
const plus = new Set(activeSubs.filter((s) => s.plan === 'BAREV_PLUS').map((s) => s.clientId));
const elite = new Set(activeSubs.filter((s) => s.plan === 'ELITE').map((s) => s.clientId));
for (const id of elite) plus.delete(id);
const paidSet = new Set(paidSubClients.map((s) => s.clientId));
const giftConverted = giftClients.filter((g) => paidSet.has(g.clientId)).length;
// ── рост ──
const clients30 = newClients.filter((c) => c.createdAt >= from30);
const sources = new Map<string, number>();
for (const c of clients30) {
const utm = c.utm && typeof c.utm === 'object' ? ((c.utm as Record<string, unknown>).utm_source ?? (c.utm as Record<string, unknown>).source) : null;
const key = c.agency ? `agency:${c.agency.name}` : utm ? `utm:${String(utm)}` : c.source;
sources.set(key, (sources.get(key) ?? 0) + 1);
}
const payingClients30 = await ctx.db.client.count({
where: {
createdAt: { gte: from30 },
OR: [{ subscriptions: { some: { source: 'PAID' } } }, { orders: { some: { paymentStatus: 'PAID' } } }],
},
});
// ── команда ──
const partnerNames = new Map(
(await ctx.db.partner.findMany({ where: { id: { in: cardUsages.map((c) => c.partnerId!) } }, select: { id: true, name: true } })).map((p) => [p.id, p.name]),
);
const rated = providers.map((p) => ({ id: p.id, name: p.name, role: p.role, avg: p.ratingSum / p.ratingCount, count: p.ratingCount })).sort((a, b) => b.avg - a.avg);
// ── лента ──
const feed = [
...recentSubs.map((s) => ({
kind: 'subscription' as const,
at: s.createdAt,
clientId: s.clientId,
who: name(s.client.user),
plan: s.plan,
source: s.source,
amount: ctx.isSuper ? s.amount : null,
})),
...recentOrders.map((o) => ({ kind: 'payment' as const, at: o.paidAt!, clientId: o.clientId, who: name(o.client.user), service: o.service.code, number: o.number, amount: ctx.isSuper ? o.totalPrice : null })),
...recentAudit.map((a) => ({ kind: 'action' as const, at: a.createdAt, by: a.adminUser?.name ?? null, action: a.action, entityId: a.entityId })),
]
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 12);
return {
money: ctx.isSuper ? money : null,
subs: {
plus: plus.size,
elite: elite.size,
gifts: activeSubs.filter((s) => s.source !== 'PAID').length,
expiring: expiringSubs.map((s) => ({ clientId: s.clientId, name: name(s.client.user), plan: s.plan, endsAt: s.endsAt, source: s.source })),
giftClients: giftClients.length,
giftConverted,
},
growth: {
today: newClients.filter((c) => c.createdAt >= today).length,
week: newClients.filter((c) => c.createdAt >= new Date(today.getTime() - 6 * DAY)).length,
month: clients30.length,
prevMonth: newClients.length - clients30.length,
sources: [...sources.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count),
funnel: { leads: leads30, clients: clients30.length, paying: payingClients30 },
},
team: ctx.isSuper
? {
topProviders: rated.slice(0, 3),
bottomProviders: rated.length > 3 ? rated.slice(-2).reverse() : [],
topPartners: cardUsages.map((c) => ({ id: c.partnerId!, name: partnerNames.get(c.partnerId!) ?? '—', scans: c._count })),
}
: null,
feed,
};
}),
});
+13
View File
@@ -0,0 +1,13 @@
import { router } from '../../trpc.ts';
import { boAuthRouter, boTeamRouter } from './auth.ts';
import { boClientsRouter, boSegmentsRouter } from './clients.ts';
import { boDashboardRouter } from './dashboard.ts';
/** API новой админки (apps/backoffice) */
export const backofficeRouter = router({
auth: boAuthRouter,
team: boTeamRouter,
dashboard: boDashboardRouter,
clients: boClientsRouter,
segments: boSegmentsRouter,
});
+235
View File
@@ -0,0 +1,235 @@
import { defaultParams, discountFor, LOCALES } from '@barev/core';
import {
applyAgencyCode,
availableSlots,
cancelByClient,
cancelPreview,
clientOrder,
createOrder,
getMembership,
getServiceByCode,
getSettings,
giveConsent,
listCatalog,
listClientOrders,
quoteForClient,
repeatDraft,
rescheduleByClient,
setLocale,
clientRelocation,
createAppLead,
createClientRequest,
LEAD_MOVE_WHEN,
LEAD_PACKAGES,
openClientRequests,
relocationNames,
setSegment,
toggleRelocationDoc,
cardQrToken,
clientPartners,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { env } from '../env.ts';
import { signedUrl } from '../infra.ts';
import { clientProcedure, consentedClientProcedure, router } from '../trpc.ts';
const paramsInput = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number().int().min(0))]));
const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
export const clientRouter = router({
me: clientProcedure.query(async ({ ctx }) => {
const [membership, agency, settings] = await Promise.all([
getMembership(ctx.db, ctx.client.id, ctx.svc.now()),
ctx.client.agencyId ? ctx.db.agency.findUnique({ where: { id: ctx.client.agencyId } }) : null,
getSettings(ctx.db),
]);
return {
firstName: ctx.client.user.firstName,
locale: ctx.client.user.locale,
consented: !!ctx.client.consentAt,
membership,
agencyName: agency?.name ?? null,
giftUsed: !!ctx.client.giftUsedAt,
cardCode: ctx.client.cardCode,
barevPlusPrice: settings['barevPlus.monthlyPrice'],
freeCancelHours: settings['cancel.freeHours'],
/** сценарий приложения: MOVING — переезжает, LIVING — уже живёт; null — спросить */
segment: ctx.client.segment,
plans: {
plusPrice: settings['barevPlus.monthlyPrice'],
plusMaxPct: settings['barevPlus.maxDiscountPct'],
elitePrice: settings['elite.monthlyPrice'],
eliteMaxPct: settings['elite.maxDiscountPct'],
consultation: settings['relocation.consultation'],
},
/** чат с консьержем — переписка в боте */
conciergeUrl: `https://t.me/${env.CLIENT_BOT_USERNAME}`,
};
}),
setLocale: clientProcedure.input(z.object({ locale: z.enum(LOCALES) })).mutation(async ({ ctx, input }) => {
await setLocale(ctx.db, ctx.telegramId, input.locale);
}),
/** ONB-3 в Mini App — если клиент открыл приложение, минуя онбординг в боте */
consent: clientProcedure.mutation(async ({ ctx }) => giveConsent(ctx.svc, ctx.client.id)),
applyAgencyCode: consentedClientProcedure
.input(z.object({ code: z.string().min(2).max(32) }))
.mutation(({ ctx, input }) => applyAgencyCode(ctx.svc, ctx.client.id, input.code)),
catalog: clientProcedure.query(async ({ ctx }) => {
const [categories, membership] = await Promise.all([listCatalog(ctx.db), getMembership(ctx.db, ctx.client.id, ctx.svc.now())]);
return { categories, level: membership.level };
}),
setSegment: clientProcedure.input(z.object({ segment: z.enum(['MOVING', 'LIVING']) })).mutation(async ({ ctx, input }) => {
await setSegment(ctx.svc, ctx.client.id, input.segment);
}),
/** План переезда: шаги со статусами от оператора и чек-лист документов */
relocation: router({
/** names — названия шагов и документов из общего списка по ключу «STEP:код» / «DOC:код» */
get: clientProcedure.query(async ({ ctx }) => {
const [lead, names] = await Promise.all([clientRelocation(ctx.svc, ctx.client.id), relocationNames(ctx.db)]);
return { lead, names };
}),
create: consentedClientProcedure
.input(
z.object({
moveWhen: z.enum(LEAD_MOVE_WHEN),
moveDate: z.iso.date().transform((v) => new Date(`${v}T00:00:00Z`)).optional(),
package: z.enum(LEAD_PACKAGES),
options: z
.object({
airport: z.enum(['none', 'standard', 'premium']).optional(),
stay: z.enum(['none', 'flat', 'hotel']).optional(),
stayBudgetFrom: z.number().int().min(0).max(10_000_000).optional(),
stayBudgetTo: z.number().int().min(0).max(10_000_000).optional(),
flat: z.boolean().optional(),
socialCard: z.boolean().optional(),
bankCard: z.boolean().optional(),
registration: z.boolean().optional(),
residence: z.boolean().optional(),
})
.optional(),
comment: z.string().max(1000).optional(),
}),
)
.mutation(({ ctx, input }) => createAppLead(ctx.svc, ctx.client.id, input)),
toggleDoc: clientProcedure
.input(z.object({ docId: z.string().max(40), checked: z.boolean() }))
.mutation(({ ctx, input }) => toggleRelocationDoc(ctx.svc, ctx.client.id, input.docId, input.checked)),
}),
/** Заявки оператору: подписка Barev+/Elite или «перезвоните» по услуге без онлайн-записи */
requests: router({
open: clientProcedure.query(({ ctx }) => openClientRequests(ctx.svc, ctx.client.id)),
create: consentedClientProcedure
.input(
z.object({
kind: z.enum(['SUBSCRIPTION', 'SERVICE']),
plan: z.enum(['BAREV_PLUS', 'ELITE']).optional(),
serviceCode: z.string().max(40).optional(),
comment: z.string().max(1000).optional(),
}),
)
.mutation(({ ctx, input }) => createClientRequest(ctx.svc, ctx.client.id, input)),
}),
/** Партнёры клуба: скидки и подарки по клубной карте */
partners: clientProcedure.query(({ ctx }) => clientPartners(ctx.svc, ctx.client.id)),
/** Живой QR клубной карты: ссылка на рабочий бот, меняется каждую минуту */
cardQr: consentedClientProcedure.query(({ ctx }) => {
const { token, expiresAt } = cardQrToken(ctx.client.cardCode, ctx.svc.now());
return { url: `https://t.me/${env.PRO_BOT_USERNAME}?start=${token}`, expiresAt, cardCode: ctx.client.cardCode };
}),
/** ORD-2: параметры услуги для формы */
service: clientProcedure.input(z.object({ code: z.string() })).query(async ({ ctx, input }) => {
const s = await getServiceByCode(ctx.db, input.code);
const membership = await getMembership(ctx.db, ctx.client.id, ctx.svc.now());
return {
code: s.code,
category: s.category,
flow: s.flow,
active: s.active,
isOnline: s.isOnline,
paramsSchema: s.paramsSchema,
defaults: defaultParams(s.paramsSchema),
fromPrice: s.fromPrice,
discountPct: discountFor(membership.level, s.categoryDiscountPct, s.categoryEliteDiscountPct),
categoryDiscountPct: s.categoryDiscountPct,
categoryEliteDiscountPct: s.categoryEliteDiscountPct,
horizonDays: s.slotConfig.horizonDays,
lateCancelFeePct: s.lateCancelFeePct,
/** цены вариантов — чтобы показать «от N ֏» на кнопках выбора */
variantPrices: s.pricing.kind === 'rooms' ? Object.fromEntries(Object.entries(s.pricing.tiers).map(([v, tier]) => [v, tier.base])) : {},
/** цены доп. услуг — показываются рядом с выбором */
extrasPrices: s.pricing.kind === 'rooms' ? s.pricing.extras : {},
};
}),
districts: clientProcedure.query(({ ctx }) => ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' }, select: { id: true, code: true } })),
paymentMethods: clientProcedure.query(({ ctx }) =>
ctx.db.paymentMethod.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' }, select: { id: true, code: true, name: true } }),
),
/** ORD-4 */
quote: clientProcedure
.input(z.object({ serviceCode: z.string(), params: paramsInput }))
.query(async ({ ctx, input }) => (await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params)).quote),
/** ORD-3 */
slots: clientProcedure
.input(z.object({ serviceCode: z.string(), params: paramsInput, dateKey, excludeOrderId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const slots = await availableSlots(ctx.svc, input);
return slots.map((s) => ({ start: s.start, end: s.end }));
}),
/** ORD-5 */
createOrder: consentedClientProcedure
.input(
z.object({
serviceCode: z.string(),
params: paramsInput,
scheduledAt: z.date(),
addressText: z.string().max(300).optional(),
districtId: z.string().optional(),
lat: z.number().min(-90).max(90).optional(),
lng: z.number().min(-180).max(180).optional(),
note: z.string().max(1000).optional(),
paymentMethodId: z.string().optional(),
repeatOfId: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const order = await createOrder(ctx.svc, { ...input, clientId: ctx.client.id });
return { id: order.id, number: order.number };
}),
orders: router({
list: clientProcedure.query(({ ctx }) => listClientOrders(ctx.db, ctx.client.id)),
get: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => clientOrder(ctx.db, ctx.client.id, input.id)),
cancelPreview: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => cancelPreview(ctx.svc, input.id, ctx.client.id)),
cancel: clientProcedure
.input(z.object({ id: z.string(), reason: z.string().max(500).optional() }))
.mutation(({ ctx, input }) => cancelByClient(ctx.svc, { orderId: input.id, clientId: ctx.client.id, reason: input.reason }).then((r) => ({ fee: r.fee }))),
reschedule: clientProcedure
.input(z.object({ id: z.string(), scheduledAt: z.date() }))
.mutation(({ ctx, input }) => rescheduleByClient(ctx.svc, { orderId: input.id, clientId: ctx.client.id, scheduledAt: input.scheduledAt })),
/** ORD-7 */
repeat: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => repeatDraft(ctx.db, input.id, ctx.client.id)),
}),
/** Фотоотчёт по своему заказу — временная ссылка, доступ логируется */
fileUrl: clientProcedure.input(z.object({ fileId: z.string() })).query(async ({ ctx, input }) => {
const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId }, include: { order: true } });
if (!file || file.deletedAt || file.order.clientId !== ctx.client.id || file.kind !== 'PHOTO_REPORT') throw new TRPCError({ code: 'NOT_FOUND' });
await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'CLIENT', actorId: ctx.client.id } });
return { url: await signedUrl(file.storageKey) };
}),
});
+112
View File
@@ -0,0 +1,112 @@
import { averageRating, fromYerevan, parseDateKey } from '@barev/core';
import {
companyBoard,
companyStaffForOrder,
dispatcherAssign,
markPaid,
proHistory,
proOrder,
proToday,
providerAction,
respondToOffer,
setChecklistItem,
setShift,
checkCard,
createStaffInvite,
partnerCabinet,
redeemOffer,
removePartnerMember,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { signedUrl } from '../infra.ts';
import { proUserProcedure, providerProcedure, router } from '../trpc.ts';
const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
/** Кабинет партнёра клуба: собственник и сотрудники */
const partnerRouter = router({
cabinet: proUserProcedure.input(z.object({ partnerId: z.string().optional() })).query(({ ctx, input }) => partnerCabinet(ctx.svc, ctx.identity.user.telegramId, input.partnerId)),
/** скан QR клиента (ссылка или код) */
check: proUserProcedure
.input(z.object({ token: z.string().max(300), partnerId: z.string().optional() }))
.mutation(({ ctx, input }) => checkCard(ctx.svc, { telegramId: ctx.identity.user.telegramId, ...input })),
redeem: proUserProcedure
.input(z.object({ checkId: z.string(), offerId: z.string() }))
.mutation(({ ctx, input }) => redeemOffer(ctx.svc, { telegramId: ctx.identity.user.telegramId, ...input })),
invite: proUserProcedure.input(z.object({ partnerId: z.string().optional() })).mutation(({ ctx, input }) => createStaffInvite(ctx.svc, ctx.identity.user.telegramId, input.partnerId)),
removeMember: proUserProcedure.input(z.object({ memberId: z.string() })).mutation(({ ctx, input }) => removePartnerMember(ctx.svc, ctx.identity.user.telegramId, input.memberId)),
});
export const proRouter = router({
partner: partnerRouter,
/** Кто я: исполнитель и/или диспетчер компаний */
me: proUserProcedure.query(({ ctx }) => {
const p = ctx.identity.provider;
return {
locale: ctx.identity.user.locale,
provider: p
? { id: p.id, name: p.name, role: p.role, onShift: p.onShift, rating: averageRating(p.ratingSum, p.ratingCount), companyName: p.company?.name ?? null }
: null,
companies: ctx.identity.companies.map((c) => ({ id: c.id, name: c.name })),
partners: ctx.identity.partners,
};
}),
today: providerProcedure.query(({ ctx }) => proToday(ctx.svc, ctx.provider.id)),
order: providerProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => proOrder(ctx.svc, ctx.provider.id, input.id)),
setShift: providerProcedure.input(z.object({ onShift: z.boolean() })).mutation(async ({ ctx, input }) => {
await setShift(ctx.db, ctx.provider.id, input.onShift);
}),
/** PRO-5 */
respondOffer: providerProcedure
.input(z.object({ offerId: z.string(), accept: z.boolean() }))
.mutation(({ ctx, input }) => respondToOffer(ctx.svc, { ...input, providerId: ctx.provider.id })),
/** PRO-3 */
action: providerProcedure
.input(z.object({ orderId: z.string(), action: z.string().max(40), comment: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
await providerAction(ctx.svc, { ...input, providerId: ctx.provider.id });
}),
/** PRO-4 */
checklist: providerProcedure
.input(z.object({ orderId: z.string(), code: z.string(), checked: z.boolean() }))
.mutation(async ({ ctx, input }) => {
await setChecklistItem(ctx.svc, { ...input, providerId: ctx.provider.id });
}),
/** PAY-1: исполнитель отмечает «оплачено» (например, наличными) */
markPaid: providerProcedure.input(z.object({ orderId: z.string() })).mutation(async ({ ctx, input }) => {
await markPaid(ctx.svc, { orderId: input.orderId, by: { type: 'PROVIDER', id: ctx.provider.id } });
}),
/** PRO-6 */
history: providerProcedure.input(z.object({ from: dateKey, to: dateKey })).query(({ ctx, input }) => {
const f = parseDateKey(input.from);
const t = parseDateKey(input.to);
return proHistory(ctx.svc, ctx.provider.id, fromYerevan(f.year, f.month, f.day), fromYerevan(t.year, t.month, t.day + 1));
}),
fileUrl: providerProcedure.input(z.object({ fileId: z.string() })).query(async ({ ctx, input }) => {
const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId }, include: { order: true } });
if (!file || file.deletedAt || file.order.providerId !== ctx.provider.id) throw new TRPCError({ code: 'NOT_FOUND' });
await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'PROVIDER', actorId: ctx.provider.id } });
return { url: await signedUrl(file.storageKey) };
}),
/** PRO-7: диспетчер компании */
dispatcher: router({
board: proUserProcedure.input(z.object({ companyId: z.string() })).query(({ ctx, input }) => companyBoard(ctx.svc, input.companyId, ctx.identity.user.id)),
staffForOrder: proUserProcedure.input(z.object({ companyId: z.string(), orderId: z.string() })).query(async ({ ctx, input }) => {
if (!ctx.identity.companies.some((c) => c.id === input.companyId)) throw new TRPCError({ code: 'FORBIDDEN' });
return companyStaffForOrder(ctx.svc, input.companyId, input.orderId);
}),
assign: proUserProcedure
.input(z.object({ companyId: z.string(), orderId: z.string(), providerId: z.string() }))
.mutation(({ ctx, input }) => dispatcherAssign(ctx.svc, { ...input, dispatcherUserId: ctx.identity.user.id })),
}),
});
+155
View File
@@ -0,0 +1,155 @@
import { randomBytes } from 'node:crypto';
import { botLink, encodeStartPayload, LOCALES, normalizeAgencyCode, UTM_KEYS } from '@barev/core';
import { createLead, createWebRequest, getServiceByCode, getSettings, LEAD_MOVE_WHEN, LEAD_PACKAGES, listCatalog, updateLead } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { loginThrottled } from '../auth/admin.ts';
import { env } from '../env.ts';
import { publicProcedure, router } from '../trpc.ts';
export const publicRouter = router({
/** LND-5: цены «от» и скидки — для лендинга */
catalog: publicProcedure.query(async ({ ctx }) => {
const [categories, settings] = await Promise.all([listCatalog(ctx.db), getSettings(ctx.db)]);
return {
categories,
barevPlus: { monthlyPrice: settings['barevPlus.monthlyPrice'], giftDays: settings['barevPlus.giftDays'] },
/** цены и скидки главной: отель, консультация, Elite, «скидки до N%» */
offer: {
plusMaxPct: settings['barevPlus.maxDiscountPct'],
elitePrice: settings['elite.monthlyPrice'],
eliteMaxPct: settings['elite.maxDiscountPct'],
hotelWithPlus: settings['relocation.hotelWithPlus'],
hotelBase: settings['relocation.hotelBase'],
consultation: settings['relocation.consultation'],
docsFrom: settings['docs.fromPrice'],
socialCard: settings['docs.socialCardPrice'],
residenceEscortFrom: settings['residence.escortFrom'],
residenceStateFee: settings['residence.stateFee'],
transferStandard: settings['transfer.standardPrice'],
transferPremium: settings['transfer.premiumPrice'],
},
/** условия для агентств-партнёров — страница /land6 */
agency: { commissionPct: settings['agency.defaultCommissionPct'], attributionMonths: settings['agency.attributionMonths'] },
botUsername: env.CLIENT_BOT_USERNAME,
};
}),
/**
* LND-3: ссылка в бота. Если есть UTM — сохраняем их и отдаём короткий токен,
* потому что payload Telegram ограничен 64 символами.
*/
startLink: publicProcedure
.input(
z.object({
locale: z.enum(LOCALES).optional(),
agency: z.string().max(32).optional(),
utm: z.partialRecord(z.enum(UTM_KEYS), z.string().max(120)).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const agency = input.agency ? (normalizeAgencyCode(input.agency) ?? undefined) : undefined;
const hasUtm = input.utm && Object.keys(input.utm).length > 0;
let payload: string;
if (hasUtm) {
const id = randomBytes(6).toString('base64url').replace(/[-_]/g, 'x');
await ctx.db.startToken.create({ data: { id, locale: input.locale, agency, utm: input.utm } });
payload = encodeStartPayload({ token: id });
} else {
payload = encodeStartPayload({ locale: input.locale, agency });
}
return { payload, url: botLink(env.CLIENT_BOT_USERNAME, payload) };
}),
/** Страница /clean: правила цены и окно записи — цена считается в браузере той же функцией quote, что и на сервере */
cleaning: publicProcedure.query(async ({ ctx }) => {
const [s, districts] = await Promise.all([getServiceByCode(ctx.db, 'cleaning'), ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' }, select: { code: true } })]);
return {
active: s.active,
pricing: s.pricing,
paramsSchema: s.paramsSchema,
slotConfig: s.slotConfig,
categoryDiscountPct: s.categoryDiscountPct,
districts: districts.map((d) => d.code),
botUsername: env.CLIENT_BOT_USERNAME,
};
}),
/** Заявка с сайта → сразу в админке; в ответе ссылка в бота, которая привязывает заявку к Telegram */
cleaningRequest: publicProcedure
.input(
z.object({
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number().int().min(0))])),
preferredAt: z.iso.datetime().transform((v) => new Date(v)),
districtCode: z.string().max(40),
addressText: z.string().max(300),
note: z.string().max(1000).optional(),
phone: z.string().max(30),
name: z.string().max(80).optional(),
locale: z.enum(LOCALES),
agency: z.string().max(32).optional(),
utm: z.partialRecord(z.enum(UTM_KEYS), z.string().max(120)).optional(),
consent: z.literal(true),
}),
)
.mutation(async ({ ctx, input }) => {
// защита от спама формой: тот же счётчик, что у входа в админку (10 попыток за 15 минут с IP)
if (loginThrottled(`web-request|${ctx.req.ip}`)) throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'REQUEST_THROTTLED' });
const agency = input.agency ? (normalizeAgencyCode(input.agency) ?? undefined) : undefined;
const request = await createWebRequest(ctx.svc, { ...input, serviceCode: 'cleaning', agency });
const payload = encodeStartPayload({ token: request.token });
return { number: request.number, url: botLink(env.CLIENT_BOT_USERNAME, payload) };
}),
/** Квиз на главной, шаг 1: имя и контакт — сразу в базу и в админку. В ответе ключ для следующих шагов */
leadStart: publicProcedure
.input(
z.object({
name: z.string().max(80),
contact: z.string().max(60),
locale: z.enum(LOCALES),
agency: z.string().max(32).optional(),
utm: z.partialRecord(z.enum(UTM_KEYS), z.string().max(120)).optional(),
consent: z.literal(true),
}),
)
.mutation(async ({ ctx, input }) => {
if (loginThrottled(`lead|${ctx.req.ip}`)) throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'REQUEST_THROTTLED' });
const agency = input.agency ? (normalizeAgencyCode(input.agency) ?? undefined) : undefined;
const lead = await createLead(ctx.svc, { ...input, agency });
return { id: lead.id, key: lead.key, number: lead.number };
}),
/** Квиз, следующие шаги: ответы дописываются в ту же заявку */
leadUpdate: publicProcedure
.input(
z.object({
id: z.string().max(40),
key: z.string().max(40),
moved: z.boolean().optional(),
moveWhen: z.enum(LEAD_MOVE_WHEN).optional(),
moveDate: z.iso.date().transform((v) => new Date(`${v}T00:00:00Z`)).optional(),
package: z.enum(LEAD_PACKAGES).optional(),
options: z
.object({
airport: z.enum(['none', 'standard', 'premium']).optional(),
stay: z.enum(['none', 'flat', 'hotel']).optional(),
stayBudgetFrom: z.number().int().min(0).max(10_000_000).optional(),
stayBudgetTo: z.number().int().min(0).max(10_000_000).optional(),
flat: z.boolean().optional(),
socialCard: z.boolean().optional(),
bankCard: z.boolean().optional(),
registration: z.boolean().optional(),
residence: z.boolean().optional(),
})
.optional(),
submit: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const lead = await updateLead(ctx.svc, input);
return { number: lead.number, submitted: !!lead.submittedAt };
}),
health: publicProcedure.query(() => ({ ok: true })),
});
+96
View File
@@ -0,0 +1,96 @@
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import multipart from '@fastify/multipart';
import * as Sentry from '@sentry/node';
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { getFlow } from '@barev/core';
import { DomainError, proIdentity, setChecklistItem } from '@barev/services';
import Fastify from 'fastify';
import { validateInitData } from './auth/telegram.ts';
import { env } from './env.ts';
import { ctx as svc, db, ensureBucket, putObject } from './infra.ts';
import { appRouter, type AppRouter } from './router.ts';
import { createContext } from './trpc.ts';
if (env.SENTRY_DSN) Sentry.init({ dsn: env.SENTRY_DSN, environment: env.NODE_ENV });
const app = Fastify({
logger: { level: env.isProd ? 'info' : 'warn', redact: ['req.headers.authorization', 'req.headers.cookie'] },
bodyLimit: 1024 * 1024,
trustProxy: true,
// tRPC склеивает несколько запросов в один путь (/trpc/a,b,c) — по умолчанию Fastify режет параметр после 100 символов (414)
routerOptions: { maxParamLength: 5000 },
});
await app.register(cors, { origin: env.corsOrigins, credentials: true });
await app.register(cookie);
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
await app.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
createContext,
onError({ error, path }) {
if (error.code === 'INTERNAL_SERVER_ERROR') {
app.log.error({ path, err: error.cause ?? error }, 'trpc error');
if (env.SENTRY_DSN) Sentry.captureException(error.cause ?? error);
}
},
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
});
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic']);
/**
* PRO-4: фото к пункту чек-листа (фотоотчёт клинера).
* multipart: orderId, code, file. Авторизация — initData бота исполнителей.
*/
app.post('/files/pro/checklist-photo', async (req, reply) => {
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;
const init = validateInitData(auth, env.proBotToken, env.TG_INIT_DATA_MAX_AGE);
if (!init.ok) return reply.code(401).send({ error: `INIT_DATA_${init.reason}` });
const identity = await proIdentity(db, BigInt(init.user.id));
if (!identity?.provider) return reply.code(403).send({ error: 'NOT_PROVIDER' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
const field = (name: string) => {
const f = part.fields[name];
return f && !Array.isArray(f) && f.type === 'field' ? String(f.value) : undefined;
};
const orderId = field('orderId');
const code = field('code');
if (!orderId || !code) return reply.code(400).send({ error: 'BAD_INPUT' });
if (!IMAGE_TYPES.has(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
const order = await db.order.findUnique({ where: { id: orderId }, include: { service: true } });
if (!order || order.providerId !== identity.provider.id) return reply.code(404).send({ error: 'NOT_FOUND' });
if (!getFlow(order.service.flow).checklistAt.includes(order.status)) return reply.code(409).send({ error: 'TRANSITION_NOT_ALLOWED' });
const body = await part.toBuffer();
const key = await putObject(`orders/${order.id}`, body, part.mimetype);
const file = await db.orderFile.create({
data: { orderId: order.id, kind: 'PHOTO_REPORT', storageKey: key, mime: part.mimetype, size: body.length, uploadedBy: 'PROVIDER', uploadedById: identity.provider.id },
});
try {
await setChecklistItem(svc, { orderId: order.id, providerId: identity.provider.id, code, checked: true, fileId: file.id });
} catch (e) {
if (e instanceof DomainError) return reply.code(400).send({ error: e.code });
throw e;
}
return { fileId: file.id };
});
/** Ошибки из Mini App и админки (sendBeacon) — только в лог, без хранения */
app.post('/client-log', { bodyLimit: 4096 }, async (req, reply) => {
const b = (typeof req.body === 'object' && req.body) || {};
app.log.warn({ clientError: b, ua: req.headers['user-agent']?.slice(0, 160), host: req.headers.host }, 'client error');
return reply.code(204).send();
});
app.get('/health', async () => ({ ok: true }));
await ensureBucket().catch((e) => app.log.warn({ err: e }, 'S3 недоступно — загрузка фото работать не будет'));
await app.listen({ port: env.API_PORT, host: '0.0.0.0' });
console.log(`API: http://localhost:${env.API_PORT}`);
+169
View File
@@ -0,0 +1,169 @@
// подключает типы req.cookies / reply.setCookie
import type {} from '@fastify/cookie';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { initTRPC, TRPCError } from '@trpc/server';
import { DomainError, getClientByTelegramId, proIdentity, startClient } from '@barev/services';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { ADMIN_COOKIE, findSession } from './auth/admin.ts';
import { validateInitData } from './auth/telegram.ts';
import { env } from './env.ts';
import { ctx as servicesCtx, db } from './infra.ts';
export async function createContext({ req, res }: { req: FastifyRequest; res: FastifyReply }) {
return { req, res, db, svc: servicesCtx };
}
export type Context = Awaited<ReturnType<typeof createContext>>;
const DOMAIN_TO_TRPC: Partial<Record<DomainError['code'], TRPCError['code']>> = {
NOT_FOUND: 'NOT_FOUND',
FORBIDDEN: 'FORBIDDEN',
CONSENT_REQUIRED: 'PRECONDITION_FAILED',
};
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
const cause = error.cause;
return {
...shape,
data: {
...shape.data,
// код бизнес-ошибки — фронт показывает errors.<domainCode>
domainCode: cause instanceof DomainError ? cause.code : null,
domainDetails: cause instanceof DomainError ? (cause.details ?? null) : null,
zod: cause instanceof ZodError ? cause.issues.map((i) => ({ path: i.path.join('.'), code: i.code })) : null,
stack: env.isProd ? undefined : shape.data.stack,
},
};
},
});
/** DomainError → TRPCError с сохранением кода */
const domainErrors = t.middleware(async ({ next }) => {
const result = await next();
if (!result.ok && result.error.cause instanceof DomainError) {
const cause = result.error.cause;
throw new TRPCError({ code: DOMAIN_TO_TRPC[cause.code] ?? 'BAD_REQUEST', message: cause.code, cause });
}
return result;
});
export const router = t.router;
export const publicProcedure = t.procedure.use(domainErrors);
function initDataFrom(req: FastifyRequest): string | undefined {
const h = req.headers.authorization;
return h?.startsWith('tma ') ? h.slice(4) : undefined;
}
function checkInitData(req: FastifyRequest, token: string) {
const res = validateInitData(initDataFrom(req), token, env.TG_INIT_DATA_MAX_AGE);
if (!res.ok) throw new TRPCError({ code: 'UNAUTHORIZED', message: `INIT_DATA_${res.reason}` });
return res;
}
/**
* Клиентский Mini App: каждый запрос подписан initData клиентского бота.
* Если клиент открыл Mini App раньше, чем написал /start, — заводим его без источника.
*/
export const clientProcedure = publicProcedure.use(async ({ ctx, next }) => {
const init = checkInitData(ctx.req, env.clientBotToken);
const telegramId = BigInt(init.user.id);
let client = await getClientByTelegramId(ctx.db, telegramId);
if (!client) {
await startClient(ctx.svc, init.user, init.startParam);
client = await getClientByTelegramId(ctx.db, telegramId);
}
return next({ ctx: { tgUser: init.user, telegramId, client: client! } });
});
/** То же, но заказ требует согласия на обработку ПДн (ONB-3) */
export const consentedClientProcedure = clientProcedure.use(async ({ ctx, next }) => {
if (!ctx.client.consentAt) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'CONSENT_REQUIRED', cause: new DomainError('CONSENT_REQUIRED') });
return next();
});
/** Mini App исполнителя: initData бота исполнителей; пользователь должен быть привязан инвайтом */
export const proUserProcedure = publicProcedure.use(async ({ ctx, next }) => {
const init = checkInitData(ctx.req, env.proBotToken);
const identity = await proIdentity(ctx.db, BigInt(init.user.id));
if (!identity || (!identity.provider && identity.companies.length === 0 && identity.partners.length === 0)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_LINKED' });
}
return next({ ctx: { tgUser: init.user, identity } });
});
export const providerProcedure = proUserProcedure.use(async ({ ctx, next }) => {
const provider = ctx.identity.provider;
if (!provider) throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_PROVIDER' });
return next({ ctx: { provider } });
});
// ───── админка ─────
async function loadAdmin(ctx: Context) {
const token = ctx.req.cookies[ADMIN_COOKIE];
const session = await findSession(ctx.db, token);
return session ? { session, admin: session.adminUser, token: token! } : null;
}
/** Вошёл паролем, но 2FA ещё не пройдена */
export const adminPreAuthProcedure = publicProcedure.use(async ({ ctx, next }) => {
const s = await loadAdmin(ctx);
if (!s) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'ADMIN_LOGIN_REQUIRED' });
return next({ ctx: s });
});
/** Аудит-лог действий админов: каждая мутация */
const audit = t.middleware(async ({ ctx, next, type, path, getRawInput }) => {
const result = await next();
const admin = (ctx as Context & { admin?: { id: string } }).admin;
if (type === 'mutation' && result.ok && admin) {
const input = (await getRawInput()) as Record<string, unknown> | undefined;
await db.auditLog.create({
data: {
adminUserId: admin.id,
action: path,
entity: path.split('.')[1] ?? 'admin',
entityId: typeof input?.id === 'string' ? input.id : typeof input?.orderId === 'string' ? input.orderId : null,
data: input ? JSON.parse(JSON.stringify(input, (k, v) => (/password|code|secret/i.test(k) ? '***' : v))) : undefined,
},
});
}
return result;
});
export const adminProcedure = adminPreAuthProcedure
.use(async ({ ctx, next }) => {
if (!ctx.session.verified) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'ADMIN_2FA_REQUIRED' });
return next();
})
.use(audit);
/** ADM-9: финансы и настройки — только владелец */
export const ownerProcedure = adminProcedure.use(async ({ ctx, next }) => {
if (ctx.admin.role !== 'OWNER') throw new TRPCError({ code: 'FORBIDDEN', message: 'OWNER_ONLY' });
return next();
});
// ───── новая админка (apps/backoffice) ─────
export type StaffRole = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
/** Сотрудник новой админки: вошёл и имеет хотя бы одну роль */
export const staffProcedure = adminProcedure.use(async ({ ctx, next }) => {
if (ctx.admin.roles.length === 0) throw new TRPCError({ code: 'FORBIDDEN', message: 'NO_ROLE' });
const roles = ctx.admin.roles as StaffRole[];
const isSuper = roles.includes('SUPERADMIN');
return next({ ctx: { roles, isSuper } });
});
/** Доступ для перечисленных ролей; суперадмину можно всё */
export const rolesProcedure = (...allowed: StaffRole[]) =>
staffProcedure.use(async ({ ctx, next }) => {
if (!ctx.isSuper && !ctx.roles.some((r) => allowed.includes(r))) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return next();
});
export const superProcedure = rolesProcedure();
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { signInitData, validateInitData } from '../src/auth/telegram.ts';
import { base32Decode, base32Encode, generateTotpSecret, totpCode, verifyTotp } from '../src/auth/totp.ts';
const TOKEN = '123456:test-token';
const user = { id: 42, first_name: 'Anna', language_code: 'ru' };
describe('Telegram initData', () => {
it('принимает корректную подпись', () => {
const res = validateInitData(signInitData(TOKEN, user, { startParam: 'l-ru' }), TOKEN, 3600);
expect(res).toMatchObject({ ok: true, user: { id: 42 }, startParam: 'l-ru' });
});
it('отклоняет отсутствующие, подделанные, чужим токеном и просроченные данные', () => {
expect(validateInitData(undefined, TOKEN, 3600)).toEqual({ ok: false, reason: 'MISSING' });
const signed = signInitData(TOKEN, user);
const tampered = signed.replace('%22id%22%3A42', '%22id%22%3A43');
expect(tampered).not.toBe(signed);
expect(validateInitData(tampered, TOKEN, 3600)).toEqual({ ok: false, reason: 'BAD_HASH' });
expect(validateInitData(signed, '999:other', 3600)).toEqual({ ok: false, reason: 'BAD_HASH' });
expect(validateInitData(signed.replace(/hash=[0-9a-f]+/, 'hash=zz'), TOKEN, 3600)).toEqual({ ok: false, reason: 'BAD_HASH' });
const old = signInitData(TOKEN, user, { authDate: new Date(Date.now() - 2 * 3600_000) });
expect(validateInitData(old, TOKEN, 3600)).toEqual({ ok: false, reason: 'EXPIRED' });
});
it('без токена бота не принимает ничего', () => {
expect(validateInitData(signInitData(TOKEN, user), '', 3600)).toEqual({ ok: false, reason: 'NO_TOKEN' });
});
it('совпадает с эталонным алгоритмом Telegram (порядок ключей не важен)', () => {
const signed = new URLSearchParams(signInitData(TOKEN, user));
const reordered = new URLSearchParams([...signed.entries()].reverse());
expect(validateInitData(reordered.toString(), TOKEN, 3600).ok).toBe(true);
});
});
describe('TOTP', () => {
it('RFC 6238: эталонный вектор SHA-1', () => {
// секрет "12345678901234567890", T = 59 c → 94287082 (8 цифр) → последние 6: 287082
const secret = base32Encode(Buffer.from('12345678901234567890'));
expect(totpCode(secret, 59_000)).toBe('287082');
expect(base32Decode(secret).toString()).toBe('12345678901234567890');
});
it('проверяет код с допуском ±30 с', () => {
const secret = generateTotpSecret();
const now = Date.now();
expect(verifyTotp(secret, totpCode(secret, now), now)).toBe(true);
expect(verifyTotp(secret, totpCode(secret, now - 30_000), now)).toBe(true);
expect(verifyTotp(secret, totpCode(secret, now - 120_000), now)).toBe(false);
expect(verifyTotp(secret, 'abcdef', now)).toBe(false);
});
});
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src", "scripts", "test"] }