Backoffice stage 5: products, broadcasts, activity log, settings

- Products: services with full setup — content (name, description, extras names
  in 5 languages via text overrides), price tiers and extras, booking window,
  late-cancel fee, on/off, checklist; category discounts by membership level;
  membership plan prices; promo codes; relocation steps and documents.
- Text overrides: TextOverride table on top of the i18n files; API, bots and the
  worker reload them every minute, mini apps fetch public.texts at start.
- Promo codes applied to orders (admin new-order form and client API): not
  stacked with membership discount — the larger wins; once per client; validity,
  limit and categories.
- Broadcasts to the client bot by any client filter, per-language texts with
  Russian fallback, test to yourself, ~25 msg/s, per-recipient status, resume after
  worker restart, history with progress and stop.
- Activity log (who/when/what, filters, details) and Settings page with all
  owner settings grouped, plus payment methods.
- Tests for promo codes and text overrides.

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 03:17:56 +04:00
1 parent ac786cc5cc
commit 0edc6432b7
34 files changed
+2704 -26

No files matched your search

+61 -1
View File
@@ -4,7 +4,7 @@
*/
import { getDb } from '@barev/db';
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
import { autoAssign, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
import { autoAssign, startTextOverrides, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
import { getFile } from '@barev/services/storage';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
@@ -17,6 +17,7 @@ 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() };
startTextOverrides(db);
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 ?? '';
@@ -46,6 +47,59 @@ async function reportAuto(orderId: string, res: AutoAssignResult) {
);
}
// ───── рассылки клиентам ─────
const running = new Set<string>();
const pause = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Рассылка: порциями по 25, ~25 сообщений в секунду (лимит Telegram — 30).
* Каждый получатель отмечается отдельно, поэтому после перезапуска воркера отправка продолжается с места остановки.
*/
async function runBroadcast(id: string) {
if (running.has(id)) return;
running.add(id);
try {
for (;;) {
const b = await db.broadcast.findUnique({ where: { id } });
if (!b || b.status !== 'SENDING') return;
const batch = await db.broadcastRecipient.findMany({ where: { broadcastId: id, status: 'PENDING' }, take: 25 });
if (!batch.length) {
await db.broadcast.update({ where: { id }, data: { status: 'DONE', finishedAt: new Date() } });
return;
}
const clients = await db.client.findMany({ where: { id: { in: batch.map((r) => r.clientId) } }, include: { user: true } });
const texts = b.texts as Record<string, string>;
for (const r of batch) {
const c = clients.find((x) => x.id === r.clientId);
let status: 'SENT' | 'BLOCKED' | 'FAILED' = 'FAILED';
let error: string | null = null;
if (c) {
const locale = c.user.locale as Locale;
const text = (texts[locale] || texts.ru || '').trim();
try {
const ok = await clientBot.send({
chatId: c.user.telegramId,
text: escapeHtml(text),
buttons: b.appButton ? compactRows([[webAppButton(createT(locale)('bot.client.openApp'), `${CLIENT_APP}/`)]]) : [],
});
status = ok ? 'SENT' : 'BLOCKED';
} catch (e) {
error = String(e).slice(0, 300);
}
}
await db.broadcastRecipient.update({ where: { id: r.id }, data: { status, error, sentAt: new Date() } });
await db.broadcast.update({ where: { id }, data: status === 'SENT' ? { sent: { increment: 1 } } : status === 'BLOCKED' ? { blocked: { increment: 1 } } : { failed: { increment: 1 } } });
await pause(40);
}
}
} catch (e) {
console.error('[worker] broadcast failed', id, e);
} finally {
running.delete(id);
}
}
/** Новая админка, если уже на сервере; иначе — раздел переписки в старой */
const BACKOFFICE_URL = process.env.BACKOFFICE_URL ?? '';
const chatButton = (threadId: string | null, peer: string): InlineButton | null =>
@@ -286,6 +340,10 @@ async function handle(event: DomainEvent) {
for (const a of recipients) await proBot.send({ chatId: a.telegramId!, text, buttons });
return;
}
case 'broadcast.start':
// отправка идёт вне задачи очереди — длинная рассылка не держит очередь
void runBroadcast(event.broadcastId);
return;
case 'sales.taskDue': {
const task = await db.salesTask.findUnique({ where: { id: event.taskId }, include: { assignee: true, lead: true } });
if (!task || task.doneAt || !task.assignee.telegramId || !task.assignee.active) return;
@@ -381,6 +439,8 @@ async function tick() {
for (const { orderId, res } of await retryAutoAssign(ctx)) await reportAuto(orderId, res);
// задачи продажников, у которых наступил срок
await dueSalesTasks(ctx);
// рассылки, прерванные перезапуском, — продолжить
for (const b of await db.broadcast.findMany({ where: { status: 'SENDING' }, select: { id: true } })) void runBroadcast(b.id);
} catch (e) {
console.error('[worker] tick failed', e);
} finally {