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:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 } }),
|
||||
]);
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
|
||||
Reference in new issue
Block a user