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
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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' },
|
||||
);
|
||||
|
||||
@@ -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! };
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user