feat(api): уточнения в окне 10 минут

saveLead в одной IMMEDIATE-транзакции: заявка с тем же телефоном
моложе 10 минут → уточнение, если имя, интерес или комментарий
отличаются от последней версии, иначе повтор без записи. Окно от
created_at заявки, уточнения его не продлевают. Ответ 200 duplicate,
notifier получает id уточнения и список изменившихся полей.
This commit is contained in:
2026-10-17 15:39:31 +03:00
parent fa0964b24d
commit c20e40c333
4 changed files with 199 additions and 19 deletions
+73 -18
View File
@@ -1,26 +1,81 @@
import type { ValidLead } from '@kvarta/shared/lead';
import type { Db } from './db/index.js';
import type { LeadEvent } from './notifier.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 };
export type SaveResult =
| { kind: 'created'; event: LeadEvent }
| { kind: 'clarified'; event: LeadEvent }
| { kind: 'unchanged'; leadId: number };
type Version = { name: string; interest: string; comment: string | null };
const CLARIFIED_FIELDS: readonly ClarifiedField[] = ['name', 'interest', 'comment'];
/**
* Новая заявка, уточнение к заявке из окна или повтор без изменений.
* Поиск и запись в одной IMMEDIATE-транзакции: два одновременных запроса с одного телефона
* не создадут две заявки, даже если процессов станет больше одного.
*/
export function saveLead(db: Db, lead: ValidLead, meta: SaveMeta): SaveResult {
const { lastInsertRowid } = db
.prepare(
`INSERT INTO leads (created_at, phone, name, interest, comment, first_touch, last_touch, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
meta.now.toISOString(),
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 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 = CLARIFIED_FIELDS.filter((field) => previous[field] !== lead[field]);
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();
}