refactor: simplify — мёртвые поля контента, общие хелперы api и тестов, липкая CTA без site в бандле

landing:
- site.ts: убраны неиспользуемые roast, brew, priceDisplay, hero.eyebrow, footer.phone (Footer берёт CONTACT_PHONE)
- form.ts: убраны FORM_TEXT.title/lead, CTAS, CtaPreset
- StickyCta получает тексты пропсами от layout: клиентский чанк больше не тащит все тексты страницы
- LeadForm/useLeadForm: без неиспользуемой подмены sendLead и className; leadPrefill без счётчика seq
- Stamp без мёртвого tone; убраны data-interest, data-hot, класс lead-form (ничем не читались)
- одно LEAD_FIELDS на sendLead и leadFormModel; регэксп typo вынесен из вызова; дубль фона body

api:
- changedFields и Version общие для leads и outbox; countLeads и leadStamps без копий SQL
- errorMessage в system.ts вместо четырёх копий; take() для ожидающих подтверждений
- backup: один Intl.DateTimeFormat на модуль, без невызываемых параметров timeZone/hour
- без мёртвых LOOPBACK '::ffff:127.0.0.1', client.call, Pick<BackupCommands> со всеми ключами
- testing.ts: validLead, addLead, count, leadIds вместо копий в тестах

deploy.sh: deployed_field и цикл по REPOS вместо повторов имён
This commit is contained in:
2026-10-17 17:31:11 +03:00
parent be8db997a5
commit 88b6e1395e
32 changed files with 169 additions and 192 deletions
+6 -6
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { MAX_BODY_BYTES } from './app.js';
import { flush, setup, T0, validBody } from './testing.js';
import { count, flush, setup, T0, validBody } from './testing.js';
describe('GET /api/health', () => {
it('отдаёт версию', async () => {
@@ -56,7 +56,7 @@ describe('POST /api/lead', () => {
const res = await post(validBody());
await flush();
expect(res.status).toBe(201);
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 1 });
expect(count(db, 'leads')).toBe(1);
});
it('400 validation: ошибки по полям, ничего не пишет', async () => {
@@ -68,7 +68,7 @@ describe('POST /api/lead', () => {
expect(Object.keys(body.fieldErrors).sort()).toEqual(['name', 'phone']);
await flush();
expect(events).toEqual([]);
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
expect(count(db, 'leads')).toBe(0);
});
it('400 bad_request: битый JSON', async () => {
@@ -96,7 +96,7 @@ describe('POST /api/lead', () => {
const res = await post(validBody({ comment: 'а'.repeat(MAX_BODY_BYTES) }));
expect(res.status).toBe(413);
expect(await res.json()).toEqual({ ok: false, error: 'bad_request' });
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
expect(count(db, 'leads')).toBe(0);
});
it('honeypot: 201 как на обычную заявку, ничего не пишет, notifier молчит', async () => {
@@ -105,7 +105,7 @@ describe('POST /api/lead', () => {
await flush();
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ ok: true, duplicate: false });
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
expect(count(db, 'leads')).toBe(0);
expect(events).toEqual([]);
});
@@ -115,7 +115,7 @@ describe('POST /api/lead', () => {
const res = await post(validBody());
expect(res.status).toBe(503);
expect(await res.json()).toEqual({ ok: false, error: 'unavailable' });
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
expect(count(db, 'leads')).toBe(0);
paused = false;
expect((await post(validBody())).status).toBe(201);
+16 -27
View File
@@ -4,7 +4,6 @@ import { join } from 'node:path';
import { gzipSync } from 'node:zlib';
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { validateLead } from '@kvarta/shared/lead';
import {
backupDirFor,
backupFileName,
@@ -23,8 +22,7 @@ import {
} from './backup.js';
import { openDb, type DbHolder } from './db/index.js';
import { MIGRATIONS } from './db/migrations.js';
import { saveLead } from './leads.js';
import { validBody } from './testing.js';
import { addLead, leadIds } from './testing.js';
import { fakeTelegram } from './telegram/fake.js';
import type { SystemReporter } from './telegram/system.js';
@@ -41,15 +39,6 @@ function tempDb() {
return { root, dbPath, holder, dir: backupDirFor(dbPath) };
}
let phoneSeq = 1000000;
function addLead(holder: DbHolder, at: Date) {
const r = validateLead(validBody({ phone: `+37529${phoneSeq++}` }));
if (!r.ok) throw new Error('invalid');
return saveLead(holder.db, r.data, { now: at, userAgent: null });
}
const leadIds = (holder: DbHolder) => (holder.db.prepare('SELECT id FROM leads ORDER BY id').all() as { id: number }[]).map((r) => r.id);
// 18.10.2026 03:00 по Минску = 00:00 UTC
const NIGHT = new Date('2026-10-18T00:00:00.000Z');
@@ -81,8 +70,8 @@ describe('расписание и имена', () => {
describe('createBackup и ротация', () => {
it('gzip-копия с данными, без временных файлов', async () => {
const { holder, dir } = tempDb();
addLead(holder, NIGHT);
addLead(holder, NIGHT);
addLead(holder.db, NIGHT);
addLead(holder.db, NIGHT);
const result = await createBackup(holder.db, dir, NIGHT);
expect(result).toMatchObject({ file: 'kvarta-2026-10-18T0300.sqlite.gz', kind: 'daily', leads: 2 });
@@ -117,7 +106,7 @@ describe('createBackup и ротация', () => {
it('runBackupAndReport: файл админу, отчёт в «Систему»', async () => {
const { holder, dir } = tempDb();
addLead(holder, NIGHT);
addLead(holder.db, NIGHT);
const tg = fakeTelegram();
const reports: string[] = [];
const system: SystemReporter = { report: async (_k, t) => void reports.push(t) };
@@ -141,11 +130,11 @@ describe('createBackup и ротация', () => {
describe('восстановление', () => {
it('подменяет данные, создаёт pre-restore, пауза на время подмены', async () => {
const { holder, dbPath, dir } = tempDb();
addLead(holder, NIGHT);
addLead(holder.db, NIGHT);
const backup = await createBackup(holder.db, dir, NIGHT);
const later = new Date(NIGHT.getTime() + 60 * 60 * 1000);
addLead(holder, later);
addLead(holder, later);
addLead(holder.db, later);
addLead(holder.db, later);
const inspected = await inspectBackup(join(dir, backup.file), dir);
expect(leadsMissingFrom(holder.db, inspected.leads)).toEqual([2, 3]);
@@ -164,7 +153,7 @@ describe('восстановление', () => {
expect(holder.paused).toBe(false);
expect(holder.db).not.toBe(oldDb);
expect(oldDb.open).toBe(false);
expect(leadIds(holder)).toEqual([1]);
expect(leadIds(holder.db)).toEqual([1]);
expect(result.leads).toBe(1);
expect(result.preRestore.file).toBe('pre-restore-2026-10-18T0400.sqlite.gz');
const pre = await inspectBackup(join(dir, result.preRestore.file), dir);
@@ -174,13 +163,13 @@ describe('восстановление', () => {
expect(readdirSync(dir).sort()).toEqual([backup.file, result.preRestore.file].sort());
expect(existsSync(`${dbPath}.rollback`)).toBe(false);
// Новая база рабочая: пишем дальше.
addLead(holder, later);
expect(leadIds(holder)).toEqual([1, 2]);
addLead(holder.db, later);
expect(leadIds(holder.db)).toEqual([1, 2]);
});
it('id после отката переиспользуются — пропавшие считаем по id и created_at', async () => {
const { holder, dir } = tempDb();
addLead(holder, NIGHT);
addLead(holder.db, NIGHT);
const inspected = await inspectBackup(join(dir, (await createBackup(holder.db, dir, NIGHT)).file), dir);
// Заявку #1 пересоздали в другое время (как после прошлого восстановления).
holder.db.prepare('UPDATE leads SET created_at = ? WHERE id = 1').run('2026-10-19T00:00:00.000Z');
@@ -189,10 +178,10 @@ describe('восстановление', () => {
it('битый файл отклоняется, база не тронута', async () => {
const { holder, dir } = tempDb();
addLead(holder, NIGHT);
addLead(holder.db, NIGHT);
await expect(inspectBackup(new Uint8Array([1, 2, 3]), dir)).rejects.toBeInstanceOf(RestoreRejected);
await expect(inspectBackup(gzipSync(Buffer.from('not sqlite at all')), dir)).rejects.toThrow(/не база SQLite/);
expect(leadIds(holder)).toEqual([1]);
expect(leadIds(holder.db)).toEqual([1]);
expect(readdirSync(dir).filter((f) => f.startsWith('.'))).toEqual([]);
});
@@ -208,8 +197,8 @@ describe('восстановление', () => {
it('сбой после снимка — возвращаем старую базу и снимаем паузу', async () => {
const { holder, dbPath, dir } = tempDb();
addLead(holder, NIGHT);
addLead(holder, NIGHT);
addLead(holder.db, NIGHT);
addLead(holder.db, NIGHT);
const inspected = await inspectBackup(join(dir, (await createBackup(holder.db, dir, NIGHT)).file), dir);
// Портим распакованную копию уже после проверки: openDb на ней упадёт.
writeFileSync(inspected.path, 'garbage');
@@ -217,7 +206,7 @@ describe('восстановление', () => {
await expect(restoreFromInspected({ holder, dbPath, inspected, now: NIGHT })).rejects.toThrow();
expect(holder.paused).toBe(false);
expect(holder.db.open).toBe(true);
expect(leadIds(holder)).toEqual([1, 2]);
expect(leadIds(holder.db)).toEqual([1, 2]);
expect(existsSync(`${dbPath}.rollback`)).toBe(false);
});
});
+28 -23
View File
@@ -19,12 +19,13 @@ import Database from 'better-sqlite3';
import { openDb, type Db, type DbHolder } from './db/index.js';
import { MIGRATIONS } from './db/migrations.js';
import type { TelegramClient } from './telegram/client.js';
import { shortError, type SystemReporter } from './telegram/system.js';
import { errorMessage, shortError, type SystemReporter } from './telegram/system.js';
export const TIME_ZONE = 'Europe/Minsk';
export const KEEP_DAILY = 14;
export const KEEP_PRE_RESTORE_DAYS = 30;
const DAY_MS = 24 * 60 * 60 * 1000;
const DAILY_HOUR = 3;
export type BackupKind = 'daily' | 'pre-restore';
export type BackupFile = { file: string; kind: BackupKind; at: Date; bytes: number };
@@ -37,17 +38,19 @@ export function backupDirFor(dbPath: string): string {
type LocalParts = { year: number; month: number; day: number; hour: number; minute: number };
const MINSK_PARTS = new Intl.DateTimeFormat('en-CA', {
timeZone: TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
/** Часы и дата по Минску. Через Intl, а не +3: правило могут поменять, tzdata — обновится. */
export function localParts(date: Date, timeZone = TIME_ZONE): LocalParts {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date);
export function localParts(date: Date): LocalParts {
const parts = MINSK_PARTS.formatToParts(date);
const get = (type: string) => Number(parts.find((p) => p.type === type)!.value);
return { year: get('year'), month: get('month'), day: get('day'), hour: get('hour'), minute: get('minute') };
}
@@ -67,9 +70,9 @@ export function backupFileName(date: Date, kind: BackupKind): string {
}
/** Минское локальное время → UTC-момент (смещение берём на этот самый момент). */
function fromLocal(p: LocalParts, timeZone = TIME_ZONE): Date {
function fromLocal(p: LocalParts): Date {
const guess = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute);
const shown = localParts(new Date(guess), timeZone);
const shown = localParts(new Date(guess));
const offset = Date.UTC(shown.year, shown.month - 1, shown.day, shown.hour, shown.minute) - guess;
return new Date(guess - offset);
}
@@ -85,12 +88,12 @@ function parseName(file: string): { kind: BackupKind; at: Date } | null {
}
/** Следующий запуск ежедневного бэкапа: ближайшие 03:00 по Минску строго после now. */
export function nextDailyRun(now: Date, hour = 3): Date {
export function nextDailyRun(now: Date): Date {
const today = localParts(now);
let run = fromLocal({ ...today, hour, minute: 0 });
let run = fromLocal({ ...today, hour: DAILY_HOUR, minute: 0 });
if (run.getTime() <= now.getTime()) {
const tomorrow = localParts(new Date(run.getTime() + DAY_MS));
run = fromLocal({ ...tomorrow, hour, minute: 0 });
run = fromLocal({ ...tomorrow, hour: DAILY_HOUR, minute: 0 });
}
return run;
}
@@ -125,7 +128,7 @@ export function rotateBackups(dir: string, now: Date): string[] {
return removed;
}
function countLeads(db: Db): number {
export function countLeads(db: Db): number {
return (db.prepare('SELECT count(*) AS n FROM leads').get() as { n: number }).n;
}
@@ -199,8 +202,7 @@ export async function inspectBackup(source: string | Uint8Array, dir: string): P
}
const hasLeads = copy.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'leads'").get();
if (!hasLeads) throw new RestoreRejected('В копии нет таблицы заявок.');
const leads = copy.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
return { path, userVersion, leads };
return { path, userVersion, leads: leadStamps(copy) };
} finally {
copy.close();
}
@@ -220,8 +222,11 @@ export function discardInspected(inspected: InspectedBackup): void {
/** Заявки текущей базы, которых нет в копии (по паре id + created_at: id после отката переиспользуются). */
export function leadsMissingFrom(db: Db, backupLeads: InspectedBackup['leads']): number[] {
const inCopy = new Set(backupLeads.map((l) => `${l.id}|${l.created_at}`));
const current = db.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
return current.filter((l) => !inCopy.has(`${l.id}|${l.created_at}`)).map((l) => l.id);
return leadStamps(db).filter((l) => !inCopy.has(`${l.id}|${l.created_at}`)).map((l) => l.id);
}
function leadStamps(db: Db): InspectedBackup['leads'] {
return db.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
}
function removeSidecars(path: string) {
@@ -347,7 +352,7 @@ export async function runBackupAndReport({
}): Promise<BackupResult | undefined> {
let result: BackupResult;
try {
result = await createBackup(holder.db, dir, now, 'daily');
result = await createBackup(holder.db, dir, now);
rotateBackups(dir, now);
} catch (err) {
await system.report('backup failed', `Бэкап ${shortLabel(now)} не удался ❌\n${shortError(err)}`);
@@ -363,7 +368,7 @@ export async function runBackupAndReport({
caption: summary,
});
} catch (err) {
delivery = `, но в личку не отправлен: ${err instanceof Error ? err.message : String(err)}`;
delivery = `, но в личку не отправлен: ${errorMessage(err)}`;
}
}
await system.report('backup', `${summary} ✅${delivery}`);
+1 -5
View File
@@ -1,13 +1,9 @@
import { describe, expect, it } from 'vitest';
import { CLARIFY_WINDOW_MS } from './leads.js';
import { flush, setup, validBody } from './testing.js';
import { count, flush, setup, validBody } from './testing.js';
const MINUTE = 60 * 1000;
function count(db: ReturnType<typeof setup>['db'], table: 'leads' | 'lead_clarifications') {
return (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n;
}
describe('уточнения в окне 10 минут', () => {
it('повтор без изменений: clarified:false, ничего не пишем, notifier молчит', async () => {
const { post, db, events, advance } = setup();
+8 -3
View File
@@ -1,4 +1,4 @@
import type { ValidLead } from '@kvarta/shared/lead';
import type { Interest, ValidLead } from '@kvarta/shared/lead';
import type { Db } from './db/index.js';
import type { ClarifiedField, LeadEvent } from './notifier.js';
@@ -12,10 +12,15 @@ export type SaveResult =
| { kind: 'clarified'; event: LeadEvent }
| { kind: 'unchanged'; leadId: number };
type Version = { name: string; interest: string; comment: string | null };
/** Версия заявки: сама заявка или уточнение к ней. */
export type Version = { name: string; interest: Interest; comment: string | null };
const CLARIFIED_FIELDS: readonly ClarifiedField[] = ['name', 'interest', 'comment'];
export function changedFields(previous: Version, next: Version): ClarifiedField[] {
return CLARIFIED_FIELDS.filter((field) => previous[field] !== next[field]);
}
/**
* Новая заявка, уточнение к заявке из окна или повтор без изменений.
* Поиск и запись в одной IMMEDIATE-транзакции: два одновременных запроса с одного телефона
@@ -63,7 +68,7 @@ export function saveLead(db: Db, lead: ValidLead, meta: SaveMeta): SaveResult {
)
.get(open.id) as Version | undefined) ?? open;
const changed = CLARIFIED_FIELDS.filter((field) => previous[field] !== lead[field]);
const changed = changedFields(previous, lead);
if (changed.length === 0) return { kind: 'unchanged', leadId: open.id };
const { lastInsertRowid } = db
+2 -1
View File
@@ -35,7 +35,8 @@ export function createRateLimiter({ limit = 5, windowMs = 10 * 60 * 1000 } = {})
};
}
export const LOOPBACK = ['127.0.0.1', '::1', '::ffff:127.0.0.1'] as const;
// «::ffff:127.0.0.1» отдельно не нужен: clientIp снимает префикс до сверки
export const LOOPBACK = ['127.0.0.1', '::1'] as const;
/**
* IP посетителя. X-Real-IP ставит nginx на той же машине; верим заголовку только от доверенного
+3 -10
View File
@@ -2,11 +2,9 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { validateLead } from '@kvarta/shared/lead';
import { backupDirFor, createBackup, createBackupScheduler, listBackups } from '../backup.js';
import { openDb, type DbHolder } from '../db/index.js';
import { saveLead } from '../leads.js';
import { validBody } from '../testing.js';
import { addLead as addLeadTo, leadIds as leadIdsOf } from '../testing.js';
import { createBackupCommands, CONFIRM_TTL_MS } from './backupCommands.js';
import { createBot } from './bot.js';
import type { TgUpdate } from './client.js';
@@ -21,7 +19,6 @@ afterEach(() => {
// 18.10.2026 03:00 по Минску
const NIGHT = new Date('2026-10-18T00:00:00.000Z');
let phone = 2000000;
function setup() {
const root = mkdtempSync(join(tmpdir(), 'kvarta-bot-'));
@@ -39,11 +36,7 @@ function setup() {
const backups = createBackupCommands({ client: tg.client, adminId: ADMIN, holder, dbPath, now: () => clock.now, system });
const bot = createBot({ client: tg.client, telegram: { adminId: ADMIN }, system, backups });
const addLead = () => {
const r = validateLead(validBody({ phone: `+37529${phone++}` }));
if (!r.ok) throw new Error('invalid');
saveLead(holder.db, r.data, { now: clock.now, userAgent: null });
};
const addLead = () => void addLeadTo(holder.db, clock.now);
const chat = { id: ADMIN, type: 'private' as const };
const from = (id: number) => ({ id, is_bot: false, first_name: 'A' });
const text = (t: string, fromId = ADMIN): TgUpdate => ({
@@ -57,7 +50,7 @@ function setup() {
const lastText = () => tg.sent().at(-1)?.text as string;
const lastButtons = () => tg.sent().at(-1)?.reply_markup?.inline_keyboard.flat() as { text: string; callback_data: string }[];
const edits = () => tg.sent('editMessageText').map((p) => p.text as string);
const leadIds = () => (holder.db.prepare('SELECT id FROM leads ORDER BY id').all() as { id: number }[]).map((r) => r.id);
const leadIds = () => leadIdsOf(holder.db);
return { tg, bot, holder, dbPath, clock, reports, uploads, addLead, text, press, lastText, lastButtons, edits, leadIds };
}
+15 -8
View File
@@ -2,6 +2,7 @@
import { join } from 'node:path';
import {
backupDirFor,
countLeads,
discardInspected,
inspectBackup,
leadsMissingFrom,
@@ -15,7 +16,7 @@ import {
} from '../backup.js';
import type { DbHolder } from '../db/index.js';
import type { InlineKeyboard, TelegramClient, TgCallbackQuery, TgMessage } from './client.js';
import { shortError, type SystemReporter } from './system.js';
import { errorMessage, shortError, type SystemReporter } from './system.js';
export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
export const CONFIRM_TTL_MS = 10 * 60 * 1000;
@@ -56,6 +57,13 @@ export function createBackupCommands({
const say = (text: string, reply_markup?: InlineKeyboard) => client.sendMessage({ chat_id: adminId, text, reply_markup });
/** Достать подтверждение из ожидающих: каждое срабатывает один раз. */
function take(id: string): Pending | undefined {
const p = pending.get(id);
pending.delete(id);
return p;
}
function sweep() {
const t = now().getTime();
for (const [id, p] of pending) {
@@ -103,7 +111,7 @@ export function createBackupCommands({
async function offerRestore(inspected: InspectedBackup, label: string) {
sweep();
const missing = leadsMissingFrom(holder.db, inspected.leads);
const total = (holder.db.prepare('SELECT count(*) AS n FROM leads').get() as { n: number }).n;
const total = countLeads(holder.db);
const id = String(++seq);
pending.set(id, { inspected, label, expiresAt: now().getTime() + CONFIRM_TTL_MS });
@@ -135,8 +143,7 @@ export function createBackupCommands({
async function confirm(id: string, query: TgCallbackQuery) {
sweep();
const p = pending.get(id);
pending.delete(id);
const p = take(id);
const edit = (text: string) =>
query.message
? client.editMessageText({ chat_id: query.message.chat.id, message_id: query.message.message_id, text })
@@ -153,7 +160,7 @@ export function createBackupCommands({
);
await system.report('restore', `База восстановлена из ${p.label} администратором`);
} catch (err) {
await say(`Не удалось восстановить: ${err instanceof Error ? err.message : String(err)}. База осталась прежней.`);
await say(`Не удалось восстановить: ${errorMessage(err)}. База осталась прежней.`);
await system.report('restore failed', `Восстановление из ${p.label} не удалось, база прежняя\n${shortError(err)}`);
}
}
@@ -186,7 +193,8 @@ export function createBackupCommands({
async onCallback(query: TgCallbackQuery) {
await client.answerCallbackQuery(query.id);
const data = query.data ?? '';
const [action, arg = ''] = [data.slice(0, 1), data.slice(2)];
const action = data.slice(0, 1);
const arg = data.slice(2);
if (action === 'r') {
// Имя проверяем по списку, а не склеиваем путь из callback_data: никакого ../
const backup = listBackups(dir).find((b) => b.file === arg);
@@ -195,9 +203,8 @@ export function createBackupCommands({
} else if (action === 'y') {
await confirm(arg, query);
} else if (action === 'n') {
const p = pending.get(arg);
const p = take(arg);
if (p) discardInspected(p.inspected);
pending.delete(arg);
if (query.message) {
await client.editMessageText({ chat_id: query.message.chat.id, message_id: query.message.message_id, text: 'Восстановление отменено.' });
}
+3 -3
View File
@@ -3,7 +3,7 @@
import type { TelegramConfig } from '../config.js';
import { TelegramError, type TelegramClient, type TgMessage, type TgUpdate, type TgUser } from './client.js';
import type { BackupCommands } from './backupCommands.js';
import { shortError, type SystemReporter } from './system.js';
import { errorMessage, shortError, type SystemReporter } from './system.js';
export const LEADS_TOPIC = 'Kvarta Cafe: Заявки';
export const SYSTEM_TOPIC = 'Kvarta Cafe: Система';
@@ -41,7 +41,7 @@ export function createBot({
client: Client;
telegram: TelegramConfig;
system: SystemReporter;
backups?: Pick<BackupCommands, 'onCommand' | 'onDocument' | 'onCallback'>;
backups?: BackupCommands;
retryPauseMs?: number;
}): Bot {
let me: TgUser | undefined;
@@ -161,7 +161,7 @@ export function createBot({
} else if (Date.now() - lastNetworkWarnAt >= NETWORK_WARN_EVERY_MS) {
// Сеть до Telegram недоступна — в «Систему» не сообщить, остаётся лог контейнера.
lastNetworkWarnAt = Date.now();
console.warn(`[bot] getUpdates не удался: ${err instanceof Error ? err.message : String(err)}`);
console.warn(`[bot] getUpdates не удался: ${errorMessage(err)}`);
}
await pause(err instanceof TelegramError && err.retryAfterSec ? err.retryAfterSec * 1000 : retryPauseMs);
}
-1
View File
@@ -105,7 +105,6 @@ export function createTelegramClient({
}
return {
call,
getMe: () => call<TgUser>('getMe'),
sendMessage: (params: SendMessageParams) => call<TgMessage>('sendMessage', params),
sendDocument(params: {
+1 -1
View File
@@ -1,7 +1,7 @@
// Фейковый Telegram для тестов: записывает вызовы, ответы и сбои задаются по методу.
import type { TelegramClient } from './client.js';
type Method = Exclude<keyof TelegramClient, 'call'>;
type Method = keyof TelegramClient;
type Handler = (params: any) => unknown;
export function fakeTelegram(handlers: Partial<Record<Method, Handler>> = {}) {
+2 -8
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import { validateLead, type ValidLead } from '@kvarta/shared/lead';
import type { ValidLead } from '@kvarta/shared/lead';
import { openDb } from '../db/index.js';
import { saveLead } from '../leads.js';
import { validBody } from '../testing.js';
import { validLead as lead } from '../testing.js';
import { TelegramError } from './client.js';
import { fakeTelegram } from './fake.js';
import { backoffMs, createOutbox, MAX_ATTEMPTS } from './outbox.js';
@@ -10,12 +10,6 @@ import type { SystemReporter } from './system.js';
const MINUTE = 60 * 1000;
function lead(overrides: Parameters<typeof validBody>[0] = {}): ValidLead {
const r = validateLead(validBody(overrides));
if (!r.ok) throw new Error('invalid');
return r.data;
}
function setup({ failures = [] as unknown[], chatId = -100 as number | null, paused = { value: false } } = {}) {
const db = openDb(':memory:');
const clock = { now: new Date('2026-10-17T12:00:00.000Z') };
+7 -9
View File
@@ -2,7 +2,8 @@
// поэтому рестарт или потерянное событие ничего не теряют: следующий тик доберёт pending.
import type { Interest, Touch } from '@kvarta/shared/lead';
import type { Db } from '../db/index.js';
import type { ClarifiedField, LeadEvent, Notifier } from '../notifier.js';
import { changedFields, type Version } from '../leads.js';
import type { Notifier } from '../notifier.js';
import { TelegramError, type TelegramClient } from './client.js';
import { formatClarification, formatLead } from './format.js';
import type { SystemReporter } from './system.js';
@@ -22,7 +23,6 @@ type LeadRow = {
first_touch: string | null;
last_touch: string;
};
type VersionRow = { name: string; interest: Interest; comment: string | null };
export type Outbox = Notifier & {
/** Отправить всё, что пора. Вызовы не пересекаются: следующий ждёт предыдущий. */
@@ -102,7 +102,7 @@ export function createOutbox({
}
const current = db
.prepare('SELECT name, interest, comment FROM lead_clarifications WHERE id = ?')
.get(item.id) as VersionRow;
.get(item.id) as Version;
// Изменения считаем по базе, а не из события: тик после рестарта события не видел.
const previous =
(db
@@ -110,9 +110,9 @@ export function createOutbox({
`SELECT name, interest, comment FROM lead_clarifications
WHERE lead_id = ? AND id < ? ORDER BY id DESC LIMIT 1`,
)
.get(item.lead_id, item.id) as VersionRow | undefined) ??
(db.prepare('SELECT name, interest, comment FROM leads WHERE id = ?').get(item.lead_id) as VersionRow);
const changed = (['name', 'interest', 'comment'] as ClarifiedField[]).filter((f) => current[f] !== previous[f]);
.get(item.lead_id, item.id) as Version | undefined) ??
(db.prepare('SELECT name, interest, comment FROM leads WHERE id = ?').get(item.lead_id) as Version);
const changed = changedFields(previous, current);
return formatClarification({ leadId: item.lead_id, ...current, changed });
}
@@ -170,9 +170,7 @@ export function createOutbox({
}
return {
leadSaved(_event: LeadEvent) {
return tick();
},
leadSaved: tick,
tick,
start() {
if (!enabled || timer) return;
+5 -1
View File
@@ -45,12 +45,16 @@ export function createSystemReporter({
text: count > 1 ? `×${count} за период · ${text}` : text,
});
} catch (err) {
log(`[system] не отправлено в Telegram: ${err instanceof Error ? err.message : String(err)}`);
log(`[system] не отправлено в Telegram: ${errorMessage(err)}`);
}
},
};
}
export function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** Короткий stack без путей до node_modules: в «Систему» и stdout. */
export function shortError(err: unknown): string {
if (!(err instanceof Error)) return String(err);
+23 -2
View File
@@ -1,7 +1,8 @@
// Общие заготовки для тестов api: база в памяти, управляемые часы, notifier-шпион.
import type { LeadRequest } from '@kvarta/shared/lead';
import { validateLead, type LeadRequest, type ValidLead } from '@kvarta/shared/lead';
import { createApp, type AppDeps } from './app.js';
import { openDb } from './db/index.js';
import { openDb, type Db } from './db/index.js';
import { saveLead } from './leads.js';
import type { LeadEvent, Notifier } from './notifier.js';
import type { RateLimiter } from './rateLimit.js';
@@ -19,6 +20,26 @@ export function validBody(overrides: Partial<LeadRequest> = {}): LeadRequest {
};
}
export function validLead(overrides: Partial<LeadRequest> = {}): ValidLead {
const r = validateLead(validBody(overrides));
if (!r.ok) throw new Error('invalid');
return r.data;
}
let phoneSeq = 1000000;
/** Новая заявка с уникальным телефоном — не попадёт в уточнения к предыдущей. */
export function addLead(db: Db, now: Date) {
return saveLead(db, validLead({ phone: `+37529${phoneSeq++}` }), { now, userAgent: null });
}
export function count(db: Db, table: 'leads' | 'lead_clarifications'): number {
return (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n;
}
export function leadIds(db: Db): number[] {
return (db.prepare('SELECT id FROM leads ORDER BY id').all() as { id: number }[]).map((r) => r.id);
}
export function setup(overrides: Partial<AppDeps> = {}) {
const db = openDb(':memory:');
const clock = { now: T0 };