marketing-events.ts45 lines · main
1import { Hono } from 'hono';
2
3import { ipKey, rateLimit } from '../middleware/rate-limit.js';
4import { hashIp } from '../services/audit.js';
5import { trackMarketingEvent } from '../services/marketing-events.js';
6import type { AppEnv } from '../types/app-env.js';
7
8/**
9 * Public beacon for marketing funnel tracking. Receives `migrate_view`
10 * events from the /migrate and /migrate/<source> marketing pages.
11 *
12 * Rate-limited per IP to keep the endpoint from being a write-amp
13 * vector against the meta-DB. Lead-submit events are NOT fired through
14 * this surface — they fire server-side from the POST /v1/migration-leads
15 * handler so we don't trust a public POST to claim a conversion.
16 */
17export const marketingEventsPublicRouter = new Hono<AppEnv>();
18
19marketingEventsPublicRouter.post(
20 '/v1/marketing-events',
21 rateLimit({
22 scope: 'marketing-events',
23 limit: 30,
24 windowMs: 60_000,
25 key: ipKey,
26 }),
27 async (c) => {
28 const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null;
29 if (!body) return c.json({ ok: true });
30 const eventType = typeof body.eventType === 'string' ? body.eventType : '';
31 const source = typeof body.source === 'string' ? body.source : '';
32 // We only accept page-view events through the public surface;
33 // conversions can only be claimed server-side from the lead route.
34 if (eventType !== 'migrate_view') return c.json({ ok: true });
35 const fwd = c.req.raw.headers.get('cf-connecting-ip') ?? c.req.raw.headers.get('x-forwarded-for');
36 const ip = fwd ? fwd.split(',')[0]!.trim() : null;
37 await trackMarketingEvent({
38 eventType,
39 source,
40 ipHash: hashIp(ip),
41 userAgent: c.req.header('user-agent') ?? null,
42 });
43 return c.json({ ok: true });
44 },
45);