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 вместо повторов имён
87 lines
3.5 KiB
TypeScript
87 lines
3.5 KiB
TypeScript
import type { Interest, ValidLead } from '@kvarta/shared/lead';
|
|
import type { Db } from './db/index.js';
|
|
import type { ClarifiedField, LeadEvent } from './notifier.js';
|
|
|
|
/** Окно уточнений: от created_at заявки, уточнения его не продлевают. */
|
|
export const CLARIFY_WINDOW_MS = 10 * 60 * 1000;
|
|
|
|
export type SaveMeta = { now: Date; userAgent: string | null };
|
|
|
|
export type SaveResult =
|
|
| { kind: 'created'; event: LeadEvent }
|
|
| { kind: 'clarified'; event: LeadEvent }
|
|
| { kind: 'unchanged'; leadId: number };
|
|
|
|
/** Версия заявки: сама заявка или уточнение к ней. */
|
|
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-транзакции: два одновременных запроса с одного телефона
|
|
* не создадут две заявки, даже если процессов станет больше одного.
|
|
*/
|
|
export function saveLead(db: Db, lead: ValidLead, meta: SaveMeta): SaveResult {
|
|
const nowIso = meta.now.toISOString();
|
|
// Время хранится в одном формате toISOString, поэтому строки сравниваются как даты.
|
|
const windowStart = new Date(meta.now.getTime() - CLARIFY_WINDOW_MS).toISOString();
|
|
|
|
return db
|
|
.transaction((): SaveResult => {
|
|
const open = db
|
|
.prepare(
|
|
`SELECT id, name, interest, comment FROM leads
|
|
WHERE phone = ? AND created_at > ?
|
|
ORDER BY created_at DESC, id DESC LIMIT 1`,
|
|
)
|
|
.get(lead.phone, windowStart) as ({ id: number } & Version) | undefined;
|
|
|
|
if (!open) {
|
|
const { lastInsertRowid } = db
|
|
.prepare(
|
|
`INSERT INTO leads (created_at, phone, name, interest, comment, first_touch, last_touch, user_agent)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
nowIso,
|
|
lead.phone,
|
|
lead.name,
|
|
lead.interest,
|
|
lead.comment,
|
|
lead.firstTouch ? JSON.stringify(lead.firstTouch) : null,
|
|
JSON.stringify(lead.lastTouch),
|
|
meta.userAgent,
|
|
);
|
|
return { kind: 'created', event: { kind: 'lead', leadId: Number(lastInsertRowid) } };
|
|
}
|
|
|
|
const previous =
|
|
(db
|
|
.prepare(
|
|
`SELECT name, interest, comment FROM lead_clarifications
|
|
WHERE lead_id = ? ORDER BY id DESC LIMIT 1`,
|
|
)
|
|
.get(open.id) as Version | undefined) ?? open;
|
|
|
|
const changed = changedFields(previous, lead);
|
|
if (changed.length === 0) return { kind: 'unchanged', leadId: open.id };
|
|
|
|
const { lastInsertRowid } = db
|
|
.prepare(
|
|
`INSERT INTO lead_clarifications (lead_id, created_at, name, interest, comment, last_touch)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(open.id, nowIso, lead.name, lead.interest, lead.comment, JSON.stringify(lead.lastTouch));
|
|
return {
|
|
kind: 'clarified',
|
|
event: { kind: 'clarification', leadId: open.id, clarificationId: Number(lastInsertRowid), changed },
|
|
};
|
|
})
|
|
.immediate();
|
|
}
|