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
+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}`);