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; 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; moveDate?: Date; package: NonNullable; 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 } }), ]); }