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:
1 parent
ac786cc5cc
commit
0edc6432b7
34 files changed
+2704
-26
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user