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 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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
@@ -24,3 +54,28 @@ export function saveLead(db: Db, lead: ValidLead, meta: SaveMeta): SaveResult {
|
|||||||
);
|
);
|
||||||
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
/** Зовётся после ответа клиенту; ошибки и долгие промисы на ответ не влияют. */
|
/** Зовётся после ответа клиенту; ошибки и долгие промисы на ответ не влияют. */
|
||||||
|
|||||||
Reference in New Issue
Block a user