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:
@@ -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
@@ -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
@@ -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,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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 на той же машине; верим заголовку только от доверенного
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@ export function createTelegramClient({
|
||||
}
|
||||
|
||||
return {
|
||||
call,
|
||||
getMe: () => call<TgUser>('getMe'),
|
||||
sendMessage: (params: SendMessageParams) => call<TgMessage>('sendMessage', params),
|
||||
sendDocument(params: {
|
||||
|
||||
@@ -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>> = {}) {
|
||||
|
||||
@@ -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') };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user