diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts new file mode 100644 index 0000000..2251709 --- /dev/null +++ b/apps/api/src/app.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_BODY_BYTES } from './app.js'; +import { flush, setup, T0, validBody } from './testing.js'; + +describe('GET /api/health', () => { + it('отдаёт версию', async () => { + const { app } = setup({ version: 'abc123' }); + const res = await app.request('/api/health'); + expect(await res.json()).toEqual({ ok: true, version: 'abc123' }); + }); +}); + +describe('POST /api/lead', () => { + it('201: сохраняет заявку с нормализованным телефоном, касаниями и user agent', async () => { + const { db, post } = setup(); + const res = await post(validBody(), { 'user-agent': 'Mozilla/5.0 test' }); + + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ ok: true, duplicate: false }); + const row = db.prepare('SELECT * FROM leads').get() as Record; + expect(row).toMatchObject({ + id: 1, + created_at: T0.toISOString(), + phone: '+375291234567', + name: 'Аня', + interest: 'subscription', + comment: 'Под эспрессо', + user_agent: 'Mozilla/5.0 test', + tg_status: 'pending', + tg_attempts: 0, + }); + expect(JSON.parse(row.first_touch as string)).toMatchObject({ utm_source: 'instagram' }); + expect(JSON.parse(row.last_touch as string)).toMatchObject({ utm_source: 'yandex', submitPage: '/#form' }); + }); + + it('номер заявки клиенту не отдаёт', async () => { + const { post } = setup(); + const text = await (await post(validBody())).text(); + expect(text).toBe('{"ok":true,"duplicate":false}'); + }); + + it('notifier получает id заявки уже после ответа', async () => { + const { post, events } = setup(); + await post(validBody()); + expect(events).toEqual([]); + await flush(); + expect(events).toEqual([{ kind: 'lead', leadId: 1 }]); + }); + + it('упавший notifier не ломает ответ', async () => { + const { post, db } = setup({ + notifier: { + leadSaved: () => Promise.reject(new Error('telegram down')), + }, + }); + const res = await post(validBody()); + await flush(); + expect(res.status).toBe(201); + expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 1 }); + }); + + it('400 validation: ошибки по полям, ничего не пишет', async () => { + const { post, db, events } = setup(); + const res = await post(validBody({ name: ' ', phone: '+7 999 123-45-67' })); + expect(res.status).toBe(400); + const body = (await res.json()) as { fieldErrors: Record }; + expect(body).toMatchObject({ ok: false, error: 'validation' }); + expect(Object.keys(body.fieldErrors).sort()).toEqual(['name', 'phone']); + await flush(); + expect(events).toEqual([]); + expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 }); + }); + + it('400 bad_request: битый JSON', async () => { + const { post } = setup(); + const res = await post('{"name":'); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ ok: false, error: 'bad_request' }); + }); + + it('415: не JSON', async () => { + const { post } = setup(); + const res = await post('name=Аня', { 'content-type': 'application/x-www-form-urlencoded' }); + expect(res.status).toBe(415); + expect(await res.json()).toEqual({ ok: false, error: 'bad_request' }); + }); + + it('JSON с charset принимается', async () => { + const { post } = setup(); + const res = await post(validBody(), { 'content-type': 'application/json; charset=utf-8' }); + expect(res.status).toBe(201); + }); + + it('413: тело больше 16 КБ', async () => { + const { post, db } = setup(); + const res = await post(validBody({ comment: 'а'.repeat(MAX_BODY_BYTES) })); + expect(res.status).toBe(413); + expect(await res.json()).toEqual({ ok: false, error: 'bad_request' }); + expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 }); + }); + + it('500 server: исключение при записи', async () => { + const { post, db } = setup(); + db.close(); + const res = await post(validBody()); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ ok: false, error: 'server' }); + }); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 26d74b0..64d0a66 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,7 +1,56 @@ import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import { validateLead, type LeadResponse } from '@kvarta/shared/lead'; +import type { Db } from './db/index.js'; +import { saveLead } from './leads.js'; +import { notifyLater, type Notifier } from './notifier.js'; -export function createApp() { +export type AppDeps = { + db: Db; + now: () => Date; + notifier: Notifier; + version?: string; +}; + +export const MAX_BODY_BYTES = 16 * 1024; +const USER_AGENT_MAX = 500; + +export function createApp(deps: AppDeps) { const app = new Hono(); - app.get('/api/health', (c) => c.json({ ok: true, version: process.env.APP_VERSION ?? 'dev' })); + const reply = (body: LeadResponse, status: 200 | 201 | 400 | 413 | 415 | 500) => Response.json(body, { status }); + + app.onError((err) => { + console.error(err); + return reply({ ok: false, error: 'server' }, 500); + }); + + app.get('/api/health', (c) => c.json({ ok: true, version: deps.version ?? 'dev' })); + + app.post( + '/api/lead', + async (c, next) => { + const type = c.req.header('content-type')?.split(';')[0]?.trim().toLowerCase(); + if (type !== 'application/json') return reply({ ok: false, error: 'bad_request' }, 415); + await next(); + }, + bodyLimit({ maxSize: MAX_BODY_BYTES, onError: () => reply({ ok: false, error: 'bad_request' }, 413) }), + async (c) => { + let body: unknown; + try { + body = await c.req.json(); + } catch { + return reply({ ok: false, error: 'bad_request' }, 400); + } + + const result = validateLead(body); + if (!result.ok) return reply({ ok: false, error: 'validation', fieldErrors: result.fieldErrors }, 400); + + const userAgent = c.req.header('user-agent')?.slice(0, USER_AGENT_MAX) ?? null; + const saved = saveLead(deps.db, result.data, { now: deps.now(), userAgent }); + notifyLater(deps.notifier, saved.event); + return reply({ ok: true, duplicate: false }, 201); + }, + ); + return app; } diff --git a/apps/api/src/db/db.test.ts b/apps/api/src/db/db.test.ts new file mode 100644 index 0000000..ccf7a61 --- /dev/null +++ b/apps/api/src/db/db.test.ts @@ -0,0 +1,59 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; +import { migrate, openDb } from './index.js'; +import { MIGRATIONS } from './migrations.js'; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('openDb', () => { + it('создаёт каталог, включает WAL и внешние ключи, применяет миграции', () => { + const dir = mkdtempSync(join(tmpdir(), 'kvarta-db-')); + dirs.push(dir); + const path = join(dir, 'nested', 'kvarta.sqlite'); + + const db = openDb(path); + expect(existsSync(path)).toBe(true); + expect(db.pragma('journal_mode', { simple: true })).toBe('wal'); + expect(db.pragma('foreign_keys', { simple: true })).toBe(1); + expect(db.pragma('user_version', { simple: true })).toBe(MIGRATIONS.length); + db.close(); + + // Повторное открытие не пытается создать таблицы заново. + const again = openDb(path); + expect(again.pragma('user_version', { simple: true })).toBe(MIGRATIONS.length); + again.close(); + }); + + it('схема совпадает с планом', () => { + const db = openDb(':memory:'); + const columns = (table: string) => + (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name); + expect(columns('leads')).toEqual([ + 'id', 'created_at', 'phone', 'name', 'interest', 'comment', 'first_touch', 'last_touch', + 'user_agent', 'tg_status', 'tg_attempts', 'tg_last_error', 'tg_next_at', + ]); + expect(columns('lead_clarifications')).toEqual([ + 'id', 'lead_id', 'created_at', 'name', 'interest', 'comment', 'last_touch', + 'tg_status', 'tg_attempts', 'tg_last_error', 'tg_next_at', + ]); + expect(() => + db.prepare( + `INSERT INTO lead_clarifications (lead_id, created_at, name, interest, last_touch) VALUES (99, 'x', 'x', 'question', '{}')`, + ).run(), + ).toThrow(/FOREIGN KEY/); + db.close(); + }); + + it('отказывается работать с базой новее кода', () => { + const db = new Database(':memory:'); + db.pragma(`user_version = ${MIGRATIONS.length + 1}`); + expect(() => migrate(db)).toThrow(/новее кода/); + db.close(); + }); +}); diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts new file mode 100644 index 0000000..be737e0 --- /dev/null +++ b/apps/api/src/db/index.ts @@ -0,0 +1,37 @@ +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import Database from 'better-sqlite3'; +import { MIGRATIONS } from './migrations.js'; + +export type Db = Database.Database; + +export const DEFAULT_DB_PATH = './data/kvarta.sqlite'; + +/** Открывает базу и доводит схему до текущей версии. `:memory:` — для тестов. */ +export function openDb(path: string = DEFAULT_DB_PATH): Db { + if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true }); + const db = new Database(path); + try { + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + migrate(db); + } catch (err) { + db.close(); + throw err; + } + return db; +} + +export function migrate(db: Db): void { + const current = db.pragma('user_version', { simple: true }) as number; + // База новее кода (свежий бэкап на старой версии) — откатывать схему не умеем, лучше не стартовать. + if (current > MIGRATIONS.length) { + throw new Error(`Схема базы v${current} новее кода (v${MIGRATIONS.length})`); + } + for (let version = current; version < MIGRATIONS.length; version++) { + db.transaction(() => { + db.exec(MIGRATIONS[version]!); + db.pragma(`user_version = ${version + 1}`); + })(); + } +} diff --git a/apps/api/src/db/migrations.ts b/apps/api/src/db/migrations.ts new file mode 100644 index 0000000..2e0dd0b --- /dev/null +++ b/apps/api/src/db/migrations.ts @@ -0,0 +1,36 @@ +// Миграции по порядку: индекс в массиве + 1 = PRAGMA user_version после применения. +// Уже выкаченные миграции не меняем — только дописываем новые в конец. +export const MIGRATIONS: readonly string[] = [ + ` + CREATE TABLE leads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + phone TEXT NOT NULL, + name TEXT NOT NULL, + interest TEXT NOT NULL CHECK (interest IN ('subscription','one_time','question')), + comment TEXT, + first_touch TEXT, + last_touch TEXT NOT NULL, + user_agent TEXT, + tg_status TEXT NOT NULL DEFAULT 'pending' CHECK (tg_status IN ('pending','sent','failed')), + tg_attempts INTEGER NOT NULL DEFAULT 0, + tg_last_error TEXT, + tg_next_at TEXT + ); + CREATE INDEX leads_phone_created ON leads(phone, created_at); + + CREATE TABLE lead_clarifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lead_id INTEGER NOT NULL REFERENCES leads(id), + created_at TEXT NOT NULL, + name TEXT NOT NULL, + interest TEXT NOT NULL, + comment TEXT, + last_touch TEXT NOT NULL, + tg_status TEXT NOT NULL DEFAULT 'pending' CHECK (tg_status IN ('pending','sent','failed')), + tg_attempts INTEGER NOT NULL DEFAULT 0, + tg_last_error TEXT, + tg_next_at TEXT + ); + `, +]; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c6bbccc..045a784 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,7 +1,38 @@ import { serve } from '@hono/node-server'; import { createApp } from './app.js'; +import { DEFAULT_DB_PATH, openDb } from './db/index.js'; +import { noopNotifier } from './notifier.js'; const port = Number(process.env.PORT ?? 3001); -serve({ fetch: createApp().fetch, port }, (info) => { +const db = openDb(process.env.DB_PATH ?? DEFAULT_DB_PATH); + +const app = createApp({ + db, + now: () => new Date(), + notifier: noopNotifier, + version: process.env.APP_VERSION, +}); + +const server = serve({ fetch: app.fetch, port }, (info) => { console.log(`api listening on :${info.port}`); }); + +let stopping = false; +function shutdown(signal: string) { + if (stopping) return; + stopping = true; + console.log(`${signal}: останавливаемся`); + // Сначала дожидаемся текущих запросов, потом закрываем базу — иначе запись оборвётся на полуслове. + server.close(() => { + db.close(); + process.exit(0); + }); + setTimeout(() => { + console.error('не дождались запросов, выходим'); + db.close(); + process.exit(1); + }, 10_000).unref(); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/apps/api/src/leads.ts b/apps/api/src/leads.ts new file mode 100644 index 0000000..404bc87 --- /dev/null +++ b/apps/api/src/leads.ts @@ -0,0 +1,26 @@ +import type { ValidLead } from '@kvarta/shared/lead'; +import type { Db } from './db/index.js'; +import type { LeadEvent } from './notifier.js'; + +export type SaveMeta = { now: Date; userAgent: string | null }; + +export type SaveResult = { kind: 'created'; event: LeadEvent }; + +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) } }; +} diff --git a/apps/api/src/notifier.ts b/apps/api/src/notifier.ts new file mode 100644 index 0000000..d0e00e3 --- /dev/null +++ b/apps/api/src/notifier.ts @@ -0,0 +1,19 @@ +// Что сообщить менеджеру после записи в базу. Только id: текст и статус отправки +// очередь берёт из строки (tg_*), поэтому событие можно потерять — строка останется pending. +export type LeadEvent = { kind: 'lead'; leadId: number }; + +export interface Notifier { + /** Зовётся после ответа клиенту; ошибки и долгие промисы на ответ не влияют. */ + leadSaved(event: LeadEvent): void | Promise; +} + +export const noopNotifier: Notifier = { leadSaved() {} }; + +/** Отложить вызов до отправки ответа и проглотить ошибку: заявка уже в базе. */ +export function notifyLater(notifier: Notifier, event: LeadEvent): void { + setImmediate(() => { + Promise.resolve() + .then(() => notifier.leadSaved(event)) + .catch((err: unknown) => console.error('notifier failed', event, err)); + }); +} diff --git a/apps/api/src/testing.ts b/apps/api/src/testing.ts new file mode 100644 index 0000000..d84ece2 --- /dev/null +++ b/apps/api/src/testing.ts @@ -0,0 +1,43 @@ +// Общие заготовки для тестов api: база в памяти, управляемые часы, notifier-шпион. +import type { LeadRequest } from '@kvarta/shared/lead'; +import { createApp, type AppDeps } from './app.js'; +import { openDb } from './db/index.js'; +import type { LeadEvent, Notifier } from './notifier.js'; + +export const T0 = new Date('2026-10-17T12:00:00.000Z'); + +export function validBody(overrides: Partial = {}): LeadRequest { + return { + name: 'Аня', + phone: '+375 29 123-45-67', + interest: 'subscription', + comment: 'Под эспрессо', + firstTouch: { utm_source: 'instagram', landingPage: '/', at: '2026-10-10T10:00:00.000Z' }, + lastTouch: { utm_source: 'yandex', landingPage: '/', submitPage: '/#form', at: '2026-10-17T11:59:00.000Z' }, + ...overrides, + }; +} + +export function setup(overrides: Partial = {}) { + const db = openDb(':memory:'); + const clock = { now: T0 }; + const events: LeadEvent[] = []; + const notifier: Notifier = { leadSaved: (e) => void events.push(e) }; + const app = createApp({ db, now: () => clock.now, notifier, ...overrides }); + + const post = (body: unknown, headers: Record = {}) => + app.request('/api/lead', { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); + + const advance = (ms: number) => { + clock.now = new Date(clock.now.getTime() + ms); + }; + + return { app, db, clock, events, post, advance }; +} + +/** notifier зовётся через setImmediate — ждём, пока очередь событий опустеет. */ +export const flush = () => new Promise((resolve) => setImmediate(resolve));