diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index ee6937a..73809eb 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -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); diff --git a/apps/api/src/backup.test.ts b/apps/api/src/backup.test.ts index de655b8..0a8fb5d 100644 --- a/apps/api/src/backup.test.ts +++ b/apps/api/src/backup.test.ts @@ -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); }); }); diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 9d2a4e9..9ca64fe 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -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 { 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}`); diff --git a/apps/api/src/leads.test.ts b/apps/api/src/leads.test.ts index f2ca549..8dc51ac 100644 --- a/apps/api/src/leads.test.ts +++ b/apps/api/src/leads.test.ts @@ -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['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(); diff --git a/apps/api/src/leads.ts b/apps/api/src/leads.ts index 1d683af..035b11a 100644 --- a/apps/api/src/leads.ts +++ b/apps/api/src/leads.ts @@ -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 diff --git a/apps/api/src/rateLimit.ts b/apps/api/src/rateLimit.ts index ec53fc5..e524719 100644 --- a/apps/api/src/rateLimit.ts +++ b/apps/api/src/rateLimit.ts @@ -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 на той же машине; верим заголовку только от доверенного diff --git a/apps/api/src/telegram/backupCommands.test.ts b/apps/api/src/telegram/backupCommands.test.ts index e39df9e..bfa1e0f 100644 --- a/apps/api/src/telegram/backupCommands.test.ts +++ b/apps/api/src/telegram/backupCommands.test.ts @@ -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 }; } diff --git a/apps/api/src/telegram/backupCommands.ts b/apps/api/src/telegram/backupCommands.ts index 68cd5c4..1547c2d 100644 --- a/apps/api/src/telegram/backupCommands.ts +++ b/apps/api/src/telegram/backupCommands.ts @@ -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: 'Восстановление отменено.' }); } diff --git a/apps/api/src/telegram/bot.ts b/apps/api/src/telegram/bot.ts index 4b5f6d2..7d5d136 100644 --- a/apps/api/src/telegram/bot.ts +++ b/apps/api/src/telegram/bot.ts @@ -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; + 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); } diff --git a/apps/api/src/telegram/client.ts b/apps/api/src/telegram/client.ts index 70b93db..c171643 100644 --- a/apps/api/src/telegram/client.ts +++ b/apps/api/src/telegram/client.ts @@ -105,7 +105,6 @@ export function createTelegramClient({ } return { - call, getMe: () => call('getMe'), sendMessage: (params: SendMessageParams) => call('sendMessage', params), sendDocument(params: { diff --git a/apps/api/src/telegram/fake.ts b/apps/api/src/telegram/fake.ts index 72bd691..11bae78 100644 --- a/apps/api/src/telegram/fake.ts +++ b/apps/api/src/telegram/fake.ts @@ -1,7 +1,7 @@ // Фейковый Telegram для тестов: записывает вызовы, ответы и сбои задаются по методу. import type { TelegramClient } from './client.js'; -type Method = Exclude; +type Method = keyof TelegramClient; type Handler = (params: any) => unknown; export function fakeTelegram(handlers: Partial> = {}) { diff --git a/apps/api/src/telegram/outbox.test.ts b/apps/api/src/telegram/outbox.test.ts index 017f1c6..a107c11 100644 --- a/apps/api/src/telegram/outbox.test.ts +++ b/apps/api/src/telegram/outbox.test.ts @@ -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[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') }; diff --git a/apps/api/src/telegram/outbox.ts b/apps/api/src/telegram/outbox.ts index f54d692..3798b9a 100644 --- a/apps/api/src/telegram/outbox.ts +++ b/apps/api/src/telegram/outbox.ts @@ -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; diff --git a/apps/api/src/telegram/system.ts b/apps/api/src/telegram/system.ts index 5b8787b..b689a60 100644 --- a/apps/api/src/telegram/system.ts +++ b/apps/api/src/telegram/system.ts @@ -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); diff --git a/apps/api/src/testing.ts b/apps/api/src/testing.ts index 7212b13..94dc65d 100644 --- a/apps/api/src/testing.ts +++ b/apps/api/src/testing.ts @@ -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 { }; } +export function validLead(overrides: Partial = {}): 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 = {}) { const db = openDb(':memory:'); const clock = { now: T0 }; diff --git a/apps/landing/src/app/globals.css b/apps/landing/src/app/globals.css index 9ae054f..844da17 100644 --- a/apps/landing/src/app/globals.css +++ b/apps/landing/src/app/globals.css @@ -80,7 +80,6 @@ font-family: var(--font-sans); font-size: var(--text-body); line-height: 1.55; - background: var(--color-cream); -webkit-tap-highlight-color: transparent; font-synthesis: none; } diff --git a/apps/landing/src/app/layout.tsx b/apps/landing/src/app/layout.tsx index ed0e572..8ee4934 100644 --- a/apps/landing/src/app/layout.tsx +++ b/apps/landing/src/app/layout.tsx @@ -44,7 +44,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} - + diff --git a/apps/landing/src/components/CtaButton.tsx b/apps/landing/src/components/CtaButton.tsx index debea74..16431ce 100644 --- a/apps/landing/src/components/CtaButton.tsx +++ b/apps/landing/src/components/CtaButton.tsx @@ -19,7 +19,6 @@ export function CtaButton({ interest, comment, source, className, children }: Ct