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

+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?.();
}
}