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

+22
View File
@@ -0,0 +1,22 @@
{
"name": "@barev/worker",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch --clear-screen=false src/index.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@barev/core": "workspace:*",
"@barev/db": "workspace:*",
"@barev/i18n": "workspace:*",
"@barev/services": "workspace:*",
"dotenv": "^17.0.0",
"bullmq": "^5.81.5"
},
"devDependencies": {
"tsx": "^4.20.0"
}
}
+307
View File
@@ -0,0 +1,307 @@
/**
* Воркер: превращает доменные события в сообщения Telegram на языке получателя (MSG-2, MSG-3)
* и раз в минуту запускает фоновые задачи — напоминания и истечение предложений.
*/
import { getDb } from '@barev/db';
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
import { dueReminders, EVENTS_QUEUE, expireOffers, type Ctx, type DomainEvent } from '@barev/services';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
import { compactRows, escapeHtml, TelegramSender, webAppButton, type InlineButton } from '@barev/services/telegram';
import { Worker } from 'bullmq';
loadRootEnv();
const db = getDb();
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
const notifier = createQueueNotifier(REDIS_URL);
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
const clientBot = new TelegramSender(process.env.CLIENT_BOT_TOKEN ?? '', 'client-bot');
const proBot = new TelegramSender(process.env.PRO_BOT_TOKEN ?? '', 'pro-bot');
const CLIENT_APP = process.env.CLIENT_MINIAPP_URL ?? '';
const PRO_APP = process.env.PRO_MINIAPP_URL ?? '';
const ADMIN_URL = process.env.ADMIN_URL ?? '';
/** Уведомления операторам: всем, кто привязал Telegram в админке и не выключил уведомления */
async function toAdmins(text: string, buttons: InlineButton[][] = []) {
const admins = await db.adminUser.findMany({ where: { active: true, tgNotify: true, telegramId: { not: null } } });
for (const a of admins) await proBot.send({ chatId: a.telegramId!, text, buttons });
}
const adminT = createT('ru');
/** Ссылка на раздел админки — Telegram принимает url-кнопки только с https */
const adminButton = (path: string): InlineButton | null => (ADMIN_URL.startsWith('https://') ? { text: adminT('bot.admin.open'), url: `${ADMIN_URL}/#${path}` } : null);
const who = (u: { firstName: string | null; lastName?: string | null; username: string | null }) =>
`${escapeHtml(`${u.firstName ?? ''} ${u.lastName ?? ''}`.trim())}${u.username ? ` (@${escapeHtml(u.username)})` : ''}`;
async function loadOrder(id: string) {
return db.order.findUnique({
where: { id },
include: {
service: true,
district: true,
client: { include: { user: true } },
provider: { include: { user: true } },
},
});
}
type OrderRow = NonNullable<Awaited<ReturnType<typeof loadOrder>>>;
function orderTitle(o: OrderRow, locale: Locale) {
const t = createT(locale);
return `#${o.number} · ${t(`services.${o.service.code}.name`)}`;
}
const when = (o: OrderRow, locale: Locale) => formatDateTime(locale, o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
const clientOrderButton = (locale: Locale, orderId: string) =>
compactRows([[webAppButton(createT(locale)('notify.openOrder'), `${CLIENT_APP}/?r=/orders/${orderId}`)]]);
const proOrderButton = (locale: Locale, orderId: string) =>
compactRows([[webAppButton(createT(locale)('notify.openOrder'), `${PRO_APP}/?r=/orders/${orderId}`)]]);
async function toClient(o: OrderRow, key: string, params: Record<string, string | number> = {}) {
const locale = o.client.user.locale as Locale;
const t = createT(locale);
const text = `<b>${escapeHtml(orderTitle(o, locale))}</b>\n${t(key, { ...params, when: when(o, locale), provider: o.provider?.name ?? '' })}`;
await clientBot.send({ chatId: o.client.user.telegramId, text, buttons: clientOrderButton(locale, o.id) });
}
async function toProvider(o: OrderRow, providerUser: { telegramId: bigint; locale: string } | null | undefined, key: string, extra?: string) {
if (!providerUser) return;
const locale = providerUser.locale as Locale;
const t = createT(locale);
const text = `<b>${escapeHtml(orderTitle(o, locale))}</b>\n${t(key, { when: when(o, locale) })}${extra ? `\n${extra}` : ''}`;
await proBot.send({ chatId: providerUser.telegramId, text, buttons: proOrderButton(locale, o.id) });
}
/** Карточка заказа для исполнителя: время, район, язык клиента, итог — без контактов клиента */
function proDetails(o: OrderRow, locale: Locale) {
const t = createT(locale);
const lines = [
`🕒 ${when(o, locale)} · ${t('notify.duration', { minutes: o.durationMinutes })}`,
o.isOnline ? `💻 ${t('common.online')}` : `📍 ${o.district ? t(`districts.${o.district.code}`) : ''}`,
`🗣 ${t('notify.clientLanguage')}: ${o.locale.toUpperCase()}`,
`💰 ${formatAmd(locale, o.totalPrice)}`,
];
return lines.join('\n');
}
async function dispatchers(companyId: string) {
return db.companyDispatcher.findMany({ where: { companyId }, include: { user: true } });
}
async function handle(event: DomainEvent) {
switch (event.type) {
case 'order.created': {
const o = await loadOrder(event.orderId);
if (!o) return;
const locale = o.client.user.locale as Locale;
await toClient(o, 'notify.created', { total: formatAmd(locale, o.totalPrice) });
await toAdmins(
`📦 <b>${adminT('bot.admin.newOrder', { number: o.number })}</b>\n${escapeHtml(adminT(`services.${o.service.code}.name`))} · ${when(o, 'ru')}\n${who(o.client.user)} · ${formatAmd('ru', o.totalPrice)}`,
compactRows([[adminButton('/')]]),
);
return;
}
case 'order.status': {
const o = await loadOrder(event.orderId);
if (!o) return;
// клиент — о каждом переходе (STS)
await toClient(o, `notify.status.${o.service.flow}.${event.to}`);
if (event.to === 'CANCELLED' && o.provider?.user) await toProvider(o, o.provider.user, 'notify.pro.cancelled');
if (event.to === 'ASSIGNED' && event.from === 'NEW' && o.provider?.user && o.companyId) {
// назначил диспетчер компании — сотрудник узнаёт сразу
await toProvider(o, o.provider.user, 'notify.pro.assignedByDispatcher', proDetails(o, o.provider.user.locale as Locale));
}
return;
}
case 'order.event': {
const o = await loadOrder(event.orderId);
if (o) await toClient(o, `notify.event.${event.code}`);
return;
}
case 'order.rescheduled': {
const o = await loadOrder(event.orderId);
if (!o) return;
await toClient(o, 'notify.rescheduled');
if (o.provider?.user) await toProvider(o, o.provider.user, 'notify.pro.rescheduled');
return;
}
case 'order.unassigned': {
const o = await loadOrder(event.orderId);
const p = await db.provider.findUnique({ where: { id: event.providerId }, include: { user: true } });
if (o && p?.user) await toProvider(o, p.user, 'notify.pro.unassigned');
return;
}
case 'offer.created': {
const offer = await db.orderOffer.findUnique({ where: { id: event.offerId }, include: { provider: { include: { user: true } } } });
const o = offer && (await loadOrder(offer.orderId));
const user = offer?.provider?.user;
if (!o || !user) return;
const locale = user.locale as Locale;
const t = createT(locale);
const minutes = offer.expiresAt ? Math.max(1, Math.round((offer.expiresAt.getTime() - Date.now()) / 60_000)) : null;
const text = [
`<b>${t('notify.pro.offerTitle')}</b>`,
escapeHtml(orderTitle(o, locale)),
proDetails(o, locale),
minutes ? t('notify.pro.offerTimeout', { minutes }) : '',
]
.filter(Boolean)
.join('\n');
// кнопки обрабатывает бот исполнителей (callback offer:<accept|decline>:<id>)
await proBot.send({
chatId: user.telegramId,
text,
buttons: [
[
{ text: `✅ ${t('pro.offer.accept')}`, callback_data: `offer:accept:${offer.id}` },
{ text: t('pro.offer.decline'), callback_data: `offer:decline:${offer.id}` },
],
],
});
return;
}
case 'offer.declined':
case 'offer.expired': {
// если отказался сотрудник компании — заказ снова ждёт диспетчера
const offer = await db.orderOffer.findUnique({ where: { id: event.offerId }, include: { provider: true } });
const companyId = offer?.provider?.companyId;
const o = offer && (await loadOrder(offer.orderId));
if (!o || !companyId) return;
for (const d of await dispatchers(companyId)) await toProvider(o, d.user, 'notify.dispatcher.returned');
return;
}
case 'company.order': {
const o = await loadOrder(event.orderId);
if (!o) return;
for (const d of await dispatchers(event.companyId)) {
await toProvider(o, d.user, 'notify.dispatcher.newOrder', proDetails(o, d.user.locale as Locale));
}
return;
}
case 'order.reminder': {
const o = await loadOrder(event.orderId);
if (!o) return;
const key = event.hours === 24 ? 'notify.reminder24' : 'notify.reminder1';
await toClient(o, key);
if (o.provider?.user) await toProvider(o, o.provider.user, `notify.pro.${event.hours === 24 ? 'reminder24' : 'reminder1'}`);
return;
}
case 'gift.activated':
// сообщение о подарке показывает сам бот/Mini App в момент активации
return;
case 'lead.step': {
const step = await db.leadStep.findUnique({ where: { id: event.stepId }, include: { lead: { include: { client: { include: { user: true } } } } } });
const user = step?.lead.client?.user;
if (!step || !user) return;
const locale = user.locale as Locale;
const t = createT(locale);
const item = await db.relocationItem.findUnique({ where: { kind_code: { kind: 'STEP', code: step.code } } });
const names = (item?.name ?? {}) as Record<string, string>;
const title = step.title ?? names[locale] ?? names.ru ?? step.code;
const lines = [`<b>${escapeHtml(title)}</b> — ${t(`client.move.status.${step.status}`)}`];
if (step.note) lines.push(escapeHtml(step.note));
await clientBot.send({ chatId: user.telegramId, text: lines.join('\n'), buttons: compactRows([[webAppButton(t('notify.openPlan'), `${CLIENT_APP}/?r=/`)]]) });
return;
}
case 'concierge.reply': {
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } } } });
if (!msg) return;
if (msg.client) {
const t = createT(msg.client.user.locale as Locale);
await clientBot.send({ chatId: msg.client.user.telegramId, text: `<b>${t('notify.concierge')}</b>\n${escapeHtml(msg.text)}` });
} else if (msg.provider?.user) {
const t = createT(msg.provider.user.locale as Locale);
await proBot.send({ chatId: msg.provider.user.telegramId, text: `<b>${t('notify.operator')}</b>\n${escapeHtml(msg.text)}` });
}
return;
}
case 'concierge.message': {
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } } } });
if (!msg) return;
const from = msg.client ? who(msg.client.user) : `${escapeHtml(msg.provider?.name ?? '')} · ${adminT('bot.admin.provider')}`;
const peer = msg.clientId ? `c:${msg.clientId}` : `p:${msg.providerId}`;
await toAdmins(`💬 <b>${from}</b>\n${escapeHtml(msg.text)}`, compactRows([[{ text: adminT('bot.admin.reply'), callback_data: `ar:${peer}` }, adminButton(`/concierge?${msg.clientId ? 'client' : 'provider'}=${msg.clientId ?? msg.providerId}`)]]));
return;
}
case 'lead.submitted': {
const lead = await db.lead.findUnique({ where: { id: event.leadId } });
if (!lead) return;
const when = lead.moved
? adminT('admin.leads.moved')
: lead.moveWhen === 'date' && lead.moveDate
? formatDateTime('ru', lead.moveDate, { day: 'numeric', month: 'long', timeZone: 'UTC' })
: lead.moveWhen
? adminT(`landing.quiz.when_${lead.moveWhen}`)
: '—';
const pkg = lead.package ? adminT(`landing.quiz.package_${lead.package}`) : '—';
await toAdmins(
`🧳 <b>${adminT('bot.admin.newLead', { number: lead.number })}</b>\n${escapeHtml(lead.name)} · ${escapeHtml(lead.contact)}\n${when} · ${pkg}${lead.adminNote ? `\n${escapeHtml(lead.adminNote)}` : ''}`,
compactRows([[adminButton('/leads')]]),
);
return;
}
case 'clientRequest.created': {
const r = await db.clientRequest.findUnique({ where: { id: event.requestId }, include: { client: { include: { user: true } } } });
if (!r) return;
const what = r.kind === 'SUBSCRIPTION' ? adminT('admin.requests.subscription', { plan: r.plan === 'ELITE' ? 'Barev Elite' : 'Barev+' }) : adminT(`services.${r.serviceCode}.name`);
await toAdmins(
`⭐ <b>${adminT('bot.admin.newRequest', { number: r.number })}</b>\n${who(r.client.user)}\n${escapeHtml(what)}${r.comment ? `\n${escapeHtml(r.comment)}` : ''}`,
compactRows([[{ text: adminT('bot.admin.reply'), callback_data: `ar:c:${r.clientId}` }, adminButton('/app-requests')]]),
);
return;
}
case 'webRequest.created': {
const r = await db.webRequest.findUnique({ where: { id: event.requestId }, include: { service: true, district: true } });
if (!r) return;
const date = formatDateTime('ru', r.preferredAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
await toAdmins(
`🧹 <b>${adminT('bot.admin.newWebRequest', { number: r.number })}</b>\n${escapeHtml(adminT(`services.${r.service.code}.name`))} · ${date}\n${escapeHtml(r.name ?? '')} ${escapeHtml(r.phone)} · ${escapeHtml(adminT(`districts.${r.district.code}`))} · ${formatAmd('ru', r.totalPrice)}`,
compactRows([[adminButton('/requests')]]),
);
return;
}
case 'subscription.activated': {
const sub = await db.subscription.findUnique({ where: { id: event.subscriptionId }, include: { client: { include: { user: true } } } });
if (!sub) return;
const locale = sub.client.user.locale as Locale;
const t = createT(locale);
const text = t('notify.subscriptionActivated', { plan: sub.plan === 'ELITE' ? 'Barev Elite' : 'Barev+', date: formatDateTime(locale, sub.endsAt, { dateStyle: 'long' }) });
await clientBot.send({ chatId: sub.client.user.telegramId, text, buttons: compactRows([[webAppButton(t('notify.openPlan'), `${CLIENT_APP}/?r=/membership`)]]) });
return;
}
}
}
const worker = new Worker<DomainEvent>(EVENTS_QUEUE, async (job) => handle(job.data), {
connection: redisConnection(REDIS_URL),
concurrency: 5,
});
worker.on('failed', (job, err) => console.error(`[worker] ${job?.name} failed:`, err.message));
// фоновые задачи — идемпотентны, безопасно запускать каждую минуту
let ticking = false;
async function tick() {
if (ticking) return;
ticking = true;
try {
const [reminders, expired] = await Promise.all([dueReminders(ctx), expireOffers(ctx)]);
if (reminders || expired) console.log(`[worker] reminders=${reminders} expiredOffers=${expired}`);
} catch (e) {
console.error('[worker] tick failed', e);
} finally {
ticking = false;
}
}
setInterval(tick, 60_000);
void tick();
console.log('Worker запущен');
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
process.on(sig, async () => {
await worker.close();
await notifier.queue.close();
await db.$disconnect();
process.exit(0);
});
}
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src"] }