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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
import { randomBytes } from 'node:crypto';
import type { ClientSegment, Db, LeadStepStatus, Prisma, RelocationItemKind, Tx } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import type { LeadOptions, UpdateLeadInput } from './leads.ts';
import { getSettings } from './settings.ts';
/** Ответы конструктора квиза, которые превращаются в шаги с тем же кодом */
const OPTION_STEPS = ['airport', 'stay', 'flat', 'registration', 'socialCard', 'bankCard', 'residence'] as const;
type Localized = Record<string, string>;
export interface RelocationItemInput {
id?: string;
kind: RelocationItemKind;
name: Localized;
hint?: Localized | null;
active: boolean;
inTurnkey?: boolean;
}
/** Какие шаги нужны по пакету: консультация — один шаг, «под ключ» — шаблоны с пометкой, конструктор — отмеченные */
export function stepCodesFor(pkg: string | null, options: LeadOptions | null, templates: { code: string; inTurnkey: boolean }[]): string[] {
const codes = new Set(templates.map((t) => t.code));
if (pkg === 'CONSULT') return codes.has('consult') ? ['consult'] : [];
if (pkg === 'TURNKEY') return templates.filter((t) => t.inTurnkey && t.code !== 'consult').map((t) => t.code);
if (pkg !== 'CUSTOM' || !options) return [];
return OPTION_STEPS.filter((c) => {
if (!codes.has(c)) return false;
if (c === 'airport') return !!options.airport && options.airport !== 'none';
if (c === 'stay') return !!options.stay && options.stay !== 'none';
return !!options[c];
});
}
const templatesOf = (db: Db | Tx, kind: RelocationItemKind) => db.relocationItem.findMany({ where: { kind, active: true }, orderBy: { sortOrder: 'asc' } });
/** Создаёт недостающие шаги плана по шаблонам; уже начатые оператором не трогает */
export async function syncLeadSteps(db: Db | Tx, lead: { id: string; package: string | null; options: Prisma.JsonValue }) {
const templates = await templatesOf(db, 'STEP');
const codes = stepCodesFor(lead.package, lead.options as LeadOptions | null, templates);
if (!codes.length) return;
const order = new Map(templates.map((t) => [t.code, t.sortOrder]));
await db.leadStep.createMany({ data: codes.map((code) => ({ leadId: lead.id, code, sortOrder: order.get(code) ?? 0 })), skipDuplicates: true });
}
/** Чек-лист документов клиента — копия общего списка при первом обращении, дальше живёт своей жизнью */
export async function ensureLeadDocs(db: Db | Tx, leadId: string) {
if (await db.leadDoc.count({ where: { leadId } })) return;
const templates = await templatesOf(db, 'DOC');
await db.leadDoc.createMany({ data: templates.map((t) => ({ leadId, code: t.code, sortOrder: t.sortOrder })), skipDuplicates: true });
}
export async function setSegment(ctx: Ctx, clientId: string, segment: ClientSegment) {
return ctx.db.client.update({ where: { id: clientId }, data: { segment } });
}
const leadInclude = { steps: { orderBy: { sortOrder: 'asc' } }, docs: { orderBy: { sortOrder: 'asc' } } } satisfies Prisma.LeadInclude;
/** Названия шагов и документов по коду (для своих шагов клиента — title в самой записи) */
export async function relocationNames(db: Db | Tx) {
const items = await db.relocationItem.findMany();
return Object.fromEntries(items.map((i) => [`${i.kind}:${i.code}`, { name: i.name as Localized, hint: (i.hint as Localized | null) ?? null }]));
}
/**
* План переезда клиента: последняя заявка из приложения или с сайта.
* Заявку с сайта узнаём по нику Telegram из квиза — при первом открытии приложения она привязывается к клиенту.
*/
export async function clientRelocation(ctx: Ctx, clientId: string) {
let lead = await ctx.db.lead.findFirst({ where: { clientId }, orderBy: { createdAt: 'desc' } });
if (!lead) {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { user: true } });
const username = client.user.username;
if (!username) return null;
const site = await ctx.db.lead.findFirst({
where: { clientId: null, contactType: 'TELEGRAM', contact: { equals: `@${username}`, mode: 'insensitive' }, moved: { not: true } },
orderBy: { createdAt: 'desc' },
});
if (!site) return null;
lead = await ctx.db.lead.update({ where: { id: site.id }, data: { clientId } });
await syncLeadSteps(ctx.db, lead);
}
await ensureLeadDocs(ctx.db, lead.id);
return ctx.db.lead.findUniqueOrThrow({ where: { id: lead.id }, include: leadInclude });
}
export interface AppLeadInput {
moveWhen: NonNullable<UpdateLeadInput['moveWhen']>;
moveDate?: Date;
package: NonNullable<UpdateLeadInput['package']>;
options?: LeadOptions;
comment?: string;
}
/** Заявка на переезд из приложения: контакт — Telegram клиента, согласие уже дано в боте */
export async function createAppLead(ctx: Ctx, clientId: string, input: AppLeadInput) {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { user: true } });
if (!client.consentAt) throw new DomainError('CONSENT_REQUIRED');
if (input.package === 'CUSTOM' && stepCodesFor('CUSTOM', input.options ?? null, await templatesOf(ctx.db, 'STEP')).length === 0) throw new DomainError('INVALID_PARAMS');
const settings = await getSettings(ctx.db);
const lead = await ctx.db.lead.create({
data: {
key: randomBytes(12).toString('base64url'),
name: client.user.firstName ?? 'Telegram',
contact: client.user.username ? `@${client.user.username}` : (client.user.phone ?? `tg:${client.user.telegramId}`),
contactType: client.user.username || !client.user.phone ? 'TELEGRAM' : 'PHONE',
locale: client.user.locale,
moved: false,
moveWhen: input.moveWhen,
moveDate: input.moveWhen === 'date' ? (input.moveDate ?? null) : null,
package: input.package,
options: input.package === 'CUSTOM' ? (input.options as Prisma.InputJsonValue) : undefined,
adminNote: input.comment?.trim() || undefined,
submittedAt: ctx.now(),
consentAt: client.consentAt,
consentVersion: client.consentVersion ?? settings['consent.version'],
clientId,
},
});
await syncLeadSteps(ctx.db, lead);
await ensureLeadDocs(ctx.db, lead.id);
await ctx.notify.emit({ type: 'lead.submitted', leadId: lead.id });
return ctx.db.lead.findUniqueOrThrow({ where: { id: lead.id }, include: leadInclude });
}
/** Клиент отмечает документ из своего чек-листа */
export async function toggleRelocationDoc(ctx: Ctx, clientId: string, docId: string, checked: boolean) {
const doc = await ctx.db.leadDoc.findUnique({ where: { id: docId }, include: { lead: true } });
if (!doc || doc.lead.clientId !== clientId) throw new DomainError('NOT_FOUND');
return ctx.db.leadDoc.update({ where: { id: doc.id }, data: { checked } });
}
/** Оператор двигает шаг плана; при смене статуса или комментария клиенту уходит сообщение в бота */
export async function updateLeadStep(ctx: Ctx, input: { stepId: string; status?: LeadStepStatus; note?: string; title?: string }) {
const step = await ctx.db.leadStep.findUniqueOrThrow({ where: { id: input.stepId }, include: { lead: true } });
const updated = await ctx.db.leadStep.update({
where: { id: step.id },
data: {
status: input.status,
note: input.note === undefined ? undefined : input.note.trim() || null,
title: input.title === undefined ? undefined : input.title.trim() || null,
},
});
if (step.lead.clientId && ((input.status && input.status !== step.status) || (input.note !== undefined && updated.note !== step.note))) {
await ctx.notify.emit({ type: 'lead.step', stepId: step.id });
}
// оператор начал работу по шагам — заявка «в работе»
if (input.status && step.lead.status === 'NEW') await ctx.db.lead.update({ where: { id: step.leadId }, data: { status: 'IN_WORK' } });
return updated;
}
const customCode = () => `custom-${randomBytes(4).toString('hex')}`;
/** Добавить клиенту шаг: из общего списка (code) или свой (title) — в конец плана */
export async function addLeadStep(ctx: Ctx, input: { leadId: string; code?: string; title?: string }) {
const last = await ctx.db.leadStep.findFirst({ where: { leadId: input.leadId }, orderBy: { sortOrder: 'desc' } });
const title = input.title?.trim();
if (!input.code && !title) throw new DomainError('INVALID_PARAMS');
const exists = input.code ? await ctx.db.leadStep.findUnique({ where: { leadId_code: { leadId: input.leadId, code: input.code } } }) : null;
if (exists) return exists;
return ctx.db.leadStep.create({ data: { leadId: input.leadId, code: input.code ?? customCode(), title: input.code ? undefined : title, sortOrder: (last?.sortOrder ?? 0) + 1 } });
}
export const removeLeadStep = (ctx: Ctx, stepId: string) => ctx.db.leadStep.delete({ where: { id: stepId } });
/** Документы клиента: добавить из общего списка или свой, переименовать, отметить, удалить */
export async function addLeadDoc(ctx: Ctx, input: { leadId: string; code?: string; title?: string }) {
await ensureLeadDocs(ctx.db, input.leadId);
const last = await ctx.db.leadDoc.findFirst({ where: { leadId: input.leadId }, orderBy: { sortOrder: 'desc' } });
const title = input.title?.trim();
if (!input.code && !title) throw new DomainError('INVALID_PARAMS');
const exists = input.code ? await ctx.db.leadDoc.findUnique({ where: { leadId_code: { leadId: input.leadId, code: input.code } } }) : null;
if (exists) return exists;
return ctx.db.leadDoc.create({ data: { leadId: input.leadId, code: input.code ?? customCode(), title: input.code ? undefined : title, sortOrder: (last?.sortOrder ?? 0) + 1 } });
}
export function updateLeadDoc(ctx: Ctx, input: { docId: string; title?: string; checked?: boolean }) {
return ctx.db.leadDoc.update({
where: { id: input.docId },
data: { checked: input.checked, title: input.title === undefined ? undefined : input.title.trim() || null },
});
}
export const removeLeadDoc = (ctx: Ctx, docId: string) => ctx.db.leadDoc.delete({ where: { id: docId } });
/** Общий список шагов и документов для админки */
export const listRelocationItems = (ctx: Ctx) => ctx.db.relocationItem.findMany({ orderBy: [{ kind: 'asc' }, { sortOrder: 'asc' }] });
/** Сохранить пункт общего списка. Новым клиентам он попадёт в план; у текущих — добавляется вручную */
export async function saveRelocationItem(ctx: Ctx, input: RelocationItemInput) {
if (!input.name.ru?.trim()) throw new DomainError('INVALID_PARAMS');
const data = { name: input.name, hint: input.hint ?? undefined, active: input.active, inTurnkey: input.inTurnkey ?? input.kind === 'STEP' };
if (input.id) return ctx.db.relocationItem.update({ where: { id: input.id }, data });
const last = await ctx.db.relocationItem.findFirst({ where: { kind: input.kind }, orderBy: { sortOrder: 'desc' } });
return ctx.db.relocationItem.create({ data: { ...data, kind: input.kind, code: `c-${randomBytes(4).toString('hex')}`, sortOrder: (last?.sortOrder ?? -1) + 1 } });
}
/** Поднять / опустить пункт в общем списке */
export async function moveRelocationItem(ctx: Ctx, input: { id: string; dir: -1 | 1 }) {
const item = await ctx.db.relocationItem.findUniqueOrThrow({ where: { id: input.id } });
const neighbour = await ctx.db.relocationItem.findFirst({
where: { kind: item.kind, sortOrder: input.dir < 0 ? { lt: item.sortOrder } : { gt: item.sortOrder } },
orderBy: { sortOrder: input.dir < 0 ? 'desc' : 'asc' },
});
if (!neighbour) return;
await ctx.db.$transaction([
ctx.db.relocationItem.update({ where: { id: item.id }, data: { sortOrder: neighbour.sortOrder } }),
ctx.db.relocationItem.update({ where: { id: neighbour.id }, data: { sortOrder: item.sortOrder } }),
]);
}