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

@@ -0,0 +1,60 @@
-- CreateEnum
CREATE TYPE "BroadcastStatus" AS ENUM ('SENDING', 'DONE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "BroadcastRecipientStatus" AS ENUM ('PENDING', 'SENT', 'BLOCKED', 'FAILED');
-- AlterTable
ALTER TABLE "PromoCode" ADD COLUMN "categories" "CategoryCode"[] DEFAULT ARRAY[]::"CategoryCode"[],
ADD COLUMN "note" TEXT;
-- CreateTable
CREATE TABLE "TextOverride" (
"key" TEXT NOT NULL,
"locale" "Locale" NOT NULL,
"value" TEXT NOT NULL,
"updatedById" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TextOverride_pkey" PRIMARY KEY ("key","locale")
);
-- CreateTable
CREATE TABLE "Broadcast" (
"id" TEXT NOT NULL,
"texts" JSONB NOT NULL,
"appButton" BOOLEAN NOT NULL DEFAULT true,
"filters" JSONB NOT NULL,
"status" "BroadcastStatus" NOT NULL DEFAULT 'SENDING',
"total" INTEGER NOT NULL DEFAULT 0,
"sent" INTEGER NOT NULL DEFAULT 0,
"blocked" INTEGER NOT NULL DEFAULT 0,
"failed" INTEGER NOT NULL DEFAULT 0,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" TIMESTAMP(3),
CONSTRAINT "Broadcast_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BroadcastRecipient" (
"id" TEXT NOT NULL,
"broadcastId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"status" "BroadcastRecipientStatus" NOT NULL DEFAULT 'PENDING',
"error" TEXT,
"sentAt" TIMESTAMP(3),
CONSTRAINT "BroadcastRecipient_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "BroadcastRecipient_broadcastId_status_idx" ON "BroadcastRecipient"("broadcastId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "BroadcastRecipient_broadcastId_clientId_key" ON "BroadcastRecipient"("broadcastId", "clientId");
-- AddForeignKey
ALTER TABLE "BroadcastRecipient" ADD CONSTRAINT "BroadcastRecipient_broadcastId_fkey" FOREIGN KEY ("broadcastId") REFERENCES "Broadcast"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+66
View File
@@ -706,6 +706,10 @@ model PromoCode {
validFrom DateTime?
validUntil DateTime?
active Boolean @default(true)
/// для команды: откуда код (рассылка, блогер, партнёр)
note String?
/// на какие направления действует; пусто — на все
categories CategoryCode[] @default([])
createdAt DateTime @default(now())
uses PromoCodeUse[]
@@ -1369,3 +1373,65 @@ model SalesTask {
@@index([assigneeId, doneAt, dueAt])
}
// ───────────── Продукты, рассылки (этап 5) ─────────────
/// Текст, изменённый в админке поверх переводов из packages/i18n (название и описание услуги, пункт чек-листа…).
/// Пустой — не храним: действует стандартный перевод
model TextOverride {
key String
locale Locale
value String
updatedById String?
updatedAt DateTime @updatedAt
@@id([key, locale])
}
enum BroadcastStatus {
SENDING
DONE
CANCELLED
}
enum BroadcastRecipientStatus {
PENDING
SENT
/// заблокировал бота или ни разу его не открывал
BLOCKED
FAILED
}
/// Рассылка клиентам в бот по сегменту (фильтры списка клиентов). Текст — на языке клиента, фолбэк — русский
model Broadcast {
id String @id @default(cuid())
/// { ru, en, zh, hy, es }
texts Json
/// кнопка «Открыть приложение» под сообщением
appButton Boolean @default(true)
/// фильтры списка клиентов, по которым собрали получателей
filters Json
status BroadcastStatus @default(SENDING)
total Int @default(0)
sent Int @default(0)
blocked Int @default(0)
failed Int @default(0)
createdById String?
createdAt DateTime @default(now())
finishedAt DateTime?
recipients BroadcastRecipient[]
}
model BroadcastRecipient {
id String @id @default(cuid())
broadcastId String
broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
clientId String
status BroadcastRecipientStatus @default(PENDING)
error String?
sentAt DateTime?
@@unique([broadcastId, clientId])
@@index([broadcastId, status])
}
+32 -1
View File
@@ -56,12 +56,43 @@ function interpolate(s: string, params?: TParams) {
* выбираются по params.count. Отсутствующий ключ возвращается как есть — его ловит `pnpm i18n:check`.
*/
const merged = new Map<Locale, Messages>();
/** Тексты, изменённые в админке (TextOverride): поверх переводов из locales/*.json */
const overrides: Partial<Record<Locale, Messages>> = {};
let overridesVersion = 0;
const listeners = new Set<() => void>();
function withFallback(locale: Locale): Messages {
let m = merged.get(locale);
if (!m) merged.set(locale, (m = { ...resources.ru, ...(resources[locale] ?? {}) }));
// свой перевод языка важнее русской правки; своя правка языка важнее всего
if (!m) merged.set(locale, (m = { ...resources.ru, ...(overrides.ru ?? {}), ...(resources[locale] ?? {}), ...(overrides[locale] ?? {}) }));
return m;
}
/**
* Подставить тексты из админки. Вызывают API, боты и воркер (из БД) и Mini App (из public.texts).
* После вызова createT отдаёт новые тексты; подписчики (React-провайдер) перерисовываются.
*/
export function setTextOverrides(rows: { key: string; locale: string; value: string }[]) {
for (const l of LOCALES) overrides[l] = {};
for (const r of rows) if (isLocale(r.locale) && r.value) overrides[r.locale]![r.key] = r.value;
merged.clear();
overridesVersion++;
for (const cb of listeners) cb();
}
/** Номер версии правок — чтобы мемоизированный t пересоздавался */
export const textOverridesVersion = () => overridesVersion;
export function onTextOverrides(cb: () => void) {
listeners.add(cb);
return () => listeners.delete(cb);
}
/** Стандартный перевод (без правок из админки) — подсказка в форме редактирования */
export function defaultText(locale: Locale, key: string): string | null {
return resources[locale]?.[key] ?? null;
}
export function createT(locale: Locale): TFunction {
return tFromMessages(locale, withFallback(locale));
}
+7 -1
View File
@@ -27,6 +27,8 @@ export type DomainEvent =
| { type: 'chat.assigned'; threadId: string; assigneeId: string; byAdminId: string }
/** срок задачи продажника — напоминание в рабочий бот */
| { type: 'sales.taskDue'; taskId: string }
/** рассылка клиентам запущена — воркер отправляет */
| { type: 'broadcast.start'; broadcastId: string }
| { type: 'lead.submitted'; leadId: string }
| { type: 'clientRequest.created'; requestId: string }
| { type: 'webRequest.created'; requestId: string };
@@ -88,7 +90,11 @@ export type DomainErrorCode =
| 'ALREADY_CONVERTED'
| 'AGENCY_CODE_FORMAT'
| 'AGENCY_CODE_TAKEN'
| 'REASON_REQUIRED';
| 'REASON_REQUIRED'
| 'PROMO_INVALID'
| 'PROMO_EXPIRED'
| 'PROMO_USED'
| 'PROMO_NOT_FOR_SERVICE';
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
export const EVENTS_QUEUE = 'barev-events';
+2
View File
@@ -16,4 +16,6 @@ export * from './clientRequests.ts';
export * from './concierge.ts';
export * from './chats.ts';
export * from './sales.ts';
export * from './texts.ts';
export * from './promo.ts';
export * from './partners.ts';
+17 -5
View File
@@ -25,14 +25,19 @@ import { getServiceByCode, type ParsedService } from './catalog.ts';
import { getMembership } from './clients.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
import { findPromo } from './promo.ts';
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>) {
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>, promoCode?: string | null) {
const service = await getServiceByCode(ctx.db, serviceCode, { activeOnly: true });
const params = validateParams(service.paramsSchema, rawParams);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
const membership = await getMembership(ctx.db, clientId, ctx.now());
const q = quote(service.pricing, params.values, discountFor(membership.level, service.categoryDiscountPct, service.categoryEliteDiscountPct));
return { service, params: params.values, quote: q, membership };
const memberPct = discountFor(membership.level, service.categoryDiscountPct, service.categoryEliteDiscountPct);
// промокод не складывается со скидкой подписки — берём большую
const promo = promoCode?.trim() ? await findPromo(ctx.db, { code: promoCode, clientId, category: service.category, now: ctx.now() }) : null;
const promoWins = !!promo && promo.discountPct! > memberPct;
const q = quote(service.pricing, params.values, promoWins ? promo!.discountPct! : memberPct);
return { service, params: params.values, quote: q, membership, promo: promoWins ? promo : null };
}
export async function availableSlots(ctx: Ctx, input: { serviceCode: string; params: Record<string, unknown>; dateKey: string; excludeOrderId?: string }) {
@@ -78,13 +83,15 @@ export interface CreateOrderInput {
createdBy?: { type: 'ADMIN'; id: string };
/** оператор ставит время, даже если по графику все заняты (договорился с исполнителем сам) */
allowBusySlot?: boolean;
/** промокод на скидку (применяется, если выгоднее скидки подписки) */
promoCode?: string | null;
}
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: input.clientId }, include: { user: true } });
if (!client.consentAt) throw new DomainError('CONSENT_REQUIRED');
const { service, params, quote: q } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params);
const { service, params, quote: q, promo } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params, input.promoCode);
if (!service.isOnline && (!input.addressText?.trim() || !input.districtId)) throw new DomainError('ADDRESS_REQUIRED');
const flow = getFlow(service.flow);
@@ -95,7 +102,7 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
if (!input.allowBusySlot) await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
else if (input.scheduledAt <= ctx.now()) throw new DomainError('SLOT_UNAVAILABLE');
const checklist = await tx.checklistItem.findMany({ where: { serviceId: service.id }, orderBy: { sortOrder: 'asc' } });
return tx.order.create({
const created = await tx.order.create({
data: {
clientId: input.clientId,
serviceId: service.id,
@@ -123,6 +130,11 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
history: { create: input.createdBy ? { toStatus: flow.initial, actorType: 'ADMIN', actorId: input.createdBy.id, event: 'createdByStaff' } : { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
},
});
if (promo) {
await tx.promoCodeUse.create({ data: { promoId: promo.id, clientId: input.clientId, orderId: created.id } });
await tx.promoCode.update({ where: { id: promo.id }, data: { usedCount: { increment: 1 } } });
}
return created;
},
{ isolationLevel: 'ReadCommitted' },
);
+25
View File
@@ -0,0 +1,25 @@
/**
* Промокоды (этап 5): скидка на заказ. Со скидкой подписки не складывается — действует большая.
* Один код — один раз на клиента; срок, лимит использований и направления задаются в админке.
*/
import type { CategoryCode } from '@barev/core';
import type { Db, Tx } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
export const normalizePromo = (code: string) => code.trim().toUpperCase().replace(/\s+/g, '');
export async function findPromo(db: Db | Tx, input: { code: string; clientId: string; category: CategoryCode; now: Date }) {
const promo = await db.promoCode.findUnique({ where: { code: normalizePromo(input.code) } });
if (!promo || !promo.active || promo.kind !== 'DISCOUNT' || !promo.discountPct) throw new DomainError('PROMO_INVALID');
if ((promo.validFrom && promo.validFrom > input.now) || (promo.validUntil && promo.validUntil < input.now)) throw new DomainError('PROMO_EXPIRED');
if (promo.maxUses != null && promo.usedCount >= promo.maxUses) throw new DomainError('PROMO_EXPIRED');
if (promo.categories.length && !promo.categories.includes(input.category)) throw new DomainError('PROMO_NOT_FOR_SERVICE');
if (await db.promoCodeUse.findUnique({ where: { promoId_clientId: { promoId: promo.id, clientId: input.clientId } } })) throw new DomainError('PROMO_USED');
return promo;
}
/** Проверка кода для формы заказа: какую скидку он даст */
export async function checkPromo(ctx: Ctx, input: { code: string; clientId: string; category: CategoryCode }) {
const p = await findPromo(ctx.db, { ...input, now: ctx.now() });
return { code: p.code, discountPct: p.discountPct! };
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Тексты из админки (TextOverride) поверх переводов: процессы с доступом к БД (API, боты, воркер)
* подгружают их при старте и обновляют раз в минуту.
*/
import { setTextOverrides } from '@barev/i18n';
import type { Db } from '@barev/db';
/** Ключи, которые можно менять из админки: тексты услуг, доп. услуг, чек-листов и параметров заказа */
export const EDITABLE_TEXT_PREFIXES = ['services.', 'extras.', 'checklist.', 'params.'];
export const isEditableTextKey = (key: string) => EDITABLE_TEXT_PREFIXES.some((p) => key.startsWith(p)) && key.length <= 120;
export async function loadTextOverrides(db: Db) {
const rows = await db.textOverride.findMany({ select: { key: true, locale: true, value: true } });
setTextOverrides(rows);
return rows;
}
let timer: ReturnType<typeof setInterval> | null = null;
export function startTextOverrides(db: Db, everyMs = 60_000) {
const run = () => loadTextOverrides(db).catch((e) => console.warn('[texts] не удалось загрузить правки текстов', e));
void run();
if (!timer) {
timer = setInterval(run, everyMs);
timer.unref?.();
}
}
+78
View File
@@ -0,0 +1,78 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { createT } from '@barev/i18n';
import { createOrder, DomainError, giveConsent, grantSubscription, loadTextOverrides, quoteForClient, startClient } from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
let fx: Awaited<ReturnType<typeof fixtures>>;
beforeEach(async () => {
fx = await fixtures();
});
afterAll(async () => {
await db.textOverride.deleteMany({});
await loadTextOverrides(db);
await db.$disconnect();
});
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
const params = { cleaningType: 'regular', rooms: 2 };
async function client(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
const s = await startClient(ctx, tgUser(tg));
await giveConsent(ctx, s.clientId);
return s.clientId;
}
describe('промокоды', () => {
it('скидка по коду; один раз на клиента; не складывается с подпиской — берётся большая', async () => {
const { ctx } = makeCtx(NOW);
await db.promoCode.create({ data: { code: 'AUTUMN20', kind: 'DISCOUNT', discountPct: 20 } });
await db.promoCode.create({ data: { code: 'SMALL5', kind: 'DISCOUNT', discountPct: 5 } });
const c = await client(ctx, 901);
const q = await quoteForClient(ctx, c, 'cleaning', params, 'autumn20');
expect(q.quote).toMatchObject({ basePrice: 19000, discountPct: 20, totalPrice: 15200 });
expect(q.promo?.code).toBe('AUTUMN20');
const o = await createOrder(ctx, { clientId: c, serviceCode: 'cleaning', params, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, promoCode: 'AUTUMN20' });
expect(o.totalPrice).toBe(15200);
expect((await db.promoCode.findUniqueOrThrow({ where: { code: 'AUTUMN20' } })).usedCount).toBe(1);
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'AUTUMN20'))).toBe('PROMO_USED');
// подписчик со скидкой 10%: код на 5% не нужен — остаётся скидка подписки, код не тратится
const member = await client(ctx, 902);
await grantSubscription(ctx, { clientId: member, plan: 'BAREV_PLUS', days: 30 });
const m = await quoteForClient(ctx, member, 'cleaning', params, 'SMALL5');
expect(m.quote.discountPct).toBe(10);
expect(m.promo).toBeNull();
});
it('выключен, истёк, исчерпан или не для этого направления — понятная ошибка', async () => {
const { ctx } = makeCtx(NOW);
const c = await client(ctx, 903);
await db.promoCode.create({ data: { code: 'OFF', kind: 'DISCOUNT', discountPct: 10, active: false } });
await db.promoCode.create({ data: { code: 'OLD', kind: 'DISCOUNT', discountPct: 10, validUntil: new Date(NOW.getTime() - 1000) } });
await db.promoCode.create({ data: { code: 'FULL', kind: 'DISCOUNT', discountPct: 10, maxUses: 1, usedCount: 1 } });
await db.promoCode.create({ data: { code: 'DOCSONLY', kind: 'DISCOUNT', discountPct: 10, categories: ['DOCS'] } });
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'NOPE'))).toBe('PROMO_INVALID');
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'OFF'))).toBe('PROMO_INVALID');
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'OLD'))).toBe('PROMO_EXPIRED');
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'FULL'))).toBe('PROMO_EXPIRED');
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'DOCSONLY'))).toBe('PROMO_NOT_FOR_SERVICE');
});
});
describe('тексты из админки', () => {
it('правка подменяет стандартный текст, свой перевод языка важнее русской правки', async () => {
const before = createT('ru')('services.cleaning.name');
await db.textOverride.create({ data: { key: 'services.cleaning.name', locale: 'ru', value: 'Уборка квартиры' } });
await db.textOverride.create({ data: { key: 'services.cleaning.name', locale: 'hy', value: 'Բնակարանի մաքրում' } });
await loadTextOverrides(db);
expect(createT('ru')('services.cleaning.name')).toBe('Уборка квартиры');
expect(createT('hy')('services.cleaning.name')).toBe('Բնակարանի մաքրում');
// у английского свой перевод — русская правка его не трогает
expect(createT('en')('services.cleaning.name')).not.toBe('Уборка квартиры');
await db.textOverride.deleteMany({});
await loadTextOverrides(db);
expect(createT('ru')('services.cleaning.name')).toBe(before);
});
});
+22 -2
View File
@@ -1,5 +1,5 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { createT, formatAmd, formatDateTime, isLocale, type Locale, type TFunction } from '@barev/i18n';
import { createT, formatAmd, formatDateTime, isLocale, onTextOverrides, setTextOverrides, textOverridesVersion, type Locale, type TFunction } from '@barev/i18n';
import { reportClientError } from './telegram.ts';
interface I18nValue {
@@ -26,6 +26,12 @@ export function I18nProvider({ initial, children }: { initial: Locale; children:
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
// тексты из админки подгрузились — перерисовать
const [version, setVersion] = useState(textOverridesVersion());
useEffect(() => {
const off = onTextOverrides(() => setVersion(textOverridesVersion()));
return () => void off();
}, []);
const value = useMemo<I18nValue>(
() => ({
locale,
@@ -34,7 +40,8 @@ export function I18nProvider({ initial, children }: { initial: Locale; children:
amd: (n) => formatAmd(locale, n),
dt: (d, opts) => formatDateTime(locale, d, opts),
}),
[locale],
// eslint-disable-next-line react-hooks/exhaustive-deps
[locale, version],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
@@ -56,3 +63,16 @@ export function errorText(t: TFunction, err: unknown): string {
}
return t('errors.generic');
}
/** Тексты, изменённые в админке (названия и описания услуг и т.п.) — подгрузить с API при старте Mini App */
export async function loadTextOverrides(url = '/trpc/public.texts') {
try {
const res = await fetch(url);
if (!res.ok) return;
const body = (await res.json()) as { result?: { data?: { json?: { key: string; locale: string; value: string }[] } } };
const rows = body.result?.data?.json;
if (rows) setTextOverrides(rows);
} catch {
// нет сети — остаются стандартные тексты
}
}