feat(api): /api/lead с SQLite и миграциями
db/: better-sqlite3 по DB_PATH (WAL, foreign_keys), миграции через PRAGMA user_version, схема leads и lead_clarifications по плану. createApp(deps) с инъекцией базы, часов и notifier (пока no-op, зовётся после ответа). POST /api/lead: только JSON (415), тело до 16 КБ (413), validateLead → 400, запись → 201. index.ts: env и graceful shutdown.
This commit is contained in:
+51
-2
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user