feat(api): уточнения в окне 10 минут
saveLead в одной IMMEDIATE-транзакции: заявка с тем же телефоном моложе 10 минут → уточнение, если имя, интерес или комментарий отличаются от последней версии, иначе повтор без записи. Окно от created_at заявки, уточнения его не продлевают. Ответ 200 duplicate, notifier получает id уточнения и список изменившихся полей.
This commit is contained in:
@@ -47,7 +47,9 @@ export function createApp(deps: AppDeps) {
|
||||
|
||||
const userAgent = c.req.header('user-agent')?.slice(0, USER_AGENT_MAX) ?? null;
|
||||
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);
|
||||
if (saved.kind === 'clarified') return reply({ ok: true, duplicate: true, clarified: true }, 200);
|
||||
return reply({ ok: true, duplicate: false }, 201);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -1,19 +1,49 @@
|
||||
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 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(
|
||||
meta.now.toISOString(),
|
||||
nowIso,
|
||||
lead.phone,
|
||||
lead.name,
|
||||
lead.interest,
|
||||
@@ -24,3 +54,28 @@ export function saveLead(db: Db, lead: ValidLead, meta: SaveMeta): SaveResult {
|
||||
);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Что сообщить менеджеру после записи в базу. Только id: текст и статус отправки
|
||||
// очередь берёт из строки (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 {
|
||||
/** Зовётся после ответа клиенту; ошибки и долгие промисы на ответ не влияют. */
|
||||
|
||||
Reference in New Issue
Block a user