Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
210 lines
12 KiB
TypeScript
210 lines
12 KiB
TypeScript
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 } }),
|
|
]);
|
|
}
|