diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 64d0a66..ccc019b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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); }, ); diff --git a/apps/api/src/leads.test.ts b/apps/api/src/leads.test.ts new file mode 100644 index 0000000..f2ca549 --- /dev/null +++ b/apps/api/src/leads.test.ts @@ -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['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 }]); + }); +}); diff --git a/apps/api/src/leads.ts b/apps/api/src/leads.ts index 404bc87..1d683af 100644 --- a/apps/api/src/leads.ts +++ b/apps/api/src/leads.ts @@ -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(); } diff --git a/apps/api/src/notifier.ts b/apps/api/src/notifier.ts index d0e00e3..ee59c9b 100644 --- a/apps/api/src/notifier.ts +++ b/apps/api/src/notifier.ts @@ -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 { /** Зовётся после ответа клиенту; ошибки и долгие промисы на ответ не влияют. */