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
+2
View File
@@ -47,7 +47,9 @@ export function createApp(deps: AppDeps) {
const userAgent = c.req.header('user-agent')?.slice(0, USER_AGENT_MAX) ?? null; const userAgent = c.req.header('user-agent')?.slice(0, USER_AGENT_MAX) ?? null;
const saved = saveLead(deps.db, result.data, { now: deps.now(), userAgent }); const saved = saveLead(deps.db, result.data, { now: deps.now(), userAgent });
if (saved.kind === 'unchanged') return reply({ ok: true, duplicate: true, clarified: false }, 200);
notifyLater(deps.notifier, saved.event); notifyLater(deps.notifier, saved.event);
if (saved.kind === 'clarified') return reply({ ok: true, duplicate: true, clarified: true }, 200);
return reply({ ok: true, duplicate: false }, 201); return reply({ ok: true, duplicate: false }, 201);
}, },
); );
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import { CLARIFY_WINDOW_MS } from './leads.js';
import { 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();
await post(validBody());
await flush();
advance(3 * MINUTE);
// Телефон в другом формате — тот же номер после нормализации; касание другое — не считается.
const res = await post(
validBody({ phone: '80291234567', lastTouch: { landingPage: '/b', at: '2026-10-17T12:03:00.000Z' } }),
);
await flush();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true, duplicate: true, clarified: false });
expect(count(db, 'leads')).toBe(1);
expect(count(db, 'lead_clarifications')).toBe(0);
expect(events).toEqual([{ kind: 'lead', leadId: 1 }]);
});
it('изменённые поля: строка в lead_clarifications и событие с изменившимися полями', async () => {
const { post, db, events, advance } = setup();
await post(validBody());
advance(2 * MINUTE);
const res = await post(validBody({ interest: 'one_time', comment: 'Лучше разовый' }));
await flush();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true, duplicate: true, clarified: true });
expect(count(db, 'leads')).toBe(1);
expect(db.prepare('SELECT lead_id, interest, comment, tg_status FROM lead_clarifications').get()).toEqual({
lead_id: 1,
interest: 'one_time',
comment: 'Лучше разовый',
tg_status: 'pending',
});
expect(events[1]).toEqual({ kind: 'clarification', leadId: 1, clarificationId: 1, changed: ['interest', 'comment'] });
});
it('сравнение идёт с последним уточнением, а не с исходной заявкой', async () => {
const { post, db, events, advance } = setup();
await post(validBody());
advance(MINUTE);
await post(validBody({ name: 'Анна' }));
advance(MINUTE);
// Повтор уточнения — не новое уточнение.
const same = await post(validBody({ name: 'Анна' }));
advance(MINUTE);
// Возврат к исходному имени — это изменение относительно последней версии.
await post(validBody({ name: 'Аня', comment: undefined }));
await flush();
expect(await same.json()).toMatchObject({ clarified: false });
expect(count(db, 'lead_clarifications')).toBe(2);
expect(events.slice(1).map((e) => (e.kind === 'clarification' ? e.changed : null))).toEqual([
['name'],
['name', 'comment'],
]);
});
it('после окна — новая заявка; уточнения окно не продлевают', async () => {
const { post, db, events, advance } = setup();
await post(validBody());
advance(9 * MINUTE);
await post(validBody({ comment: 'Уточняю' }));
advance(2 * MINUTE);
const res = await post(validBody({ comment: 'Ещё раз' }));
await flush();
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ ok: true, duplicate: false });
expect(count(db, 'leads')).toBe(2);
expect(count(db, 'lead_clarifications')).toBe(1);
expect(events.at(-1)).toEqual({ kind: 'lead', leadId: 2 });
});
it('граница: за миллисекунду до 10 минут — уточнение, ровно 10 минут — новая заявка', async () => {
const before = setup();
await before.post(validBody());
before.advance(CLARIFY_WINDOW_MS - 1);
expect((await before.post(validBody({ comment: 'x' }))).status).toBe(200);
const exact = setup();
await exact.post(validBody());
exact.advance(CLARIFY_WINDOW_MS);
expect((await exact.post(validBody({ comment: 'x' }))).status).toBe(201);
expect(count(exact.db, 'leads')).toBe(2);
});
it('другой телефон в окне — отдельная заявка', async () => {
const { post, db } = setup();
await post(validBody());
const res = await post(validBody({ phone: '+375 44 765-43-21' }));
expect(res.status).toBe(201);
expect(count(db, 'leads')).toBe(2);
});
it('два одновременных запроса дают одну заявку', async () => {
const { post, db, events } = setup();
const responses = await Promise.all([post(validBody()), post(validBody())]);
await flush();
expect(responses.map((r) => r.status).sort()).toEqual([200, 201]);
expect(count(db, 'leads')).toBe(1);
expect(count(db, 'lead_clarifications')).toBe(0);
expect(events).toEqual([{ kind: 'lead', leadId: 1 }]);
});
});
+58 -3
View File
@@ -1,19 +1,49 @@
import type { ValidLead } from '@kvarta/shared/lead'; import type { ValidLead } from '@kvarta/shared/lead';
import type { Db } from './db/index.js'; 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 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 { 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 const { lastInsertRowid } = db
.prepare( .prepare(
`INSERT INTO leads (created_at, phone, name, interest, comment, first_touch, last_touch, user_agent) `INSERT INTO leads (created_at, phone, name, interest, comment, first_touch, last_touch, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
) )
.run( .run(
meta.now.toISOString(), nowIso,
lead.phone, lead.phone,
lead.name, lead.name,
lead.interest, lead.interest,
@@ -23,4 +53,29 @@ export function saveLead(db: Db, lead: ValidLead, meta: SaveMeta): SaveResult {
meta.userAgent, meta.userAgent,
); );
return { kind: 'created', event: { kind: 'lead', leadId: Number(lastInsertRowid) } }; 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();
} }
+6 -1
View File
@@ -1,6 +1,11 @@
// Что сообщить менеджеру после записи в базу. Только id: текст и статус отправки // Что сообщить менеджеру после записи в базу. Только id: текст и статус отправки
// очередь берёт из строки (tg_*), поэтому событие можно потерять — строка останется pending. // очередь берёт из строки (tg_*), поэтому событие можно потерять — строка останется pending.
export type LeadEvent = { kind: 'lead'; leadId: number }; export type ClarifiedField = 'name' | 'interest' | 'comment';
export type LeadEvent =
| { kind: 'lead'; leadId: number }
/** changed — поля, отличающиеся от предыдущей версии (прошлого уточнения или самой заявки). */
| { kind: 'clarification'; leadId: number; clarificationId: number; changed: ClarifiedField[] };
export interface Notifier { export interface Notifier {
/** Зовётся после ответа клиенту; ошибки и долгие промисы на ответ не влияют. */ /** Зовётся после ответа клиенту; ошибки и долгие промисы на ответ не влияют. */