Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1

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 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+8 -2
View File
@@ -4,7 +4,10 @@
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./queue": "./src/queue.ts",
"./env": "./src/env.ts",
"./telegram": "./src/telegram.ts"
},
"scripts": {
"test": "vitest run",
@@ -12,7 +15,10 @@
},
"dependencies": {
"@barev/core": "workspace:*",
"@barev/db": "workspace:*"
"@barev/db": "workspace:*",
"bullmq": "^5.81.5",
"dotenv": "^17.0.0",
"@barev/i18n": "workspace:*"
},
"devDependencies": {
"dotenv": "^17.0.0",
+4 -1
View File
@@ -16,6 +16,7 @@ export interface ParsedService {
code: string;
category: CategoryCode;
categoryDiscountPct: number;
categoryEliteDiscountPct: number;
flow: Service['flow'];
providerRole: Service['providerRole'];
pricing: Pricing;
@@ -27,7 +28,7 @@ export interface ParsedService {
fromPrice: number;
}
type ServiceWithCategory = Service & { category: { code: CategoryCode; barevPlusDiscountPct: number } };
type ServiceWithCategory = Service & { category: { code: CategoryCode; barevPlusDiscountPct: number; eliteDiscountPct: number } };
export function parseService(s: ServiceWithCategory): ParsedService {
const pricing = pricingSchema.parse(s.pricing);
@@ -36,6 +37,7 @@ export function parseService(s: ServiceWithCategory): ParsedService {
code: s.code,
category: s.category.code,
categoryDiscountPct: s.category.barevPlusDiscountPct,
categoryEliteDiscountPct: s.category.eliteDiscountPct,
flow: s.flow,
providerRole: s.providerRole,
pricing,
@@ -57,6 +59,7 @@ export async function listCatalog(db: Db) {
return categories.map((c) => ({
code: c.code,
barevPlusDiscountPct: c.barevPlusDiscountPct,
eliteDiscountPct: c.eliteDiscountPct,
services: c.services.map((s) => {
const p = parseService(s);
return { code: p.code, flow: p.flow, fromPrice: p.fromPrice, active: p.active, isOnline: p.isOnline };
+71
View File
@@ -0,0 +1,71 @@
import type { ClientRequestKind, ClientRequestStatus, SubscriptionPlan } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
const DAY = 24 * 3600_000;
/**
* Заявка из приложения оператору: подписка (онлайн-оплаты пока нет — оператор договаривается и включает)
* или «перезвоните мне» по услуге, которую ещё нельзя заказать с выбором времени.
* Повторная заявка на ту же подписку или услугу, пока первая открыта, не создаётся.
*/
export async function createClientRequest(
ctx: Ctx,
clientId: string,
input: { kind: ClientRequestKind; plan?: SubscriptionPlan; serviceCode?: string; comment?: string },
) {
if (input.kind === 'SUBSCRIPTION' && !input.plan) throw new DomainError('INVALID_PARAMS');
if (input.kind === 'SERVICE') {
if (!input.serviceCode) throw new DomainError('INVALID_PARAMS');
const service = await ctx.db.service.findUnique({ where: { code: input.serviceCode } });
if (!service) throw new DomainError('NOT_FOUND');
}
const open = await ctx.db.clientRequest.findFirst({
where: { clientId, status: 'NEW', kind: input.kind, plan: input.plan ?? null, serviceCode: input.serviceCode ?? null },
});
if (open) return open;
const request = await ctx.db.clientRequest.create({
data: { clientId, kind: input.kind, plan: input.plan, serviceCode: input.serviceCode, comment: input.comment?.trim() || undefined },
});
await ctx.notify.emit({ type: 'clientRequest.created', requestId: request.id });
return request;
}
/** Открытые заявки клиента — приложение показывает «заявка отправлена» */
export function openClientRequests(ctx: Ctx, clientId: string) {
return ctx.db.clientRequest.findMany({ where: { clientId, status: 'NEW' }, orderBy: { createdAt: 'desc' } });
}
export function listClientRequests(ctx: Ctx, input: { status?: ClientRequestStatus }) {
return ctx.db.clientRequest.findMany({
where: input.status ? { status: input.status } : {},
include: { client: { include: { user: { select: { firstName: true, lastName: true, username: true, phone: true, locale: true } } } } },
orderBy: { createdAt: 'desc' },
take: 200,
});
}
export async function updateClientRequest(ctx: Ctx, input: { id: string; status?: ClientRequestStatus; adminNote?: string }) {
return ctx.db.clientRequest.update({
where: { id: input.id },
data: { status: input.status, adminNote: input.adminNote === undefined ? undefined : input.adminNote.trim() || null },
});
}
/**
* Включить подписку вручную на N дней. Тот же план продлевается с даты окончания;
* переход на другой план начинается сразу (Elite важнее Barev+, пока обе активны).
*/
export async function grantSubscription(ctx: Ctx, input: { clientId: string; plan: SubscriptionPlan; days: number; requestId?: string }) {
const now = ctx.now();
const current = await ctx.db.subscription.findFirst({
where: { clientId: input.clientId, plan: input.plan, status: 'ACTIVE', endsAt: { gt: now } },
orderBy: { endsAt: 'desc' },
});
const startsAt = current?.endsAt ?? now;
const sub = await ctx.db.subscription.create({
data: { clientId: input.clientId, plan: input.plan, source: 'MANUAL', startsAt, endsAt: new Date(startsAt.getTime() + input.days * DAY) },
});
if (input.requestId) await ctx.db.clientRequest.update({ where: { id: input.requestId }, data: { status: 'DONE' } });
await ctx.notify.emit({ type: 'subscription.activated', subscriptionId: sub.id });
return sub;
}
+87
View File
@@ -0,0 +1,87 @@
import { DomainError, type Ctx } from './context.ts';
/** С кем переписка: клиент (клиентский бот) или исполнитель (рабочий бот) */
export type Peer = { clientId: string; providerId?: undefined } | { providerId: string; clientId?: undefined };
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId });
async function incoming(ctx: Ctx, peer: Peer, text: string) {
const body = text.trim().slice(0, 4000);
if (!body) return null;
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: peer.clientId ? 'CLIENT' : 'PROVIDER', text: body } });
await ctx.notify.emit({ type: 'concierge.message', messageId: msg.id });
return msg;
}
/** Сообщение клиента боту — в чат консьержа в админке и уведомление операторам */
export const addClientMessage = (ctx: Ctx, clientId: string, text: string) => incoming(ctx, { clientId }, text);
/** Сообщение исполнителя рабочему боту */
export const addProviderMessage = (ctx: Ctx, providerId: string, text: string) => incoming(ctx, { providerId }, text);
/** Список диалогов для оператора: последнее сообщение и сколько непрочитанных, свежие сверху */
export async function listConversations(ctx: Ctx) {
const [last, unread] = await Promise.all([
ctx.db.conciergeMessage.findMany({
distinct: ['clientId', 'providerId'],
orderBy: [{ clientId: 'asc' }, { providerId: 'asc' }, { createdAt: 'desc' }],
include: {
client: { include: { user: { select: { firstName: true, lastName: true, username: true, locale: true } } } },
provider: { include: { user: { select: { username: true, locale: true } } } },
},
}),
ctx.db.conciergeMessage.groupBy({ by: ['clientId', 'providerId'], where: { author: { not: 'OPERATOR' }, readAt: null }, _count: true }),
]);
const key = (m: { clientId: string | null; providerId: string | null }) => (m.clientId ? `c:${m.clientId}` : `p:${m.providerId}`);
const unreadBy = new Map(unread.map((u) => [key(u), u._count]));
return last
.map((m) => ({
key: key(m),
clientId: m.clientId,
providerId: m.providerId,
name: m.client ? `${m.client.user.firstName ?? ''} ${m.client.user.lastName ?? ''}`.trim() : (m.provider?.name ?? ''),
username: m.client?.user.username ?? m.provider?.user?.username ?? null,
locale: m.client?.user.locale ?? m.provider?.user?.locale ?? 'ru',
segment: m.client?.segment ?? null,
providerRole: m.provider?.role ?? null,
last: { text: m.text, author: m.author, createdAt: m.createdAt },
unread: unreadBy.get(key(m)) ?? 0,
}))
.sort((a, b) => b.last.createdAt.getTime() - a.last.createdAt.getTime());
}
/** Переписка; открыл — входящие прочитаны. Пустая — можно написать первым */
export async function conversation(ctx: Ctx, peer: Peer) {
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
return ctx.db.conciergeMessage.findMany({ where: peerWhere(peer), orderBy: { createdAt: 'asc' }, take: 500 });
}
/** Кто это — для заголовка нового диалога, который оператор начинает первым */
export async function peerInfo(ctx: Ctx, peer: Peer) {
if (peer.clientId) {
const c = await ctx.db.client.findUniqueOrThrow({ where: { id: peer.clientId }, include: { user: true } });
return { name: `${c.user.firstName ?? ''} ${c.user.lastName ?? ''}`.trim(), username: c.user.username, locale: c.user.locale, reachable: true };
}
const p = await ctx.db.provider.findUniqueOrThrow({ where: { id: peer.providerId! }, include: { user: true } });
return { name: p.name, username: p.user?.username ?? null, locale: p.user?.locale ?? 'ru', reachable: !!p.user };
}
export function unreadConciergeCount(ctx: Ctx) {
return ctx.db.conciergeMessage.count({ where: { author: { not: 'OPERATOR' }, readAt: null } });
}
/** Ответ оператора: сохраняем и отправляем в чат с ботом (воркер) */
export async function replyTo(ctx: Ctx, input: Peer & { adminUserId: string; text: string }) {
const peer: Peer = input.clientId ? { clientId: input.clientId } : { providerId: input.providerId! };
if (peer.providerId) {
const p = await ctx.db.provider.findUnique({ where: { id: peer.providerId } });
// исполнитель ещё не открыл рабочий бот по инвайту — написать ему некуда
if (!p?.userId) throw new DomainError('PROVIDER_NOT_LINKED');
}
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: 'OPERATOR', adminUserId: input.adminUserId, text: input.text.trim().slice(0, 4000) } });
// ответили — входящие этого диалога прочитаны
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
await ctx.notify.emit({ type: 'concierge.reply', messageId: msg.id });
return msg;
}
/** Совместимость: ответ клиенту */
export const replyToClient = (ctx: Ctx, input: { clientId: string; adminUserId: string; text: string }) => replyTo(ctx, input);
+23 -2
View File
@@ -14,7 +14,18 @@ export type DomainEvent =
| { type: 'offer.expired'; offerId: string }
| { type: 'company.order'; orderId: string; companyId: string }
| { type: 'order.reminder'; orderId: string; hours: 24 | 1 }
| { type: 'gift.activated'; clientId: string; endsAt: string };
| { type: 'gift.activated'; clientId: string; endsAt: string }
/** оператор сменил статус шага переезда — клиенту сообщение в бота */
| { type: 'lead.step'; stepId: string }
/** оператор ответил в чате консьержа — ответ уходит в чат с ботом */
| { type: 'concierge.reply'; messageId: string }
/** оператор включил подписку по заявке из приложения */
| { type: 'subscription.activated'; subscriptionId: string }
/** уведомления операторам в рабочий бот */
| { type: 'concierge.message'; messageId: string }
| { type: 'lead.submitted'; leadId: string }
| { type: 'clientRequest.created'; requestId: string }
| { type: 'webRequest.created'; requestId: string };
export interface Notifier {
emit(event: DomainEvent): Promise<void> | void;
@@ -59,4 +70,14 @@ export type DomainErrorCode =
| 'PROVIDER_BUSY'
| 'ALREADY_ASSIGNED'
| 'INVITE_INVALID'
| 'NOT_PAID_STATE';
| 'NOT_PAID_STATE'
| 'PHONE_INVALID'
| 'CONTACT_INVALID'
| 'NAME_REQUIRED'
| 'PROVIDER_NOT_LINKED'
| 'CARD_EXPIRED'
| 'OFFER_NOT_AVAILABLE'
| 'OFFER_LIMIT_REACHED';
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
export const EVENTS_QUEUE = 'barev-events';
+13
View File
@@ -0,0 +1,13 @@
import path from 'node:path';
import { config } from 'dotenv';
/** Загружает корневой .env для процессов монорепо (боты, воркер) */
export function loadRootEnv() {
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
}
export function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`${name} is not set`);
return v;
}
+7
View File
@@ -8,3 +8,10 @@ export * from './assignment.ts';
export * from './providers.ts';
export * from './queries.ts';
export * from './reminders.ts';
export * from './payouts.ts';
export * from './webRequests.ts';
export * from './leads.ts';
export * from './relocation.ts';
export * from './clientRequests.ts';
export * from './concierge.ts';
export * from './partners.ts';
+129
View File
@@ -0,0 +1,129 @@
import { randomBytes } from 'node:crypto';
import type { Locale, Utm } from '@barev/core';
import type { LeadContactType, LeadStatus, Prisma } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { getSettings } from './settings.ts';
import { ensureLeadDocs, syncLeadSteps } from './relocation.ts';
import { normalizePhone } from './webRequests.ts';
/**
* Контакт из квиза: телефон или Telegram. «@anna_k», «t.me/anna_k» и «anna_k» — Telegram,
* строка из цифр (с «+», пробелами, скобками) — телефон.
*/
export function normalizeContact(raw: string): { contact: string; contactType: LeadContactType } | null {
const s = raw.trim();
const tg = s.match(/^(?:https?:\/\/)?(?:t\.me\/|telegram\.me\/)?@?([a-zA-Z][a-zA-Z0-9_]{4,31})$/);
if (tg) return { contact: `@${tg[1]}`, contactType: 'TELEGRAM' };
const phone = normalizePhone(s);
return phone ? { contact: phone, contactType: 'PHONE' } : null;
}
export const LEAD_MOVE_WHEN = ['month', 'quarter', 'later', 'date'] as const;
export const LEAD_PACKAGES = ['CONSULT', 'TURNKEY', 'CUSTOM'] as const;
/** Конструктор пакета: что человек отметил. Бюджет отеля — драмы за сутки. */
export interface LeadOptions {
airport?: 'none' | 'standard' | 'premium';
stay?: 'none' | 'flat' | 'hotel';
stayBudgetFrom?: number;
stayBudgetTo?: number;
flat?: boolean;
socialCard?: boolean;
bankCard?: boolean;
registration?: boolean;
residence?: boolean;
}
export interface CreateLeadInput {
name: string;
contact: string;
locale: Locale;
agency?: string;
utm?: Utm;
}
/** Шаг 1 квиза: имя и контакт сохраняются сразу — даже если дальше человек закроет квиз, оператор ему перезвонит */
export async function createLead(ctx: Ctx, input: CreateLeadInput) {
const name = input.name.trim();
if (name.length < 1) throw new DomainError('NAME_REQUIRED');
const contact = normalizeContact(input.contact);
if (!contact) throw new DomainError('CONTACT_INVALID');
const settings = await getSettings(ctx.db);
return ctx.db.lead.create({
data: {
key: randomBytes(12).toString('base64url'),
name,
...contact,
locale: input.locale,
agency: input.agency,
utm: input.utm && Object.keys(input.utm).length ? (input.utm as Prisma.InputJsonValue) : undefined,
consentAt: ctx.now(),
consentVersion: settings['consent.version'],
},
});
}
export interface UpdateLeadInput {
id: string;
key: string;
moved?: boolean;
moveWhen?: (typeof LEAD_MOVE_WHEN)[number];
moveDate?: Date;
package?: (typeof LEAD_PACKAGES)[number];
options?: LeadOptions;
submit?: boolean;
}
/** Следующие шаги квиза дописывают ответы по ключу из шага 1; после отправки заявку меняет только оператор */
export async function updateLead(ctx: Ctx, input: UpdateLeadInput) {
const lead = await ctx.db.lead.findUnique({ where: { id: input.id } });
if (!lead || lead.key !== input.key) throw new DomainError('NOT_FOUND');
if (lead.submittedAt) return lead;
const moveDate = input.moveWhen === undefined ? undefined : input.moveWhen === 'date' ? (input.moveDate ?? null) : null;
const updated = await ctx.db.lead.update({
where: { id: lead.id },
data: {
moved: input.moved,
moveWhen: input.moveWhen,
moveDate,
package: input.package,
options: input.options as Prisma.InputJsonValue | undefined,
// «уже живу» — квиз на этом заканчивается, заявка тоже считается отправленной
submittedAt: input.submit || input.moved === true ? ctx.now() : undefined,
},
});
// отправленная заявка на переезд — сразу с планом по шагам для оператора
if (updated.submittedAt && !updated.moved) await syncLeadSteps(ctx.db, updated);
if (updated.submittedAt) await ctx.notify.emit({ type: 'lead.submitted', leadId: updated.id });
return updated;
}
/** Для админки: свежие сверху */
export async function listLeads(ctx: Ctx, input: { status?: LeadStatus }) {
// заявки, отправленные до появления шаблонов, получают план и документы при первом показе
const bare = await ctx.db.lead.findMany({ where: { submittedAt: { not: null }, moved: { not: true }, package: { not: null }, docs: { none: {} } } });
for (const l of bare) {
await syncLeadSteps(ctx.db, l);
await ensureLeadDocs(ctx.db, l.id);
}
return ctx.db.lead.findMany({
where: input.status ? { status: input.status } : {},
omit: { key: true },
include: {
steps: { orderBy: { sortOrder: 'asc' } },
docs: { orderBy: { sortOrder: 'asc' } },
client: { select: { id: true, user: { select: { username: true, firstName: true } } } },
},
orderBy: { createdAt: 'desc' },
take: 200,
});
}
/** Оператор ведёт заявку: статус и заметка для себя */
export async function updateLeadByAdmin(ctx: Ctx, input: { id: string; status?: LeadStatus; adminNote?: string }) {
return ctx.db.lead.update({
where: { id: input.id },
data: { status: input.status, adminNote: input.adminNote === undefined ? undefined : input.adminNote.trim() || null },
omit: { key: true },
});
}
+2 -1
View File
@@ -31,7 +31,7 @@ export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: st
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));
const q = quote(service.pricing, params.values, discountFor(membership.level, service.categoryDiscountPct, service.categoryEliteDiscountPct));
return { service, params: params.values, quote: q, membership };
}
@@ -105,6 +105,7 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
note: input.note?.trim() || undefined,
locale: client.user.locale,
basePrice: q.basePrice,
priceBreakdown: q.breakdown as unknown as Prisma.InputJsonValue,
discountPct: q.discountPct,
discountAmount: q.discountAmount,
totalPrice: q.totalPrice,
+223
View File
@@ -0,0 +1,223 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { fromYerevan, toYerevan, type MembershipLevel } from '@barev/core';
import type { OfferLimit, Prisma, SubscriptionPlan } from '@barev/db';
import { getMembership, upsertUser, type TelegramUserInput } from './clients.ts';
import { DomainError, type Ctx } from './context.ts';
/** QR клиента меняется каждую минуту; принимаем текущее окно и два предыдущих — запас на медленный скан */
const WINDOW_MS = 60_000;
const ACCEPT_WINDOWS = 2;
/** После проверки сотрудник может списать лимитное предложение в течение этого времени */
const REDEEM_MS = 15 * 60_000;
const INVITE_MS = 24 * 3600_000;
const secret = () => process.env.CARD_QR_SECRET || process.env.ADMIN_SESSION_SECRET || 'dev-card-secret';
const sign = (cardCode: string, w: number) => createHmac('sha256', secret()).update(`${cardCode}:${w}`).digest('hex').slice(0, 12);
/**
* Живой код клубной карты для QR: cd-<код карты>-<окно>-<подпись>.
* Помещается в payload Telegram (≤64 символа, [A-Za-z0-9_-]) — QR открывает рабочий бот обычной камерой.
*/
export function cardQrToken(cardCode: string, now: Date) {
const w = Math.floor(now.getTime() / WINDOW_MS);
return { token: `cd-${cardCode}-${w.toString(36)}-${sign(cardCode, w)}`, expiresAt: new Date((w + 1) * WINDOW_MS) };
}
export function verifyCardToken(token: string, now: Date): string | null {
const m = /^cd-([A-Z0-9]{4,16})-([a-z0-9]+)-([a-f0-9]{12})$/.exec(token.trim());
if (!m) return null;
const [, cardCode, w36, sig] = m;
const w = parseInt(w36!, 36);
const current = Math.floor(now.getTime() / WINDOW_MS);
if (w > current || current - w > ACCEPT_WINDOWS) return null;
const expected = Buffer.from(sign(cardCode!, w));
return timingSafeEqual(expected, Buffer.from(sig!)) ? cardCode! : null;
}
/** Достаём код из того, что вернул сканер: ссылка t.me/…?start=cd-… или сам код */
export function tokenFromScan(raw: string): string {
const m = /start=(cd-[A-Za-z0-9_-]+)/.exec(raw);
return m ? m[1]! : raw.trim();
}
const RANK: Record<MembershipLevel, number> = { BAREV: 0, BAREV_PLUS: 1, ELITE: 2 };
const planRank = (p: SubscriptionPlan | null) => (p === 'ELITE' ? 2 : p === 'BAREV_PLUS' ? 1 : 0);
/** Начало периода лимита по Еревану: ONCE — всё время, WEEK — с понедельника, MONTH — с 1-го числа */
export function limitPeriodStart(limit: OfferLimit, now: Date): Date | null {
if (limit === 'NONE') return null;
if (limit === 'ONCE') return new Date(0);
const p = toYerevan(now);
if (limit === 'MONTH') return fromYerevan(p.year, p.month, 1);
return new Date(fromYerevan(p.year, p.month, p.day).getTime() - p.weekday * 24 * 3600_000);
}
/** Партнёры, где пользователь — собственник или сотрудник */
export async function partnerMemberships(ctx: Ctx, telegramId: bigint) {
return ctx.db.partnerMember.findMany({ where: { user: { telegramId }, partner: { active: true } }, include: { partner: true } });
}
async function memberOf(ctx: Ctx, telegramId: bigint, partnerId?: string) {
const list = await partnerMemberships(ctx, telegramId);
const m = partnerId ? list.find((x) => x.partnerId === partnerId) : list[0];
if (!m) throw new DomainError('FORBIDDEN');
return m;
}
type Localized = Record<string, string>;
async function offersFor(ctx: Ctx, partnerId: string, clientId: string, level: MembershipLevel) {
const now = ctx.now();
const offers = await ctx.db.partnerOffer.findMany({ where: { partnerId, active: true }, orderBy: { sortOrder: 'asc' } });
return Promise.all(
offers.map(async (o) => {
const eligible = RANK[level] >= planRank(o.minLevel);
const since = limitPeriodStart(o.limit, now);
const used = since ? await ctx.db.cardUsage.count({ where: { clientId, offerId: o.id, createdAt: { gte: since } } }) : 0;
const quota = o.limit === 'ONCE' ? 1 : o.limitCount;
return {
id: o.id,
title: o.title as Localized,
minLevel: o.minLevel,
limit: o.limit,
limitCount: o.limitCount,
eligible,
/** сколько ещё можно списать в текущем периоде; null — без ограничений */
remaining: o.limit === 'NONE' ? null : Math.max(0, quota - used),
};
}),
);
}
/**
* Сотрудник партнёра отсканировал QR клиента: кто это, какой уровень, что положено.
* Каждая проверка записывается как визит; лимитные предложения списываются отдельно (redeemOffer).
*/
export async function checkCard(ctx: Ctx, input: { telegramId: bigint; token: string; partnerId?: string }) {
const member = await memberOf(ctx, input.telegramId, input.partnerId);
const cardCode = verifyCardToken(tokenFromScan(input.token), ctx.now());
if (!cardCode) throw new DomainError('CARD_EXPIRED');
const client = await ctx.db.client.findUnique({ where: { cardCode }, include: { user: true } });
if (!client?.consentAt) throw new DomainError('CARD_EXPIRED');
const membership = await getMembership(ctx.db, client.id, ctx.now());
const visit = await ctx.db.cardUsage.create({
data: { clientId: client.id, partnerId: member.partnerId, memberUserId: member.userId, level: membership.level },
});
return {
checkId: visit.id,
partner: { id: member.partnerId, name: member.partner.name },
client: { firstName: client.user.firstName ?? '', cardCode },
level: membership.level,
endsAt: membership.endsAt,
offers: await offersFor(ctx, member.partnerId, client.id, membership.level),
};
}
/** Списать лимитное предложение по недавней проверке */
export async function redeemOffer(ctx: Ctx, input: { telegramId: bigint; checkId: string; offerId: string }) {
const check = await ctx.db.cardUsage.findUnique({ where: { id: input.checkId } });
if (!check || check.offerId || !check.partnerId || ctx.now().getTime() - check.createdAt.getTime() > REDEEM_MS) throw new DomainError('CARD_EXPIRED');
const member = await memberOf(ctx, input.telegramId, check.partnerId);
const offers = await offersFor(ctx, member.partnerId, check.clientId, (check.level as MembershipLevel) ?? 'BAREV');
const offer = offers.find((o) => o.id === input.offerId);
if (!offer?.eligible) throw new DomainError('OFFER_NOT_AVAILABLE');
if (offer.remaining !== null && offer.remaining <= 0) throw new DomainError('OFFER_LIMIT_REACHED');
await ctx.db.cardUsage.create({ data: { clientId: check.clientId, partnerId: member.partnerId, offerId: offer.id, memberUserId: member.userId, level: check.level } });
return { ...offer, remaining: offer.remaining === null ? null : offer.remaining - 1 };
}
/** Кабинет партнёра: предложения, сотрудники (видит собственник), последние проверки */
export async function partnerCabinet(ctx: Ctx, telegramId: bigint, partnerId?: string) {
const member = await memberOf(ctx, telegramId, partnerId);
const isOwner = member.role === 'OWNER';
const since = new Date(ctx.now().getTime() - 30 * 24 * 3600_000);
const [offers, members, recent, visits30] = await Promise.all([
ctx.db.partnerOffer.findMany({ where: { partnerId: member.partnerId, active: true }, orderBy: { sortOrder: 'asc' } }),
isOwner ? ctx.db.partnerMember.findMany({ where: { partnerId: member.partnerId }, include: { user: true }, orderBy: { createdAt: 'asc' } }) : [],
ctx.db.cardUsage.findMany({
where: { partnerId: member.partnerId, ...(isOwner ? {} : { memberUserId: member.userId }) },
include: { client: { include: { user: { select: { firstName: true } } } }, offer: true },
orderBy: { createdAt: 'desc' },
take: 30,
}),
ctx.db.cardUsage.count({ where: { partnerId: member.partnerId, offerId: null, createdAt: { gte: since } } }),
]);
return {
partner: { id: member.partnerId, name: member.partner.name, address: member.partner.address },
role: member.role,
offers: offers.map((o) => ({ id: o.id, title: o.title as Localized, minLevel: o.minLevel, limit: o.limit, limitCount: o.limitCount })),
members: members.map((m) => ({ id: m.id, role: m.role, name: `${m.user.firstName ?? ''} ${m.user.lastName ?? ''}`.trim(), username: m.user.username, isMe: m.userId === member.userId })),
recent: recent.map((u) => ({ id: u.id, at: u.createdAt, client: u.client.user.firstName ?? '', level: u.level, offer: (u.offer?.title as Localized | undefined) ?? null })),
visits30,
};
}
const proBotUrl = (payload: string) => `https://t.me/${process.env.PRO_BOT_USERNAME ?? 'barev_pro_bot'}?start=${payload}`;
/** Собственник приглашает сотрудника: ссылка-QR на сутки, одноразовая */
export async function createStaffInvite(ctx: Ctx, telegramId: bigint, partnerId?: string) {
const member = await memberOf(ctx, telegramId, partnerId);
if (member.role !== 'OWNER') throw new DomainError('FORBIDDEN');
const code = randomBytes(9).toString('base64url').replace(/[-_]/g, 'x');
const invite = await ctx.db.partnerInvite.create({ data: { code, partnerId: member.partnerId, expiresAt: new Date(ctx.now().getTime() + INVITE_MS) } });
return { url: proBotUrl(`pi-${code}`), expiresAt: invite.expiresAt };
}
export async function removePartnerMember(ctx: Ctx, telegramId: bigint, memberId: string) {
const target = await ctx.db.partnerMember.findUniqueOrThrow({ where: { id: memberId } });
const me = await memberOf(ctx, telegramId, target.partnerId);
if (me.role !== 'OWNER' || target.role === 'OWNER') throw new DomainError('FORBIDDEN');
await ctx.db.partnerMember.delete({ where: { id: memberId } });
}
/** Ссылка для собственника — выдаёт админка; одноразовая */
export async function createOwnerInvite(ctx: Ctx, partnerId: string) {
const code = randomBytes(9).toString('base64url').replace(/[-_]/g, 'x');
await ctx.db.partner.update({ where: { id: partnerId }, data: { ownerInviteCode: code } });
return { url: proBotUrl(`po-${code}`) };
}
/** /start po-… (собственник) или pi-… (сотрудник) в рабочем боте */
export async function acceptPartnerInvite(ctx: Ctx, tg: TelegramUserInput, payload: string) {
const user = await upsertUser(ctx.db, tg);
if (payload.startsWith('po-')) {
const partner = await ctx.db.partner.findUnique({ where: { ownerInviteCode: payload.slice(3) } });
if (!partner) throw new DomainError('INVITE_INVALID');
await ctx.db.$transaction([
ctx.db.partnerMember.upsert({ where: { partnerId_userId: { partnerId: partner.id, userId: user.id } }, create: { partnerId: partner.id, userId: user.id, role: 'OWNER' }, update: { role: 'OWNER' } }),
ctx.db.partner.update({ where: { id: partner.id }, data: { ownerInviteCode: null } }),
]);
return { role: 'OWNER' as const, partnerName: partner.name };
}
const invite = await ctx.db.partnerInvite.findUnique({ where: { code: payload.slice(3) }, include: { partner: true } });
if (!invite || invite.usedAt || invite.expiresAt < ctx.now()) throw new DomainError('INVITE_INVALID');
await ctx.db.$transaction([
ctx.db.partnerMember.upsert({ where: { partnerId_userId: { partnerId: invite.partnerId, userId: user.id } }, create: { partnerId: invite.partnerId, userId: user.id, role: 'STAFF' }, update: {} }),
ctx.db.partnerInvite.update({ where: { id: invite.id }, data: { usedAt: ctx.now() } }),
]);
return { role: 'STAFF' as const, partnerName: invite.partner.name };
}
/** Каталог партнёров для клиента: предложения с отметкой «доступно на твоём уровне» и остатком по лимиту */
export async function clientPartners(ctx: Ctx, clientId: string) {
const membership = await getMembership(ctx.db, clientId, ctx.now());
const partners = await ctx.db.partner.findMany({ where: { active: true }, orderBy: [{ category: 'asc' }, { name: 'asc' }] });
return Promise.all(
partners.map(async (p) => ({
id: p.id,
name: p.name,
category: p.category,
address: p.address,
offer: p.offer as Localized,
offers: await offersFor(ctx, p.id, clientId, membership.level),
})),
);
}
export type PartnerOfferInput = { id?: string; partnerId: string; title: Localized; minLevel: SubscriptionPlan | null; limit: OfferLimit; limitCount: number; active: boolean };
export async function savePartnerOffer(ctx: Ctx, input: PartnerOfferInput) {
const data = { title: input.title as Prisma.InputJsonValue, minLevel: input.minLevel, limit: input.limit, limitCount: Math.max(1, input.limitCount), active: input.active };
if (input.id) return ctx.db.partnerOffer.update({ where: { id: input.id }, data });
const last = await ctx.db.partnerOffer.findFirst({ where: { partnerId: input.partnerId }, orderBy: { sortOrder: 'desc' } });
return ctx.db.partnerOffer.create({ data: { ...data, partnerId: input.partnerId, sortOrder: (last?.sortOrder ?? -1) + 1 } });
}
+65
View File
@@ -0,0 +1,65 @@
import {
defaultParams,
paramsSchemaSchema,
payoutByRules,
payoutFor,
payoutRulesSchema,
pricingSchema,
quote,
type ParamValues,
type PriceBreakdown,
type RateType,
} from '@barev/core';
type Rate = { type: RateType; value: number };
export interface PayoutProvider {
rateType: RateType;
rateValue: number;
company: { rateType: RateType; rateValue: number } | null;
payoutPreset: { rules: unknown } | null;
}
export interface PayoutOrder {
totalPrice: number;
basePrice: number;
params: unknown;
priceBreakdown: unknown;
service: { pricing: unknown; paramsSchema: unknown };
}
/** Разбивка цены заказа: сохранённая при создании или пересчитанная по параметрам (старые заказы) */
export function breakdownOf(o: PayoutOrder): PriceBreakdown | null {
if (o.priceBreakdown && typeof o.priceBreakdown === 'object') return o.priceBreakdown as PriceBreakdown;
try {
const schema = paramsSchemaSchema.parse(o.service.paramsSchema);
const params = { ...defaultParams(schema), ...(o.params as ParamValues) };
return quote(pricingSchema.parse(o.service.pricing), params).breakdown;
} catch {
return null;
}
}
/**
* PRO-8: выплата исполнителю за заказ.
* - сотрудник компании — договорная ставка компании (от итоговой цены);
* - исполнитель с пресетом (джун / мидл / прайват) — фикс или % по каждой строке цены;
* - иначе — индивидуальная ставка (фикс за заказ или % от итога).
*/
export function orderPayout(o: PayoutOrder, p: PayoutProvider): number {
if (p.company) return payoutFor(o.totalPrice, { type: p.company.rateType, value: p.company.rateValue });
if (p.payoutPreset) {
const rules = payoutRulesSchema.safeParse(p.payoutPreset.rules);
const b = breakdownOf(o);
if (rules.success && b) return payoutByRules(b, rules.data).total;
}
const rate: Rate = { type: p.rateType, value: p.rateValue };
return payoutFor(o.totalPrice, rate);
}
/** Описание ставки для экрана «История» исполнителя */
export function rateLabel(p: PayoutProvider & { payoutPreset: { name: string; rules: unknown } | null }) {
if (p.company) return { kind: 'company' as const, type: p.company.rateType, value: p.company.rateValue };
if (p.payoutPreset) return { kind: 'preset' as const, name: p.payoutPreset.name };
return { kind: 'own' as const, type: p.rateType, value: p.rateValue };
}
+11 -14
View File
@@ -1,4 +1,5 @@
import { averageRating, payoutFor, providerActions, startOfYerevanDay, getFlow, type ServiceFlow } from '@barev/core';
import { averageRating, providerActions, startOfYerevanDay, getFlow, type ServiceFlow } from '@barev/core';
import { orderPayout, rateLabel } from './payouts.ts';
import type { Db } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { upsertUser, type TelegramUserInput } from './clients.ts';
@@ -33,6 +34,7 @@ export async function proIdentity(db: Db, telegramId: bigint) {
include: {
provider: { include: { company: true } },
dispatcher: { include: { company: true } },
partnerMembers: { include: { partner: true } },
},
});
if (!user) return null;
@@ -41,6 +43,8 @@ export async function proIdentity(db: Db, telegramId: bigint) {
user,
provider,
companies: user.dispatcher.filter((d) => d.company.active).map((d) => d.company),
/** собственник или сотрудник партнёра клуба — проверяет QR клиентов */
partners: user.partnerMembers.filter((m) => m.partner.active).map((m) => ({ id: m.partnerId, name: m.partner.name, role: m.role })),
};
}
@@ -48,14 +52,9 @@ export async function setShift(db: Db, providerId: string, onShift: boolean) {
return db.provider.update({ where: { id: providerId }, data: { onShift } });
}
/** Ставка, по которой считается заработок: у сотрудника компании — ставка компании */
function rateOf(p: { rateType: 'FIXED' | 'PERCENT'; rateValue: number; company: { rateType: 'FIXED' | 'PERCENT'; rateValue: number } | null }) {
return p.company ? { type: p.company.rateType, value: p.company.rateValue } : { type: p.rateType, value: p.rateValue };
}
/** PRO-1/PRO-2: шапка и заказы на сегодня */
export async function proToday(ctx: Ctx, providerId: string) {
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true } });
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true, payoutPreset: true } });
const dayStart = startOfYerevanDay(ctx.now());
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const [orders, offers] = await Promise.all([
@@ -76,7 +75,6 @@ export async function proToday(ctx: Ctx, providerId: string) {
orderBy: { scheduledAt: 'asc' },
take: 20,
});
const rate = rateOf(provider);
const doneToday = orders.filter((o) => getFlow(o.service.flow as ServiceFlow).done.includes(o.status));
const current =
orders.find((o) => !getFlow(o.service.flow as ServiceFlow).done.includes(o.status) && o.status !== getFlow(o.service.flow as ServiceFlow).assigned) ??
@@ -91,7 +89,7 @@ export async function proToday(ctx: Ctx, providerId: string) {
rating: averageRating(provider.ratingSum, provider.ratingCount),
companyName: provider.company?.name ?? null,
},
earningsToday: doneToday.reduce((s, o) => s + payoutFor(o.totalPrice, rate), 0),
earningsToday: doneToday.reduce((s, o) => s + orderPayout(o, provider), 0),
currentOrderId: current?.id ?? null,
orders: orders.map((o) => ({ ...proOrderView(o), actions: providerActions(o.service.flow as ServiceFlow, o.status) })),
upcoming: upcoming.map(proOrderView),
@@ -153,19 +151,18 @@ export async function proOrder(ctx: Ctx, providerId: string, orderId: string) {
/** PRO-6: история и заработок за период */
export async function proHistory(ctx: Ctx, providerId: string, from: Date, to: Date) {
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true } });
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true, payoutPreset: true } });
const orders = await ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: from, lt: to } },
include: { service: true, district: true, client: { include: { user: true } } },
orderBy: { scheduledAt: 'desc' },
});
const rate = rateOf(provider);
const done = orders.filter((o) => getFlow(o.service.flow as ServiceFlow).done.includes(o.status));
return {
orders: orders.map((o) => ({ ...proOrderView(o), payout: getFlow(o.service.flow as ServiceFlow).done.includes(o.status) ? payoutFor(o.totalPrice, rate) : 0 })),
totalEarned: done.reduce((s, o) => s + payoutFor(o.totalPrice, rate), 0),
orders: orders.map((o) => ({ ...proOrderView(o), payout: getFlow(o.service.flow as ServiceFlow).done.includes(o.status) ? orderPayout(o, provider) : 0 })),
totalEarned: done.reduce((s, o) => s + orderPayout(o, provider), 0),
doneCount: done.length,
rate,
rate: rateLabel(provider),
};
}
+21
View File
@@ -0,0 +1,21 @@
import { Queue } from 'bullmq';
import { EVENTS_QUEUE, type DomainEvent, type Notifier } from './context.ts';
export function redisConnection(url: string) {
const u = new URL(url);
return { host: u.hostname, port: Number(u.port || 6379), password: u.password || undefined, maxRetriesPerRequest: null };
}
/** Notifier, который кладёт доменные события в очередь воркера */
export function createQueueNotifier(redisUrl: string): Notifier & { queue: Queue<DomainEvent> } {
const queue = new Queue<DomainEvent>(EVENTS_QUEUE, {
connection: redisConnection(redisUrl),
defaultJobOptions: { attempts: 5, backoff: { type: 'exponential', delay: 2000 }, removeOnComplete: 1000, removeOnFail: 5000 },
});
return {
queue,
async emit(event) {
await queue.add(event.type, event);
},
};
}
+209
View File
@@ -0,0 +1,209 @@
import { randomBytes } from 'node:crypto';
import type { ClientSegment, Db, LeadStepStatus, Prisma, RelocationItemKind, Tx } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import type { LeadOptions, UpdateLeadInput } from './leads.ts';
import { getSettings } from './settings.ts';
/** Ответы конструктора квиза, которые превращаются в шаги с тем же кодом */
const OPTION_STEPS = ['airport', 'stay', 'flat', 'registration', 'socialCard', 'bankCard', 'residence'] as const;
type Localized = Record<string, string>;
export interface RelocationItemInput {
id?: string;
kind: RelocationItemKind;
name: Localized;
hint?: Localized | null;
active: boolean;
inTurnkey?: boolean;
}
/** Какие шаги нужны по пакету: консультация — один шаг, «под ключ» — шаблоны с пометкой, конструктор — отмеченные */
export function stepCodesFor(pkg: string | null, options: LeadOptions | null, templates: { code: string; inTurnkey: boolean }[]): string[] {
const codes = new Set(templates.map((t) => t.code));
if (pkg === 'CONSULT') return codes.has('consult') ? ['consult'] : [];
if (pkg === 'TURNKEY') return templates.filter((t) => t.inTurnkey && t.code !== 'consult').map((t) => t.code);
if (pkg !== 'CUSTOM' || !options) return [];
return OPTION_STEPS.filter((c) => {
if (!codes.has(c)) return false;
if (c === 'airport') return !!options.airport && options.airport !== 'none';
if (c === 'stay') return !!options.stay && options.stay !== 'none';
return !!options[c];
});
}
const templatesOf = (db: Db | Tx, kind: RelocationItemKind) => db.relocationItem.findMany({ where: { kind, active: true }, orderBy: { sortOrder: 'asc' } });
/** Создаёт недостающие шаги плана по шаблонам; уже начатые оператором не трогает */
export async function syncLeadSteps(db: Db | Tx, lead: { id: string; package: string | null; options: Prisma.JsonValue }) {
const templates = await templatesOf(db, 'STEP');
const codes = stepCodesFor(lead.package, lead.options as LeadOptions | null, templates);
if (!codes.length) return;
const order = new Map(templates.map((t) => [t.code, t.sortOrder]));
await db.leadStep.createMany({ data: codes.map((code) => ({ leadId: lead.id, code, sortOrder: order.get(code) ?? 0 })), skipDuplicates: true });
}
/** Чек-лист документов клиента — копия общего списка при первом обращении, дальше живёт своей жизнью */
export async function ensureLeadDocs(db: Db | Tx, leadId: string) {
if (await db.leadDoc.count({ where: { leadId } })) return;
const templates = await templatesOf(db, 'DOC');
await db.leadDoc.createMany({ data: templates.map((t) => ({ leadId, code: t.code, sortOrder: t.sortOrder })), skipDuplicates: true });
}
export async function setSegment(ctx: Ctx, clientId: string, segment: ClientSegment) {
return ctx.db.client.update({ where: { id: clientId }, data: { segment } });
}
const leadInclude = { steps: { orderBy: { sortOrder: 'asc' } }, docs: { orderBy: { sortOrder: 'asc' } } } satisfies Prisma.LeadInclude;
/** Названия шагов и документов по коду (для своих шагов клиента — title в самой записи) */
export async function relocationNames(db: Db | Tx) {
const items = await db.relocationItem.findMany();
return Object.fromEntries(items.map((i) => [`${i.kind}:${i.code}`, { name: i.name as Localized, hint: (i.hint as Localized | null) ?? null }]));
}
/**
* План переезда клиента: последняя заявка из приложения или с сайта.
* Заявку с сайта узнаём по нику Telegram из квиза — при первом открытии приложения она привязывается к клиенту.
*/
export async function clientRelocation(ctx: Ctx, clientId: string) {
let lead = await ctx.db.lead.findFirst({ where: { clientId }, orderBy: { createdAt: 'desc' } });
if (!lead) {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { user: true } });
const username = client.user.username;
if (!username) return null;
const site = await ctx.db.lead.findFirst({
where: { clientId: null, contactType: 'TELEGRAM', contact: { equals: `@${username}`, mode: 'insensitive' }, moved: { not: true } },
orderBy: { createdAt: 'desc' },
});
if (!site) return null;
lead = await ctx.db.lead.update({ where: { id: site.id }, data: { clientId } });
await syncLeadSteps(ctx.db, lead);
}
await ensureLeadDocs(ctx.db, lead.id);
return ctx.db.lead.findUniqueOrThrow({ where: { id: lead.id }, include: leadInclude });
}
export interface AppLeadInput {
moveWhen: NonNullable<UpdateLeadInput['moveWhen']>;
moveDate?: Date;
package: NonNullable<UpdateLeadInput['package']>;
options?: LeadOptions;
comment?: string;
}
/** Заявка на переезд из приложения: контакт — Telegram клиента, согласие уже дано в боте */
export async function createAppLead(ctx: Ctx, clientId: string, input: AppLeadInput) {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { user: true } });
if (!client.consentAt) throw new DomainError('CONSENT_REQUIRED');
if (input.package === 'CUSTOM' && stepCodesFor('CUSTOM', input.options ?? null, await templatesOf(ctx.db, 'STEP')).length === 0) throw new DomainError('INVALID_PARAMS');
const settings = await getSettings(ctx.db);
const lead = await ctx.db.lead.create({
data: {
key: randomBytes(12).toString('base64url'),
name: client.user.firstName ?? 'Telegram',
contact: client.user.username ? `@${client.user.username}` : (client.user.phone ?? `tg:${client.user.telegramId}`),
contactType: client.user.username || !client.user.phone ? 'TELEGRAM' : 'PHONE',
locale: client.user.locale,
moved: false,
moveWhen: input.moveWhen,
moveDate: input.moveWhen === 'date' ? (input.moveDate ?? null) : null,
package: input.package,
options: input.package === 'CUSTOM' ? (input.options as Prisma.InputJsonValue) : undefined,
adminNote: input.comment?.trim() || undefined,
submittedAt: ctx.now(),
consentAt: client.consentAt,
consentVersion: client.consentVersion ?? settings['consent.version'],
clientId,
},
});
await syncLeadSteps(ctx.db, lead);
await ensureLeadDocs(ctx.db, lead.id);
await ctx.notify.emit({ type: 'lead.submitted', leadId: lead.id });
return ctx.db.lead.findUniqueOrThrow({ where: { id: lead.id }, include: leadInclude });
}
/** Клиент отмечает документ из своего чек-листа */
export async function toggleRelocationDoc(ctx: Ctx, clientId: string, docId: string, checked: boolean) {
const doc = await ctx.db.leadDoc.findUnique({ where: { id: docId }, include: { lead: true } });
if (!doc || doc.lead.clientId !== clientId) throw new DomainError('NOT_FOUND');
return ctx.db.leadDoc.update({ where: { id: doc.id }, data: { checked } });
}
/** Оператор двигает шаг плана; при смене статуса или комментария клиенту уходит сообщение в бота */
export async function updateLeadStep(ctx: Ctx, input: { stepId: string; status?: LeadStepStatus; note?: string; title?: string }) {
const step = await ctx.db.leadStep.findUniqueOrThrow({ where: { id: input.stepId }, include: { lead: true } });
const updated = await ctx.db.leadStep.update({
where: { id: step.id },
data: {
status: input.status,
note: input.note === undefined ? undefined : input.note.trim() || null,
title: input.title === undefined ? undefined : input.title.trim() || null,
},
});
if (step.lead.clientId && ((input.status && input.status !== step.status) || (input.note !== undefined && updated.note !== step.note))) {
await ctx.notify.emit({ type: 'lead.step', stepId: step.id });
}
// оператор начал работу по шагам — заявка «в работе»
if (input.status && step.lead.status === 'NEW') await ctx.db.lead.update({ where: { id: step.leadId }, data: { status: 'IN_WORK' } });
return updated;
}
const customCode = () => `custom-${randomBytes(4).toString('hex')}`;
/** Добавить клиенту шаг: из общего списка (code) или свой (title) — в конец плана */
export async function addLeadStep(ctx: Ctx, input: { leadId: string; code?: string; title?: string }) {
const last = await ctx.db.leadStep.findFirst({ where: { leadId: input.leadId }, orderBy: { sortOrder: 'desc' } });
const title = input.title?.trim();
if (!input.code && !title) throw new DomainError('INVALID_PARAMS');
const exists = input.code ? await ctx.db.leadStep.findUnique({ where: { leadId_code: { leadId: input.leadId, code: input.code } } }) : null;
if (exists) return exists;
return ctx.db.leadStep.create({ data: { leadId: input.leadId, code: input.code ?? customCode(), title: input.code ? undefined : title, sortOrder: (last?.sortOrder ?? 0) + 1 } });
}
export const removeLeadStep = (ctx: Ctx, stepId: string) => ctx.db.leadStep.delete({ where: { id: stepId } });
/** Документы клиента: добавить из общего списка или свой, переименовать, отметить, удалить */
export async function addLeadDoc(ctx: Ctx, input: { leadId: string; code?: string; title?: string }) {
await ensureLeadDocs(ctx.db, input.leadId);
const last = await ctx.db.leadDoc.findFirst({ where: { leadId: input.leadId }, orderBy: { sortOrder: 'desc' } });
const title = input.title?.trim();
if (!input.code && !title) throw new DomainError('INVALID_PARAMS');
const exists = input.code ? await ctx.db.leadDoc.findUnique({ where: { leadId_code: { leadId: input.leadId, code: input.code } } }) : null;
if (exists) return exists;
return ctx.db.leadDoc.create({ data: { leadId: input.leadId, code: input.code ?? customCode(), title: input.code ? undefined : title, sortOrder: (last?.sortOrder ?? 0) + 1 } });
}
export function updateLeadDoc(ctx: Ctx, input: { docId: string; title?: string; checked?: boolean }) {
return ctx.db.leadDoc.update({
where: { id: input.docId },
data: { checked: input.checked, title: input.title === undefined ? undefined : input.title.trim() || null },
});
}
export const removeLeadDoc = (ctx: Ctx, docId: string) => ctx.db.leadDoc.delete({ where: { id: docId } });
/** Общий список шагов и документов для админки */
export const listRelocationItems = (ctx: Ctx) => ctx.db.relocationItem.findMany({ orderBy: [{ kind: 'asc' }, { sortOrder: 'asc' }] });
/** Сохранить пункт общего списка. Новым клиентам он попадёт в план; у текущих — добавляется вручную */
export async function saveRelocationItem(ctx: Ctx, input: RelocationItemInput) {
if (!input.name.ru?.trim()) throw new DomainError('INVALID_PARAMS');
const data = { name: input.name, hint: input.hint ?? undefined, active: input.active, inTurnkey: input.inTurnkey ?? input.kind === 'STEP' };
if (input.id) return ctx.db.relocationItem.update({ where: { id: input.id }, data });
const last = await ctx.db.relocationItem.findFirst({ where: { kind: input.kind }, orderBy: { sortOrder: 'desc' } });
return ctx.db.relocationItem.create({ data: { ...data, kind: input.kind, code: `c-${randomBytes(4).toString('hex')}`, sortOrder: (last?.sortOrder ?? -1) + 1 } });
}
/** Поднять / опустить пункт в общем списке */
export async function moveRelocationItem(ctx: Ctx, input: { id: string; dir: -1 | 1 }) {
const item = await ctx.db.relocationItem.findUniqueOrThrow({ where: { id: input.id } });
const neighbour = await ctx.db.relocationItem.findFirst({
where: { kind: item.kind, sortOrder: input.dir < 0 ? { lt: item.sortOrder } : { gt: item.sortOrder } },
orderBy: { sortOrder: input.dir < 0 ? 'desc' : 'asc' },
});
if (!neighbour) return;
await ctx.db.$transaction([
ctx.db.relocationItem.update({ where: { id: item.id }, data: { sortOrder: neighbour.sortOrder } }),
ctx.db.relocationItem.update({ where: { id: neighbour.id }, data: { sortOrder: item.sortOrder } }),
]);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Отправка сообщений через Telegram Bot API без зависимостей (воркер).
* Без токена (локальная разработка) сообщение печатается в консоль.
*/
export type InlineButton = { text: string; callback_data: string } | { text: string; web_app: { url: string } } | { text: string; url: string };
export interface OutgoingMessage {
chatId: bigint | number | string;
text: string;
buttons?: InlineButton[][];
}
/** Telegram принимает web_app-кнопки только с https — в разработке такие кнопки опускаются */
export function webAppButton(text: string, url: string): InlineButton | null {
return url.startsWith('https://') ? { text, web_app: { url } } : null;
}
export function compactRows(rows: (InlineButton | null)[][]): InlineButton[][] {
return rows.map((r) => r.filter((b): b is InlineButton => !!b)).filter((r) => r.length > 0);
}
export class TelegramSender {
constructor(
private token: string,
private label: string,
) {}
async send(msg: OutgoingMessage): Promise<void> {
if (!this.token) {
const buttons = msg.buttons?.flat().map((b) => `[${b.text}]`).join(' ') ?? '';
console.log(`[${this.label} → ${msg.chatId}] ${msg.text.replace(/<[^>]+>/g, '')} ${buttons}`);
return;
}
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
chat_id: String(msg.chatId),
text: msg.text,
parse_mode: 'HTML',
link_preview_options: { is_disabled: true },
reply_markup: msg.buttons?.length ? { inline_keyboard: msg.buttons } : undefined,
}),
});
if (!res.ok) {
const body = await res.text();
// 403 — пользователь заблокировал бота, 400 chat not found — бот ему ещё не писал
// (например, тестовые пользователи seed); повторять бессмысленно
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) {
console.warn(`[${this.label}] chat ${msg.chatId} unreachable: ${res.status}`);
return;
}
throw new Error(`Telegram ${res.status}: ${body}`);
}
}
}
export const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+162
View File
@@ -0,0 +1,162 @@
import { randomBytes } from 'node:crypto';
import { quote, validateParams, windowStarts, yerevanDateKey, type Locale, type Utm } from '@barev/core';
import type { Prisma, WebRequestStatus } from '@barev/db';
import { getServiceByCode } from './catalog.ts';
import { giveConsent } from './clients.ts';
import { DomainError, type Ctx } from './context.ts';
import { createOrder } from './orders.ts';
import { getSettings } from './settings.ts';
/** Телефон: цифры с необязательным «+», 8–15 цифр (E.164). Пробелы, скобки и дефисы убираем. */
export function normalizePhone(raw: string): string | null {
const s = raw.trim().replace(/[\s()\-.]/g, '');
if (!/^\+?\d{8,15}$/.test(s)) return null;
return s.startsWith('+') ? s : `+${s}`;
}
export interface CreateWebRequestInput {
serviceCode: string;
params: Record<string, unknown>;
preferredAt: Date;
districtCode: string;
addressText: string;
note?: string;
phone: string;
name?: string;
locale: Locale;
agency?: string;
utm?: Utm;
}
/**
* Заявка с сайта: цена и длительность считаются на сервере, время — в рабочем окне услуги.
* Вместе с заявкой создаётся StartToken: по ссылке t.me/<bot>?start=s-<token> бот узнаёт заявку,
* а startClient — язык, агентство и UTM.
*/
export async function createWebRequest(ctx: Ctx, input: CreateWebRequestInput) {
const service = await getServiceByCode(ctx.db, input.serviceCode, { activeOnly: true });
if (service.isOnline) throw new DomainError('SERVICE_UNAVAILABLE');
const params = validateParams(service.paramsSchema, input.params);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
const q = quote(service.pricing, params.values);
const phone = normalizePhone(input.phone);
if (!phone) throw new DomainError('PHONE_INVALID');
const addressText = input.addressText.trim();
const district = await ctx.db.district.findUnique({ where: { code: input.districtCode } });
if (!district || addressText.length < 3) throw new DomainError('ADDRESS_REQUIRED');
// длинная уборка может не поместиться в один день — тогда сверяем только начало, длительность согласует оператор
const window = service.slotConfig.dayEndMinute - service.slotConfig.dayStartMinute;
const starts = windowStarts({
dateKey: yerevanDateKey(input.preferredAt),
config: service.slotConfig,
durationMinutes: Math.min(q.durationMinutes, window),
now: ctx.now(),
});
if (!starts.some((s) => s.getTime() === input.preferredAt.getTime())) throw new DomainError('SLOT_UNAVAILABLE');
const settings = await getSettings(ctx.db);
const token = randomBytes(6).toString('base64url').replace(/[-_]/g, 'x');
const [, request] = await ctx.db.$transaction([
ctx.db.startToken.create({ data: { id: token, locale: input.locale, agency: input.agency, utm: input.utm } }),
ctx.db.webRequest.create({
data: {
token,
serviceId: service.id,
params: params.values as Prisma.InputJsonValue,
preferredAt: input.preferredAt,
durationMinutes: q.durationMinutes,
districtId: district.id,
addressText,
note: input.note?.trim() || undefined,
phone,
name: input.name?.trim() || undefined,
locale: input.locale,
totalPrice: q.totalPrice,
consentAt: ctx.now(),
consentVersion: settings['consent.version'],
},
}),
]);
await ctx.notify.emit({ type: 'webRequest.created', requestId: request.id });
return request;
}
type Gift = Awaited<ReturnType<typeof giveConsent>>;
export type LinkResult =
| { kind: 'none' }
| { kind: 'ordered'; requestNumber: number; orderNumber: number; scheduledAt: Date; gift?: Gift }
| { kind: 'linked'; requestNumber: number; gift?: Gift };
/**
* Человек открыл бота по ссылке заявки: привязываем заявку к клиенту, переносим согласие с сайта
* и пробуем сразу оформить заказ на желаемое время. Если время занято или исполнителей нет —
* заявка остаётся оператору (LINKED). Повторный /start по той же ссылке ничего не меняет.
*/
export async function linkWebRequest(ctx: Ctx, token: string, clientId: string): Promise<LinkResult> {
const request = await ctx.db.webRequest.findUnique({ where: { token }, include: { service: true, district: true } });
if (!request) return { kind: 'none' };
if (request.clientId && request.clientId !== clientId) return { kind: 'none' };
if (request.status === 'ORDERED' && request.orderId) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: request.orderId } });
return { kind: 'ordered', requestNumber: request.number, orderNumber: order.number, scheduledAt: order.scheduledAt };
}
if (request.status !== 'NEW') return { kind: 'linked', requestNumber: request.number };
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { user: true } });
// согласие дано на сайте; вместе с ним активируется подарок агентства, если положен
const gift = client.consentAt ? null : await giveConsent(ctx, clientId);
if (!client.user.phone) await ctx.db.user.update({ where: { id: client.userId }, data: { phone: request.phone } });
await ctx.db.webRequest.update({ where: { id: request.id }, data: { clientId, status: 'LINKED' } });
try {
const order = await createOrder(ctx, {
clientId,
serviceCode: request.service.code,
params: request.params as Record<string, unknown>,
scheduledAt: request.preferredAt,
addressText: request.addressText,
districtId: request.districtId,
note: request.note ?? undefined,
});
// заявка могла быть отменена оператором, пока создавался заказ, — тогда не перезаписываем статус
await ctx.db.webRequest.updateMany({ where: { id: request.id, status: 'LINKED' }, data: { status: 'ORDERED', orderId: order.id } });
return { kind: 'ordered', requestNumber: request.number, orderNumber: order.number, scheduledAt: order.scheduledAt, gift };
} catch (e) {
if (e instanceof DomainError) return { kind: 'linked', requestNumber: request.number, gift };
throw e;
}
}
/** Для админки: свежие сверху, по статусу */
export async function listWebRequests(ctx: Ctx, input: { status?: WebRequestStatus }) {
return ctx.db.webRequest.findMany({
where: input.status ? { status: input.status } : {},
include: {
service: { select: { code: true } },
district: { select: { code: true } },
client: { select: { id: true, user: { select: { firstName: true, lastName: true, username: true } } } },
order: { select: { id: true, number: true, status: true } },
},
orderBy: { createdAt: 'desc' },
take: 200,
});
}
/** Оператор отменяет заявку или возвращает её в работу; заметка — для себя (созвонились, перезвонить…) */
export async function updateWebRequest(ctx: Ctx, input: { id: string; status?: 'CANCELLED' | 'REOPEN'; adminNote?: string }) {
const request = await ctx.db.webRequest.findUniqueOrThrow({ where: { id: input.id } });
let status: WebRequestStatus | undefined;
if (input.status === 'CANCELLED') {
if (request.status === 'ORDERED') throw new DomainError('TRANSITION_NOT_ALLOWED');
status = 'CANCELLED';
} else if (input.status === 'REOPEN') {
if (request.status !== 'CANCELLED') throw new DomainError('TRANSITION_NOT_ALLOWED');
status = request.clientId ? 'LINKED' : 'NEW';
}
return ctx.db.webRequest.update({
where: { id: input.id },
data: { status, adminNote: input.adminNote === undefined ? undefined : input.adminNote.trim() || null },
});
}
+326 -1
View File
@@ -16,11 +16,46 @@ import {
providerAction,
quoteForClient,
respondToOffer,
orderPayout,
rescheduleByClient,
setChecklistItem,
startClient,
transitionOrder,
DomainError,
createWebRequest,
linkWebRequest,
normalizePhone,
updateWebRequest,
createLead,
normalizeContact,
updateLead,
updateLeadByAdmin,
addClientMessage,
clientRelocation,
conversation,
createAppLead,
createClientRequest,
getMembership,
grantSubscription,
listConversations,
replyToClient,
toggleRelocationDoc,
updateLeadStep,
addLeadDoc,
addLeadStep,
addProviderMessage,
replyTo,
saveRelocationItem,
acceptPartnerInvite,
cardQrToken,
checkCard,
clientPartners,
createOwnerInvite,
createStaffInvite,
partnerCabinet,
redeemOffer,
savePartnerOffer,
verifyCardToken,
} from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
@@ -106,7 +141,7 @@ describe('заказ (ORD-3…6)', () => {
const plus = await onboardedClient(ctx, 10, encodeStartPayload({ agency: 'DOMIK' }));
const plain = await onboardedClient(ctx, 11);
const p = { cleaningType: 'regular', rooms: 2 };
expect((await quoteForClient(ctx, plus, 'cleaning', p)).quote).toEqual({ basePrice: 19000, discountPct: 10, discountAmount: 1900, totalPrice: 17100, durationMinutes: 165 });
expect((await quoteForClient(ctx, plus, 'cleaning', p)).quote).toMatchObject({ basePrice: 19000, servicePrice: 19000, extras: [], discountPct: 10, discountAmount: 1900, totalPrice: 17100, durationMinutes: 165 });
expect((await quoteForClient(ctx, plain, 'cleaning', p)).quote.totalPrice).toBe(19000);
});
@@ -325,3 +360,293 @@ describe('оплата и напоминания', () => {
expect(events.filter((e) => e.type === 'order.reminder').map((e) => (e as { hours: number }).hours)).toEqual([24, 1]);
});
});
describe('выплаты клинеру по пресету', () => {
it('заказ хранит разбивку цены; заработок считается по строкам пресета', async () => {
const { ctx } = makeCtx(NOW);
const clientId = await onboardedClient(ctx, 70);
const order = await createOrder(ctx, {
clientId,
serviceCode: 'cleaning',
params: { cleaningType: 'regular', rooms: 2, bathrooms: 2, extras: { windows: 2 } },
scheduledAt: slotAt(2, 11),
addressText: 'Pushkin 10',
districtId: fx.kentron.id,
});
expect(order.priceBreakdown).toMatchObject({ variant: 'regular', base: 15000, rooms: { count: 1, amount: 4000 }, bathrooms: { count: 1, amount: 3000 } });
const preset = await db.payoutPreset.create({
data: {
name: 'Мидл',
role: 'CLEANER',
rules: { base: { regular: { type: 'PERCENT', value: 60 } }, room: { type: 'FIXED', value: 2500 }, bathroom: { type: 'FIXED', value: 1500 }, extras: { windows: { type: 'FIXED', value: 1000 } } },
},
});
await db.provider.update({ where: { id: fx.maria.id }, data: { payoutPresetId: preset.id } });
const maria = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id }, include: { company: true, payoutPreset: true } });
const full = await db.order.findUniqueOrThrow({ where: { id: order.id }, include: { service: true } });
// 60% от 15000 + 2500 за комнату + 1500 за санузел + 2 окна × 1000
expect(orderPayout(full, maria)).toBe(9000 + 2500 + 1500 + 2000);
// без пресета — индивидуальная ставка 70% от итога
expect(orderPayout(full, { ...maria, payoutPreset: null })).toBe(Math.floor(full.totalPrice * 0.7));
// сотрудник компании — ставка компании
const lusine = await db.provider.findUniqueOrThrow({ where: { id: fx.lusine.id }, include: { company: true, payoutPreset: true } });
expect(orderPayout(full, lusine)).toBe(Math.floor(full.totalPrice * 0.65));
});
});
describe('заявка с сайта (/clean)', () => {
const input = (when = slotAt(2, 11)) => ({
serviceCode: 'cleaning',
params: { cleaningType: 'regular', rooms: 2, bathrooms: 1, extras: { windows: 2 } },
preferredAt: when,
districtCode: 'kentron',
addressText: 'Pushkin 10',
phone: '+374 (91) 12-34-56',
name: 'Anna',
locale: 'en' as const,
agency: 'DOMIK',
});
it('цена считается на сервере, телефон нормализуется, токен ведёт в бота с языком и агентством', async () => {
const { ctx } = makeCtx(NOW);
const r = await createWebRequest(ctx, input());
expect(r).toMatchObject({ status: 'NEW', phone: '+37491123456', totalPrice: 15000 + 4000 + 2 * 3500, locale: 'en' });
const token = await db.startToken.findUniqueOrThrow({ where: { id: r.token } });
expect(token).toMatchObject({ locale: 'en', agency: 'DOMIK' });
});
it('время вне окна, в прошлом или кривой телефон — отказ', async () => {
const { ctx } = makeCtx(NOW);
expect(await code(createWebRequest(ctx, input(slotAt(2, 21))))).toBe('SLOT_UNAVAILABLE');
expect(await code(createWebRequest(ctx, input(slotAt(0, 11))))).toBe('SLOT_UNAVAILABLE');
expect(await code(createWebRequest(ctx, { ...input(), phone: '12-34' }))).toBe('PHONE_INVALID');
expect(await code(createWebRequest(ctx, { ...input(), districtCode: 'mars' }))).toBe('ADDRESS_REQUIRED');
expect(normalizePhone('8 (999) 123-45-67')).toBe('+89991234567');
});
it('открыл бота: согласие с сайта, агентство, заказ на желаемое время; повторный /start ничего не дублирует', async () => {
const { ctx, events } = makeCtx(NOW);
const r = await createWebRequest(ctx, input());
const start = await startClient(ctx, tgUser(77), encodeStartPayload({ token: r.token }));
expect(start.locale).toBe('en');
const link = await linkWebRequest(ctx, r.token, start.clientId);
expect(link).toMatchObject({ kind: 'ordered', requestNumber: r.number, scheduledAt: slotAt(2, 11) });
const client = await db.client.findUniqueOrThrow({ where: { id: start.clientId }, include: { user: true } });
expect(client.consentAt).not.toBeNull();
expect(client.agencyId).toBe(fx.agency.id);
expect(client.user.phone).toBe('+37491123456');
const saved = await db.webRequest.findUniqueOrThrow({ where: { id: r.id }, include: { order: true } });
expect(saved.status).toBe('ORDERED');
// подарок агентства (Barev+) активировался вместе с согласием — в заказе уже скидка 10% на «Дом»
expect(link.kind === 'ordered' && link.gift).toBeTruthy();
expect(saved.order).toMatchObject({ status: 'NEW', basePrice: 26000, totalPrice: 23400, districtId: fx.kentron.id });
expect(events.filter((e) => e.type === 'order.created')).toHaveLength(1);
expect(await linkWebRequest(ctx, r.token, start.clientId)).toMatchObject({ kind: 'ordered', orderNumber: saved.order!.number });
expect(await db.order.count()).toBe(1);
// чужой Telegram по той же ссылке заявку не забирает
const other = await startClient(ctx, tgUser(78), encodeStartPayload({ token: r.token }));
expect(await linkWebRequest(ctx, r.token, other.clientId)).toEqual({ kind: 'none' });
});
it('нет свободного исполнителя — заявка остаётся оператору; отмену и возврат делает админ', async () => {
const { ctx } = makeCtx(NOW);
await db.provider.updateMany({ data: { active: false } });
const r = await createWebRequest(ctx, input());
const start = await startClient(ctx, tgUser(79));
expect(await linkWebRequest(ctx, r.token, start.clientId)).toMatchObject({ kind: 'linked', requestNumber: r.number });
expect(await db.order.count()).toBe(0);
expect((await db.webRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('LINKED');
await updateWebRequest(ctx, { id: r.id, status: 'CANCELLED', adminNote: 'перезвонить в пятницу' });
expect(await db.webRequest.findUniqueOrThrow({ where: { id: r.id } })).toMatchObject({ status: 'CANCELLED', adminNote: 'перезвонить в пятницу' });
await updateWebRequest(ctx, { id: r.id, status: 'REOPEN' });
expect((await db.webRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('LINKED');
});
});
describe('квиз на главной (заявка на переезд)', () => {
it('контакт: телефон или Telegram, мусор — отказ', async () => {
expect(normalizeContact('+374 91 12-34-56')).toEqual({ contact: '+37491123456', contactType: 'PHONE' });
expect(normalizeContact('@anna_k')).toEqual({ contact: '@anna_k', contactType: 'TELEGRAM' });
expect(normalizeContact('https://t.me/anna_k')).toEqual({ contact: '@anna_k', contactType: 'TELEGRAM' });
expect(normalizeContact('anna')).toBeNull();
const { ctx } = makeCtx(NOW);
expect(await code(createLead(ctx, { name: 'Anna', contact: '12', locale: 'ru' }))).toBe('CONTACT_INVALID');
expect(await code(createLead(ctx, { name: ' ', contact: '@anna_k', locale: 'ru' }))).toBe('NAME_REQUIRED');
});
it('шаг 1 сохраняется сразу, ответы дописываются по ключу, после отправки заявка не меняется', async () => {
const { ctx } = makeCtx(NOW);
const lead = await createLead(ctx, { name: 'Anna', contact: '@anna_k', locale: 'en', utm: { utm_source: 'ig' } });
expect(lead).toMatchObject({ status: 'NEW', submittedAt: null, locale: 'en', consentAt: NOW });
expect(await code(updateLead(ctx, { id: lead.id, key: 'wrong', moved: false }))).toBe('NOT_FOUND');
await updateLead(ctx, { id: lead.id, key: lead.key, moved: false, moveWhen: 'date', moveDate: slotAt(30, 0) });
await updateLead(ctx, { id: lead.id, key: lead.key, package: 'CUSTOM', options: { airport: 'premium', stay: 'hotel', stayBudgetFrom: 20000, stayBudgetTo: 40000, residence: true } });
const sent = await updateLead(ctx, { id: lead.id, key: lead.key, submit: true });
expect(sent).toMatchObject({ moved: false, moveWhen: 'date', moveDate: slotAt(30, 0), package: 'CUSTOM', submittedAt: NOW });
expect(sent.options).toMatchObject({ airport: 'premium', stayBudgetTo: 40000 });
await updateLead(ctx, { id: lead.id, key: lead.key, package: 'CONSULT' });
expect((await db.lead.findUniqueOrThrow({ where: { id: lead.id } })).package).toBe('CUSTOM');
expect(await updateLeadByAdmin(ctx, { id: lead.id, status: 'IN_WORK', adminNote: ' позвонить ' })).toMatchObject({ status: 'IN_WORK', adminNote: 'позвонить' });
});
it('«уже живу в Армении» — квиз закончен, заявка отправлена', async () => {
const { ctx } = makeCtx(NOW);
const lead = await createLead(ctx, { name: 'Ivan', contact: '+79991234567', locale: 'ru' });
expect(await updateLead(ctx, { id: lead.id, key: lead.key, moved: true })).toMatchObject({ moved: true, submittedAt: NOW });
});
});
describe('приложение: переезд, Elite, заявки оператору, консьерж', () => {
it('заявка из приложения строит план по шагам; оператор двигает шаг — клиенту событие', async () => {
const { ctx, events } = makeCtx(NOW);
const clientId = await onboardedClient(ctx, 501);
const lead = await createAppLead(ctx, clientId, { moveWhen: 'month', package: 'CUSTOM', options: { airport: 'standard', stay: 'none', flat: true, residence: true } });
expect(lead.steps.map((s) => s.code)).toEqual(['airport', 'flat', 'residence']);
expect(lead).toMatchObject({ clientId, submittedAt: NOW, moved: false });
expect(await code(createAppLead(ctx, clientId, { moveWhen: 'month', package: 'CUSTOM', options: { airport: 'none' } }))).toBe('INVALID_PARAMS');
await updateLeadStep(ctx, { stepId: lead.steps[0]!.id, status: 'DONE', note: 'Водитель Арам, +374 91 000000' });
expect(events).toContainEqual({ type: 'lead.step', stepId: lead.steps[0]!.id });
expect((await db.lead.findUniqueOrThrow({ where: { id: lead.id } })).status).toBe('IN_WORK');
const plan = (await clientRelocation(ctx, clientId))!;
expect(plan.docs.map((d) => d.code)).toEqual(['passport', 'diploma', 'criminalRecord']);
await toggleRelocationDoc(ctx, clientId, plan.docs[1]!.id, true);
expect((await clientRelocation(ctx, clientId))!.docs.filter((d) => d.checked).map((d) => d.code)).toEqual(['diploma']);
const other = await onboardedClient(ctx, 599);
expect(await code(toggleRelocationDoc(ctx, other, plan.docs[0]!.id, true))).toBe('NOT_FOUND');
});
it('заявка с сайта находится по нику Telegram и получает план «под ключ»', async () => {
const { ctx } = makeCtx(NOW);
const site = await createLead(ctx, { name: 'Ann', contact: '@Ann_Moving', locale: 'en' });
await updateLead(ctx, { id: site.id, key: site.key, moved: false });
await updateLead(ctx, { id: site.id, key: site.key, package: 'TURNKEY', submit: true });
const start = await startClient(ctx, { ...tgUser(502), username: 'ann_moving' });
const plan = await clientRelocation(ctx, start.clientId);
expect(plan?.id).toBe(site.id);
expect(plan?.steps).toHaveLength(7);
});
it('Elite: своя скидка в заказе, заявка на подписку не дублируется, оператор включает', async () => {
const { ctx, events } = makeCtx(NOW);
await db.category.updateMany({ where: { code: 'HOME' }, data: { eliteDiscountPct: 35 } });
const clientId = await onboardedClient(ctx, 503);
const r1 = await createClientRequest(ctx, clientId, { kind: 'SUBSCRIPTION', plan: 'ELITE' });
const r2 = await createClientRequest(ctx, clientId, { kind: 'SUBSCRIPTION', plan: 'ELITE' });
expect(r2.id).toBe(r1.id);
await grantSubscription(ctx, { clientId, plan: 'ELITE', days: 30, requestId: r1.id });
expect((await db.clientRequest.findUniqueOrThrow({ where: { id: r1.id } })).status).toBe('DONE');
expect(events.some((e) => e.type === 'subscription.activated')).toBe(true);
expect((await getMembership(db, clientId, NOW)).level).toBe('ELITE');
const order = await newOrder(ctx, clientId);
// уборка 2 комнаты 15 000 + 4 000 → −35%
expect(order).toMatchObject({ basePrice: 19000, totalPrice: 12350 });
expect(await code(createClientRequest(ctx, clientId, { kind: 'SERVICE', serviceCode: 'nope' }))).toBe('NOT_FOUND');
});
it('консьерж: сообщения клиента непрочитаны, пока оператор не открыл диалог; ответ уходит в бота', async () => {
const { ctx, events } = makeCtx(NOW);
const clientId = await onboardedClient(ctx, 504);
await addClientMessage(ctx, clientId, 'Как продлить визу?');
expect((await listConversations(ctx))[0]).toMatchObject({ clientId, unread: 1 });
await conversation(ctx, clientId);
expect((await listConversations(ctx))[0]!.unread).toBe(0);
const admin = await db.adminUser.create({ data: { email: 'op@test', name: 'Оператор', passwordHash: 'x' } });
const msg = await replyToClient(ctx, { clientId, adminUserId: admin.id, text: 'Подскажем завтра утром' });
expect(events).toContainEqual({ type: 'concierge.reply', messageId: msg.id });
});
});
describe('шаблоны переезда и переписка с исполнителями', () => {
it('новый шаг в общем списке попадает в «под ключ» новым клиентам; у клиента — свои шаги и документы', async () => {
const { ctx } = makeCtx(NOW);
await saveRelocationItem(ctx, { kind: 'STEP', name: { ru: 'Школа для ребёнка' }, active: true, inTurnkey: true });
await saveRelocationItem(ctx, { kind: 'STEP', name: { ru: 'Скрытый' }, active: false, inTurnkey: true });
const clientId = await onboardedClient(ctx, 601);
const lead = await createAppLead(ctx, clientId, { moveWhen: 'later', package: 'TURNKEY' });
expect(lead.steps).toHaveLength(8);
expect(lead.steps.some((s) => s.code.startsWith('c-'))).toBe(true);
const custom = await addLeadStep(ctx, { leadId: lead.id, title: 'Купить сим-карту' });
expect(custom).toMatchObject({ title: 'Купить сим-карту', status: 'TODO' });
expect(await addLeadStep(ctx, { leadId: lead.id, code: 'airport' })).toMatchObject({ id: lead.steps.find((s) => s.code === 'airport')!.id });
await addLeadDoc(ctx, { leadId: lead.id, title: 'Справка 2-НДФЛ' });
expect((await clientRelocation(ctx, clientId))!.docs.map((d) => d.title ?? d.code)).toEqual(['passport', 'diploma', 'criminalRecord', 'Справка 2-НДФЛ']);
});
it('исполнитель пишет боту — операторам уведомление; ответ уходит исполнителю', async () => {
const { ctx, events } = makeCtx(NOW);
const provider = await db.provider.findFirstOrThrow({ where: { userId: { not: null } } });
const msg = await addProviderMessage(ctx, provider.id, 'Клиент не открывает дверь');
expect(events).toContainEqual({ type: 'concierge.message', messageId: msg!.id });
const admin = await db.adminUser.create({ data: { email: 'op2@test', name: 'Оператор', passwordHash: 'x' } });
const reply = await replyTo(ctx, { providerId: provider.id, adminUserId: admin.id, text: 'Звоним клиенту' });
expect(reply).toMatchObject({ providerId: provider.id, author: 'OPERATOR' });
expect((await db.conciergeMessage.findUniqueOrThrow({ where: { id: msg!.id } })).readAt).not.toBeNull();
const unlinked = await db.provider.create({ data: { name: 'Без бота', role: 'CLEANER', languages: ['ru'], inviteCode: 'X-NOBOT' } });
expect(await code(replyTo(ctx, { providerId: unlinked.id, adminUserId: admin.id, text: 'привет' }))).toBe('PROVIDER_NOT_LINKED');
});
});
describe('партнёры: QR клубной карты и кабинет в рабочем боте', () => {
async function setup(ctx: ReturnType<typeof makeCtx>['ctx']) {
const partner = await db.partner.create({ data: { category: 'cafe', name: 'Lumen', offer: { ru: 'кофе' } } });
const every = await savePartnerOffer(ctx, { partnerId: partner.id, title: { ru: '−10% на всё' }, minLevel: null, limit: 'NONE', limitCount: 1, active: true });
const weekly = await savePartnerOffer(ctx, { partnerId: partner.id, title: { ru: 'Кофе в подарок' }, minLevel: 'BAREV_PLUS', limit: 'WEEK', limitCount: 1, active: true });
const first = await savePartnerOffer(ctx, { partnerId: partner.id, title: { ru: 'Десерт на первый визит' }, minLevel: null, limit: 'ONCE', limitCount: 1, active: true });
const { url } = await createOwnerInvite(ctx, partner.id);
const owner = await acceptPartnerInvite(ctx, tgUser(800), url.split('start=')[1]!);
expect(owner.role).toBe('OWNER');
return { partner, every, weekly, first };
}
it('QR меняется каждую минуту, старый скриншот через 3 минуты не проходит, подделка не проходит', () => {
const { token } = cardQrToken('ABCD2345', NOW);
expect(token.length).toBeLessThanOrEqual(64);
expect(verifyCardToken(token, NOW)).toBe('ABCD2345');
expect(verifyCardToken(token, new Date(NOW.getTime() + 90_000))).toBe('ABCD2345');
expect(verifyCardToken(token, new Date(NOW.getTime() + 4 * 60_000))).toBeNull();
expect(verifyCardToken(token.replace('ABCD2345', 'ZZZZ2345'), NOW)).toBeNull();
});
it('сотрудник проверяет карту: уровень, что положено, лимиты «раз в неделю» и «первый визит»', async () => {
const { ctx, clock } = makeCtx(NOW);
const { partner, weekly, first } = await setup(ctx);
const { url } = await createStaffInvite(ctx, 800n);
expect((await acceptPartnerInvite(ctx, tgUser(801), url.split('start=')[1]!)).role).toBe('STAFF');
expect(await code(acceptPartnerInvite(ctx, tgUser(802), url.split('start=')[1]!))).toBe('INVITE_INVALID');
expect(await code(createStaffInvite(ctx, 801n))).toBe('FORBIDDEN');
const clientId = await onboardedClient(ctx, 810);
const card = (await db.client.findUniqueOrThrow({ where: { id: clientId } })).cardCode;
let check = await checkCard(ctx, { telegramId: 801n, token: `https://t.me/bot?start=${cardQrToken(card, NOW).token}` });
expect(check.level).toBe('BAREV');
expect(check.offers.find((o) => o.id === weekly.id)).toMatchObject({ eligible: false });
expect(await code(redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: weekly.id }))).toBe('OFFER_NOT_AVAILABLE');
expect(await redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: first.id })).toMatchObject({ remaining: 0 });
expect(await code(redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: first.id }))).toBe('OFFER_LIMIT_REACHED');
await grantSubscription(ctx, { clientId, plan: 'BAREV_PLUS', days: 30 });
check = await checkCard(ctx, { telegramId: 801n, token: cardQrToken(card, NOW).token });
expect(check.offers.find((o) => o.id === weekly.id)).toMatchObject({ eligible: true, remaining: 1 });
await redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: weekly.id });
// через неделю — снова можно (19.10 — понедельник, 26.10 — следующий)
clock.now = new Date(NOW.getTime() + 7 * 24 * 3600_000);
check = await checkCard(ctx, { telegramId: 801n, token: cardQrToken(card, clock.now).token });
expect(check.offers.find((o) => o.id === weekly.id)).toMatchObject({ remaining: 1 });
expect(check.offers.find((o) => o.id === first.id)).toMatchObject({ remaining: 0 });
// чужой человек проверить не может; собственник видит сотрудников и проверки
expect(await code(checkCard(ctx, { telegramId: 999n, token: cardQrToken(card, clock.now).token }))).toBe('FORBIDDEN');
const cab = await partnerCabinet(ctx, 800n);
expect(cab).toMatchObject({ role: 'OWNER', visits30: 3 });
expect(cab.members.map((m) => m.role)).toEqual(['OWNER', 'STAFF']);
expect((await partnerCabinet(ctx, 801n)).members).toEqual([]);
expect((await clientPartners(ctx, clientId)).find((p) => p.id === partner.id)!.offers).toHaveLength(3);
});
});
+9
View File
@@ -38,7 +38,16 @@ export const slotAt = (dayOffset: number, hour: number) => {
let tgSeq = 1000;
export async function fixtures() {
// шаблоны переезда (в базе их создаёт миграция; тесты чистят все таблицы)
const steps = ['consult', 'airport', 'stay', 'flat', 'registration', 'socialCard', 'bankCard', 'residence'];
const docs = ['passport', 'diploma', 'criminalRecord'];
await wipe();
await db.relocationItem.createMany({
data: [
...steps.map((code, i) => ({ kind: 'STEP' as const, code, name: { ru: code }, sortOrder: i, inTurnkey: code !== 'consult' })),
...docs.map((code, i) => ({ kind: 'DOC' as const, code, name: { ru: code }, sortOrder: i, inTurnkey: false })),
],
});
for (const [i, code] of YEREVAN_DISTRICTS.entries()) await db.district.create({ data: { code, sortOrder: i } });
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
const home = await db.category.create({ data: { code: 'HOME', barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS.HOME } });