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

+1
View File
@@ -23,6 +23,7 @@
"@aws-sdk/s3-request-presigner": "^3.1152.0",
"@barev/core": "workspace:*",
"@barev/db": "workspace:*",
"@barev/i18n": "workspace:*",
"@barev/services": "workspace:*",
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^11.1.0",
+137
View File
@@ -0,0 +1,137 @@
/** Этап 5: рассылки клиентам, журнал действий, настройки */
import { DAY, SETTING_KEYS, settingsSchema } from '@barev/core';
import type { Prisma } from '@barev/db';
import { getSettings } from '@barev/services';
import { TelegramSender } from '@barev/services/telegram';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { env } from '../../env.ts';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
import { clientFilters, filteredClients } from './clients.ts';
const LANGS = ['ru', 'en', 'zh', 'hy', 'es'] as const;
const texts = z.object({ ru: z.string().trim().min(1).max(3500), en: z.string().max(3500), zh: z.string().max(3500), hy: z.string().max(3500), es: z.string().max(3500) });
const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** Рассылки делают поддержка и суперадмин */
const bc = rolesProcedure('SUPPORT');
/** Получатели: клиенты по фильтрам, только давшие согласие (иначе бот им не писал) */
async function recipients(db: import('@barev/db').Db, filters: z.infer<typeof clientFilters>, now: Date) {
const { list } = await filteredClients(db, filters, now);
return list.filter((c) => !!c.consentAt).map((c) => ({ id: c.id, locale: c.user.locale }));
}
export const boBroadcastsRouter = router({
/** Сколько клиентов получит и на каких языках */
preview: bc.input(clientFilters).query(async ({ ctx, input }) => {
const list = await recipients(ctx.db, input, ctx.svc.now());
const byLocale = Object.fromEntries(LANGS.map((l) => [l, list.filter((c) => c.locale === l).length])) as Record<(typeof LANGS)[number], number>;
return { total: list.length, byLocale };
}),
/** Проверка: прислать себе в клиентский бот (нужно, чтобы вы хоть раз открыли его) */
test: bc.input(z.object({ text: z.string().trim().min(1).max(3500), appButton: z.boolean() })).mutation(async ({ ctx, input }) => {
if (!ctx.admin.telegramId) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'TELEGRAM_NOT_LINKED' });
const bot = new TelegramSender(env.CLIENT_BOT_TOKEN, 'client-bot');
const app = env.CLIENT_MINIAPP_URL;
const ok = await bot.send({
chatId: ctx.admin.telegramId,
text: escapeHtml(input.text),
buttons: input.appButton && app.startsWith('https://') ? [[{ text: 'Barev Club', web_app: { url: app } }]] : [],
});
if (!ok) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'TEST_UNREACHABLE' });
}),
/** Запустить рассылку: фиксируем получателей, воркер отправляет с ограничением скорости Telegram */
send: bc.input(z.object({ filters: clientFilters, texts, appButton: z.boolean() })).mutation(async ({ ctx, input }) => {
const list = await recipients(ctx.db, input.filters, ctx.svc.now());
if (!list.length) throw new TRPCError({ code: 'BAD_REQUEST', message: 'NO_RECIPIENTS' });
const b = await ctx.db.broadcast.create({
data: {
texts: input.texts,
appButton: input.appButton,
filters: input.filters as Prisma.InputJsonValue,
total: list.length,
createdById: ctx.admin.id,
recipients: { createMany: { data: list.map((c) => ({ clientId: c.id })) } },
},
});
await ctx.svc.notify.emit({ type: 'broadcast.start', broadcastId: b.id });
return { id: b.id, total: list.length };
}),
list: bc.query(async ({ ctx }) => {
const rows = await ctx.db.broadcast.findMany({ orderBy: { createdAt: 'desc' }, take: 50 });
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: rows.map((r) => r.createdById).filter((x): x is string => !!x) } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
return rows.map((r) => ({
id: r.id,
text: (r.texts as Record<string, string>).ru ?? '',
status: r.status,
total: r.total,
sent: r.sent,
blocked: r.blocked,
failed: r.failed,
by: r.createdById ? (admins.get(r.createdById) ?? null) : null,
createdAt: r.createdAt,
finishedAt: r.finishedAt,
}));
}),
cancel: bc.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await ctx.db.broadcast.updateMany({ where: { id: input.id, status: 'SENDING' }, data: { status: 'CANCELLED', finishedAt: ctx.svc.now() } });
}),
});
// ───── журнал действий ─────
export const boJournalRouter = router({
list: superProcedure
.input(z.object({ adminUserId: z.string().optional(), entity: z.string().max(40).optional(), days: z.number().int().min(1).max(365).default(30), cursor: z.string().optional() }))
.query(async ({ ctx, input }) => {
const since = new Date(ctx.svc.now().getTime() - input.days * DAY);
const rows = await ctx.db.auditLog.findMany({
where: { createdAt: { gte: since }, ...(input.adminUserId ? { adminUserId: input.adminUserId } : {}), ...(input.entity ? { entity: input.entity } : {}) },
orderBy: { createdAt: 'desc' },
take: 101,
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
include: { adminUser: { select: { id: true, name: true } } },
});
const page = rows.slice(0, 100);
return {
items: page.map((r) => ({ id: r.id, at: r.createdAt, by: r.adminUser, action: r.action, entity: r.entity, entityId: r.entityId, data: r.data as Record<string, unknown> | null })),
next: rows.length > 100 ? page.at(-1)!.id : null,
};
}),
facets: superProcedure.query(async ({ ctx }) => {
const [staff, entities] = await Promise.all([
ctx.db.adminUser.findMany({ orderBy: { name: 'asc' }, select: { id: true, name: true } }),
ctx.db.auditLog.groupBy({ by: ['entity'], _count: true, orderBy: { _count: { entity: 'desc' } } }),
]);
return { staff, entities: entities.map((e) => e.entity) };
}),
});
// ───── настройки ─────
export const boSettingsRouter = router({
get: superProcedure.query(({ ctx }) => getSettings(ctx.db)),
save: superProcedure.input(settingsSchema.partial()).mutation(async ({ ctx, input }) => {
for (const key of SETTING_KEYS) {
const value = input[key];
if (value === undefined) continue;
await ctx.db.setting.upsert({ where: { key }, create: { key, value: value as never }, update: { value: value as never } });
}
}),
paymentMethods: superProcedure.query(({ ctx }) => ctx.db.paymentMethod.findMany({ orderBy: { sortOrder: 'asc' } })),
savePaymentMethod: superProcedure
.input(z.object({ id: z.string().optional(), code: z.string().trim().min(1).max(40), name: texts, active: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
if (id) await ctx.db.paymentMethod.update({ where: { id }, data });
else {
const last = await ctx.db.paymentMethod.findFirst({ orderBy: { sortOrder: 'desc' } });
await ctx.db.paymentMethod.create({ data: { ...data, sortOrder: (last?.sortOrder ?? 0) + 1 } });
}
}),
});
+1 -1
View File
@@ -110,7 +110,7 @@ function baseWhere(f: ClientFilters): Prisma.ClientWhereInput {
return and.length ? { AND: and } : {};
}
async function filteredClients(db: Db, f: ClientFilters, now: Date) {
export 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,
+6
View File
@@ -7,6 +7,8 @@ import { boOrdersRouter } from './orders.ts';
import { boProvidersRouter } from './providers.ts';
import { boCrmRouter } from './crm.ts';
import { boPartnersRouter } from './partners.ts';
import { boProductsRouter } from './products.ts';
import { boBroadcastsRouter, boJournalRouter, boSettingsRouter } from './admin5.ts';
/** API новой админки (apps/backoffice) */
export const backofficeRouter = router({
@@ -20,4 +22,8 @@ export const backofficeRouter = router({
providers: boProvidersRouter,
crm: boCrmRouter,
partners: boPartnersRouter,
products: boProductsRouter,
broadcasts: boBroadcastsRouter,
journal: boJournalRouter,
settings: boSettingsRouter,
});
+5 -3
View File
@@ -337,10 +337,10 @@ export const boOrdersRouter = router({
/** Цена и длительность для клиента (с его скидкой по подписке) */
quote: ordersProcedure
.input(z.object({ clientId: z.string(), service: z.string(), params: z.record(z.string(), z.unknown()) }))
.input(z.object({ clientId: z.string(), service: z.string(), params: z.record(z.string(), z.unknown()), promoCode: z.string().trim().max(40).optional() }))
.query(async ({ ctx, input }) => {
const { quote: q, membership } = await quoteForClient(ctx.svc, input.clientId, input.service, input.params);
return { durationMinutes: q.durationMinutes, total: q.totalPrice, base: q.basePrice, discountPct: q.discountPct, level: membership.level };
const { quote: q, membership, promo } = await quoteForClient(ctx.svc, input.clientId, input.service, input.params, input.promoCode);
return { durationMinutes: q.durationMinutes, total: q.totalPrice, base: q.basePrice, discountPct: q.discountPct, level: membership.level, promo: promo ? promo.code : null };
}),
slots: ordersProcedure
@@ -359,10 +359,12 @@ export const boOrdersRouter = router({
note: z.string().trim().max(1000).optional(),
paymentMethodId: z.string().optional(),
allowBusySlot: z.boolean().default(false),
promoCode: z.string().trim().max(40).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const o = await createOrder(ctx.svc, {
promoCode: input.promoCode,
clientId: input.clientId,
serviceCode: input.service,
params: input.params,
+160
View File
@@ -0,0 +1,160 @@
import { randomBytes } from 'node:crypto';
import { CATEGORY_CODES, DAY, fromPrice, paramsSchemaSchema, pricingSchema, slotConfigSchema } from '@barev/core';
import { defaultText, LOCALES, type Locale } from '@barev/i18n';
import { isEditableTextKey, listRelocationItems, loadTextOverrides, moveRelocationItem, normalizePromo, saveRelocationItem } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/** Продукты видят поддержка и суперадмин; меняет — суперадмин (цены и скидки — деньги) */
const view = rolesProcedure('SUPPORT');
const edit = superProcedure;
const localized = z.object({ ru: z.string().max(500), en: z.string().max(500), zh: z.string().max(500), hy: z.string().max(500), es: z.string().max(500) });
/** Текст по ключу на 5 языках: стандартный (из переводов) и изменённый в админке */
async function textsFor(db: import('@barev/db').Db, keys: string[]) {
const rows = await db.textOverride.findMany({ where: { key: { in: keys } } });
const out: Record<string, Record<Locale, { default: string | null; value: string | null }>> = {};
for (const key of keys) {
out[key] = Object.fromEntries(LOCALES.map((l) => [l, { default: defaultText(l, key), value: rows.find((r) => r.key === key && r.locale === l)?.value ?? null }])) as Record<Locale, { default: string | null; value: string | null }>;
}
return out;
}
export const boProductsRouter = router({
/** Направления (скидки по уровням) и услуги со всеми настройками */
catalog: view.query(async ({ ctx }) => {
const since = new Date(ctx.svc.now().getTime() - 30 * DAY);
const [categories, orders30] = await Promise.all([
ctx.db.category.findMany({ orderBy: { sortOrder: 'asc' }, include: { services: { orderBy: { sortOrder: 'asc' }, include: { checklist: { orderBy: { sortOrder: 'asc' } } } } } }),
ctx.db.order.groupBy({ by: ['serviceId'], where: { createdAt: { gte: since }, status: { not: 'CANCELLED' } }, _count: true }),
]);
const count = new Map(orders30.map((o) => [o.serviceId, o._count]));
return categories.map((c) => ({
id: c.id,
code: c.code,
barevPlusDiscountPct: c.barevPlusDiscountPct,
eliteDiscountPct: c.eliteDiscountPct,
services: c.services.map((s) => {
const pricing = pricingSchema.parse(s.pricing);
return {
id: s.id,
code: s.code,
flow: s.flow,
providerRole: s.providerRole,
active: s.active,
isOnline: s.isOnline,
lateCancelFeePct: s.lateCancelFeePct,
pricing,
paramsSchema: paramsSchemaSchema.parse(s.paramsSchema),
slotConfig: slotConfigSchema.parse(s.slotConfig),
fromPrice: fromPrice(pricing),
orders30: count.get(s.id) ?? 0,
checklist: s.checklist.map((x) => ({ code: x.code, requiresPhoto: x.requiresPhoto })),
};
}),
}));
}),
/** Тексты для формы: название/описание услуги, доп. услуги, пункты чек-листа */
texts: view.input(z.object({ keys: z.array(z.string().max(120)).max(200) })).query(({ ctx, input }) => textsFor(ctx.db, input.keys.filter(isEditableTextKey))),
/** Сохранить тексты; пустое значение — вернуть стандартный перевод */
saveTexts: edit.input(z.array(z.object({ key: z.string().max(120), locale: z.enum(LOCALES), value: z.string().max(2000) })).max(400)).mutation(async ({ ctx, input }) => {
for (const r of input) {
if (!isEditableTextKey(r.key)) throw new TRPCError({ code: 'BAD_REQUEST', message: 'TEXT_KEY_FORBIDDEN' });
const value = r.value.trim();
const same = value === (defaultText(r.locale, r.key) ?? '');
if (!value || same) await ctx.db.textOverride.deleteMany({ where: { key: r.key, locale: r.locale } });
else await ctx.db.textOverride.upsert({ where: { key_locale: { key: r.key, locale: r.locale } }, create: { key: r.key, locale: r.locale, value, updatedById: ctx.admin.id }, update: { value, updatedById: ctx.admin.id } });
}
// этот процесс видит правки сразу; боты и воркер — в течение минуты
await loadTextOverrides(ctx.db);
}),
saveService: edit
.input(z.object({ id: z.string(), active: z.boolean(), lateCancelFeePct: z.number().int().min(0).max(100), pricing: pricingSchema, slotConfig: slotConfigSchema }))
.mutation(async ({ ctx, input }) => {
if (input.slotConfig.dayEndMinute <= input.slotConfig.dayStartMinute) throw new TRPCError({ code: 'BAD_REQUEST', message: 'SLOT_WINDOW_INVALID' });
const { id, ...data } = input;
await ctx.db.service.update({ where: { id }, data });
}),
saveCategory: edit
.input(z.object({ id: z.string(), barevPlusDiscountPct: z.number().int().min(0).max(100), eliteDiscountPct: z.number().int().min(0).max(100) }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
await ctx.db.category.update({ where: { id }, data });
}),
/** Чек-лист услуги: новые заказы получают новый список, уже созданные — без изменений */
saveChecklist: edit
.input(z.object({ serviceId: z.string(), items: z.array(z.object({ code: z.string().max(60).optional(), label: z.string().max(120).optional(), requiresPhoto: z.boolean() })).max(30) }))
.mutation(async ({ ctx, input }) => {
const items = input.items.map((x) => ({ ...x, code: x.code || `c_${randomBytes(3).toString('hex')}` }));
await ctx.db.$transaction([
ctx.db.checklistItem.deleteMany({ where: { serviceId: input.serviceId } }),
ctx.db.checklistItem.createMany({ data: items.map((x, i) => ({ serviceId: input.serviceId, code: x.code, requiresPhoto: x.requiresPhoto, sortOrder: i })) }),
]);
// подпись нового пункта — русский текст (переводы можно добавить в «Текстах»)
for (const x of items) {
if (x.label?.trim() && !defaultText('ru', `checklist.${x.code}`)) {
await ctx.db.textOverride.upsert({ where: { key_locale: { key: `checklist.${x.code}`, locale: 'ru' } }, create: { key: `checklist.${x.code}`, locale: 'ru', value: x.label.trim(), updatedById: ctx.admin.id }, update: { value: x.label.trim() } });
}
}
await loadTextOverrides(ctx.db);
}),
// ───── промокоды ─────
promos: view.query(async ({ ctx }) => {
const list = await ctx.db.promoCode.findMany({ where: { kind: 'DISCOUNT' }, orderBy: [{ active: 'desc' }, { createdAt: 'desc' }] });
const now = ctx.svc.now();
return list.map((p) => ({
id: p.id,
code: p.code,
discountPct: p.discountPct ?? 0,
maxUses: p.maxUses,
usedCount: p.usedCount,
validFrom: p.validFrom,
validUntil: p.validUntil,
categories: p.categories,
note: p.note,
active: p.active,
state: !p.active ? 'off' : p.validUntil && p.validUntil < now ? 'expired' : p.maxUses != null && p.usedCount >= p.maxUses ? 'exhausted' : p.validFrom && p.validFrom > now ? 'scheduled' : 'live',
}));
}),
savePromo: edit
.input(
z.object({
id: z.string().optional(),
code: z.string().min(3).max(40),
discountPct: z.number().int().min(1).max(100),
maxUses: z.number().int().min(1).nullable(),
validFrom: z.date().nullable(),
validUntil: z.date().nullable(),
categories: z.array(z.enum(CATEGORY_CODES)),
note: z.string().max(300).nullable(),
active: z.boolean(),
}),
)
.mutation(async ({ ctx, input }) => {
const code = normalizePromo(input.code);
if (!/^[A-Z0-9_-]{3,40}$/.test(code)) throw new TRPCError({ code: 'BAD_REQUEST', message: 'PROMO_CODE_FORMAT' });
const clash = await ctx.db.promoCode.findUnique({ where: { code } });
if (clash && clash.id !== input.id) throw new TRPCError({ code: 'BAD_REQUEST', message: 'PROMO_CODE_TAKEN' });
const { id, ...rest } = input;
const data = { ...rest, code, kind: 'DISCOUNT' as const };
if (id) await ctx.db.promoCode.update({ where: { id }, data });
else await ctx.db.promoCode.create({ data });
}),
// ───── релокация: общий список шагов и документов ─────
relocation: view.query(({ ctx }) => listRelocationItems(ctx.svc)),
saveRelocation: edit
.input(z.object({ id: z.string().optional(), kind: z.enum(['STEP', 'DOC']), name: localized, hint: localized.nullable().optional(), active: z.boolean(), inTurnkey: z.boolean().optional() }))
.mutation(({ ctx, input }) => saveRelocationItem(ctx.svc, input).then((x) => ({ id: x.id }))),
moveRelocation: edit.input(z.object({ id: z.string(), dir: z.union([z.literal(-1), z.literal(1)]) })).mutation(({ ctx, input }) => moveRelocationItem(ctx.svc, input)),
});
+7 -2
View File
@@ -182,8 +182,11 @@ export const clientRouter = router({
/** 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),
.input(z.object({ serviceCode: z.string(), params: paramsInput, promoCode: z.string().trim().max(40).optional() }))
.query(async ({ ctx, input }) => {
const r = await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params, input.promoCode);
return { ...r.quote, promo: r.promo ? { code: r.promo.code, discountPct: r.promo.discountPct } : null };
}),
/** ORD-3 */
slots: clientProcedure
@@ -207,6 +210,8 @@ export const clientRouter = router({
note: z.string().max(1000).optional(),
paymentMethodId: z.string().optional(),
repeatOfId: z.string().optional(),
/** промокод: применится, если выгоднее скидки подписки */
promoCode: z.string().trim().max(40).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
+3
View File
@@ -9,6 +9,9 @@ import { publicProcedure, router } from '../trpc.ts';
export const publicRouter = router({
/** LND-5: цены «от» и скидки — для лендинга */
/** Тексты, изменённые в админке, — Mini App подставляет их поверх своих переводов */
texts: publicProcedure.query(({ ctx }) => ctx.db.textOverride.findMany({ select: { key: true, locale: true, value: true } })),
catalog: publicProcedure.query(async ({ ctx }) => {
const [categories, settings] = await Promise.all([listCatalog(ctx.db), getSettings(ctx.db)]);
return {
+4 -1
View File
@@ -4,7 +4,7 @@ import multipart, { type MultipartFile } 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, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffSend } from '@barev/services';
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffSend, startTextOverrides } from '@barev/services';
import { getFile, putFile } from '@barev/services/storage';
import { TelegramSender } from '@barev/services/telegram';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
@@ -278,6 +278,9 @@ app.post('/client-log', { bodyLimit: 4096 }, async (req, reply) => {
app.get('/health', async () => ({ ok: true }));
// тексты из админки поверх переводов (обновляются раз в минуту)
startTextOverrides(db);
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}`);