migration-requests.ts348 lines · main
1import { ValidationError } from '@briven/shared';
2import { Hono } from 'hono';
3
4import {
5 sendMigrationRequestCustomerConfirmation,
6 sendMigrationRequestOperatorAlert,
7} from '../lib/email.js';
8import { log } from '../lib/logger.js';
9import { ipKey, rateLimit } from '../middleware/rate-limit.js';
10import { requireAuth } from '../middleware/session.js';
11import { audit, hashIp, listAuditForMigrationRequest } from '../services/audit.js';
12import { trackMarketingEvent } from '../services/marketing-events.js';
13import {
14 createMigrationRequest,
15 deleteMigrationRequestForUser,
16 getMigrationRequest,
17 listMigrationRequestsForUser,
18} from '../services/migration-requests.js';
19import type { AppEnv } from '../types/app-env.js';
20
21/**
22 * Customer-facing migration request intake. Submitted from the dashboard
23 * wizard at /dashboard/projects/new/migrate. Each request is triaged by
24 * an operator via /dashboard/admin/migrations during beta; the planned
25 * adapter pipeline will eventually process supported sources without an
26 * operator in the loop.
27 */
28export const migrationRequestsRouter = new Hono<AppEnv>();
29
30migrationRequestsRouter.use('/v1/migration-requests', requireAuth());
31migrationRequestsRouter.use('/v1/migration-requests/*', requireAuth());
32
33migrationRequestsRouter.get('/v1/migration-requests', async (c) => {
34 const user = c.get('user')!;
35 const rows = await listMigrationRequestsForUser(user.id, { limit: 50 });
36 return c.json({ requests: rows.map(serialize) });
37});
38
39/**
40 * Customer-facing timeline for a single migration request. Returns
41 * the request itself plus the chronological list of audit events
42 * scoped to that request. Auth-gated by /v1/migration-requests/* and
43 * ownership-gated below (returns 404 when the request belongs to a
44 * different user — same shape as "doesn't exist" so the endpoint
45 * doesn't leak the existence of other users' requests).
46 */
47migrationRequestsRouter.get('/v1/migration-requests/:id', async (c) => {
48 const user = c.get('user')!;
49 const id = c.req.param('id');
50 let request;
51 try {
52 request = await getMigrationRequest(id);
53 } catch {
54 return c.json({ code: 'not_found' }, 404);
55 }
56 if (request.userId !== user.id) {
57 return c.json({ code: 'not_found' }, 404);
58 }
59 const timeline = await listAuditForMigrationRequest(id, 100);
60 return c.json({
61 request: serialize(request),
62 timeline: timeline.map((t) => ({
63 id: t.id,
64 action: t.action,
65 createdAt: t.createdAt.toISOString(),
66 // Filter the metadata for what's safe to surface to the customer:
67 // never the operator's user id, never the ip hash, never internal
68 // field names. Only the high-level signal of what changed.
69 metadata: {
70 source: typeof t.metadata?.source === 'string' ? t.metadata.source : null,
71 statusChanged:
72 typeof t.metadata?.statusChanged === 'boolean'
73 ? t.metadata.statusChanged
74 : t.action === 'migration_request.status_change'
75 ? true
76 : null,
77 newStatus:
78 typeof t.metadata?.newStatus === 'string'
79 ? t.metadata.newStatus
80 : null,
81 previousStatus:
82 typeof t.metadata?.previousStatus === 'string'
83 ? t.metadata.previousStatus
84 : null,
85 messageIncluded:
86 typeof t.metadata?.messageIncluded === 'boolean'
87 ? t.metadata.messageIncluded
88 : null,
89 linkedUserId: typeof t.metadata?.linkedUserId === 'string',
90 },
91 })),
92 });
93});
94
95/**
96 * Hard-delete a migration request the caller owns. 404 also covers
97 * "not yours" so we don't leak the existence of other users' requests.
98 */
99migrationRequestsRouter.delete('/v1/migration-requests/:id', async (c) => {
100 const user = c.get('user')!;
101 const id = c.req.param('id');
102 const ok = await deleteMigrationRequestForUser(id, user.id);
103 if (!ok) return c.json({ code: 'not_found' }, 404);
104 await audit({
105 actorId: user.id,
106 projectId: null,
107 action: 'migration_request.delete',
108 ipHash: hashIpFromReq(c.req.raw.headers.get('x-forwarded-for')),
109 userAgent: c.req.header('user-agent') ?? null,
110 metadata: { requestId: id },
111 });
112 return c.json({ ok: true });
113});
114
115migrationRequestsRouter.post('/v1/migration-requests', async (c) => {
116 const user = c.get('user')!;
117 const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null;
118 if (!body || typeof body !== 'object') {
119 return c.json({ code: 'validation_failed', message: 'JSON body required' }, 400);
120 }
121 try {
122 const request = await createMigrationRequest({
123 userId: user.id,
124 orgId: typeof body.orgId === 'string' ? body.orgId : null,
125 source: String(body.source ?? ''),
126 sourceUrl: typeof body.sourceUrl === 'string' ? body.sourceUrl : null,
127 sourceNotes: typeof body.sourceNotes === 'string' ? body.sourceNotes : '',
128 estimatedTables: toOptionalInt(body.estimatedTables),
129 estimatedRows: toOptionalBigint(body.estimatedRows),
130 estimatedFunctions: toOptionalInt(body.estimatedFunctions),
131 urgency: typeof body.urgency === 'string' ? body.urgency : 'exploring',
132 // Default the contact email to the signed-in user's address; the
133 // wizard sends this explicitly but we accept an empty/missing value
134 // and fall back so a curious user hitting the endpoint with curl
135 // still produces a usable record.
136 contactEmail:
137 typeof body.contactEmail === 'string' && body.contactEmail.trim()
138 ? body.contactEmail
139 : user.email,
140 });
141 await audit({
142 actorId: user.id,
143 projectId: null,
144 action: 'migration_request.create',
145 ipHash: hashIpFromReq(c.req.raw.headers.get('x-forwarded-for')),
146 userAgent: c.req.header('user-agent') ?? null,
147 metadata: { requestId: request.id, source: request.source },
148 });
149 // Fire-and-forget notifications. We never want a slow / unreachable
150 // mittera to block the POST response — the request is already
151 // persisted, so the operator can still triage from the dashboard
152 // even if the email pipeline is degraded.
153 const emailPayload = {
154 requestId: request.id,
155 source: request.source,
156 contactEmail: request.contactEmail,
157 sourceUrl: request.sourceUrl,
158 urgency: request.urgency,
159 estimatedTables: request.estimatedTables,
160 estimatedRows:
161 request.estimatedRows == null ? null : request.estimatedRows.toString(),
162 estimatedFunctions: request.estimatedFunctions,
163 sourceNotes: request.sourceNotes,
164 };
165 void sendMigrationRequestCustomerConfirmation(emailPayload).catch((err) => {
166 log.error('migration_request_customer_email_failed', {
167 requestId: request.id,
168 error: err instanceof Error ? err.message : String(err),
169 });
170 });
171 void sendMigrationRequestOperatorAlert(emailPayload).catch((err) => {
172 log.error('migration_request_operator_email_failed', {
173 requestId: request.id,
174 error: err instanceof Error ? err.message : String(err),
175 });
176 });
177 return c.json({ request: serialize(request) }, 201);
178 } catch (err) {
179 if (err instanceof ValidationError) {
180 return c.json({ code: 'validation_failed', message: err.message }, 400);
181 }
182 throw err;
183 }
184});
185
186/**
187 * Public, unauthenticated intake. Lives on a separate Hono router so
188 * the requireAuth middleware on /v1/migration-requests/* doesn't shadow
189 * it. Used by the /migrate marketing page + per-source pages so
190 * prospects can request a migration before creating an account. Rate-
191 * limited by IP (5/hour) to keep the endpoint from being a spam vector.
192 * Triaged by an operator identically to authed requests — the user_id
193 * column is null until the operator promotes the lead.
194 */
195export const migrationRequestsPublicRouter = new Hono<AppEnv>();
196
197migrationRequestsPublicRouter.post(
198 '/v1/migration-leads',
199 rateLimit({
200 scope: 'migration-public',
201 limit: 5,
202 windowMs: 60 * 60_000,
203 key: ipKey,
204 }),
205 async (c) => {
206 const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null;
207 if (!body || typeof body !== 'object') {
208 return c.json({ code: 'validation_failed', message: 'JSON body required' }, 400);
209 }
210 const contactEmail = typeof body.contactEmail === 'string' ? body.contactEmail.trim() : '';
211 if (!contactEmail) {
212 return c.json(
213 { code: 'validation_failed', message: 'contactEmail is required' },
214 400,
215 );
216 }
217 try {
218 const request = await createMigrationRequest({
219 userId: null,
220 source: String(body.source ?? ''),
221 sourceUrl: typeof body.sourceUrl === 'string' ? body.sourceUrl : null,
222 sourceNotes: typeof body.sourceNotes === 'string' ? body.sourceNotes : '',
223 urgency: typeof body.urgency === 'string' ? body.urgency : 'exploring',
224 contactEmail,
225 });
226 await audit({
227 actorId: null,
228 projectId: null,
229 action: 'migration_request.public_create',
230 ipHash: hashIpFromReq(c.req.raw.headers.get('x-forwarded-for')),
231 userAgent: c.req.header('user-agent') ?? null,
232 metadata: { requestId: request.id, source: request.source },
233 });
234 const emailPayload = {
235 requestId: request.id,
236 source: request.source,
237 contactEmail: request.contactEmail,
238 sourceUrl: request.sourceUrl,
239 urgency: request.urgency,
240 estimatedTables: request.estimatedTables,
241 estimatedRows:
242 request.estimatedRows == null ? null : request.estimatedRows.toString(),
243 estimatedFunctions: request.estimatedFunctions,
244 sourceNotes: request.sourceNotes,
245 };
246 void sendMigrationRequestCustomerConfirmation(emailPayload).catch((err) => {
247 log.error('migration_request_customer_email_failed', {
248 requestId: request.id,
249 error: err instanceof Error ? err.message : String(err),
250 });
251 });
252 void sendMigrationRequestOperatorAlert(emailPayload).catch((err) => {
253 log.error('migration_request_operator_email_failed', {
254 requestId: request.id,
255 error: err instanceof Error ? err.message : String(err),
256 });
257 });
258 // Funnel-tracking: server-side fire so a public lead-claim from
259 // a curl caller can't inflate the conversion count without
260 // actually creating a request.
261 void trackMarketingEvent({
262 eventType: 'migrate_lead_submitted',
263 source: request.source,
264 ipHash: hashIpFromReq(c.req.raw.headers.get('x-forwarded-for')),
265 userAgent: c.req.header('user-agent') ?? null,
266 });
267 // We deliberately return only the request id — no PII echo to a
268 // public endpoint, no leak of contact-email back to a curl caller.
269 return c.json({ requestId: request.id }, 201);
270 } catch (err) {
271 if (err instanceof ValidationError) {
272 return c.json({ code: 'validation_failed', message: err.message }, 400);
273 }
274 throw err;
275 }
276 },
277);
278
279function hashIpFromReq(forwarded: string | null): string | null {
280 const ip = forwarded ? forwarded.split(',')[0]!.trim() : null;
281 return hashIp(ip);
282}
283
284function toOptionalInt(value: unknown): number | null {
285 if (value == null || value === '') return null;
286 const n = Number(value);
287 if (!Number.isFinite(n)) return null;
288 return Math.trunc(n);
289}
290
291function toOptionalBigint(value: unknown): bigint | null {
292 if (value == null || value === '') return null;
293 if (typeof value === 'bigint') return value;
294 if (typeof value === 'number' && Number.isFinite(value)) return BigInt(Math.trunc(value));
295 if (typeof value === 'string') {
296 try {
297 return BigInt(value);
298 } catch {
299 return null;
300 }
301 }
302 return null;
303}
304
305interface SerializedRequest {
306 id: string;
307 source: string;
308 sourceUrl: string | null;
309 sourceNotes: string;
310 estimatedTables: number | null;
311 estimatedRows: string | null;
312 estimatedFunctions: number | null;
313 urgency: string;
314 status: string;
315 contactEmail: string;
316 createdAt: string;
317 updatedAt: string;
318}
319
320function serialize(row: {
321 id: string;
322 source: string;
323 sourceUrl: string | null;
324 sourceNotes: string;
325 estimatedTables: number | null;
326 estimatedRows: bigint | null;
327 estimatedFunctions: number | null;
328 urgency: string;
329 status: string;
330 contactEmail: string;
331 createdAt: Date;
332 updatedAt: Date;
333}): SerializedRequest {
334 return {
335 id: row.id,
336 source: row.source,
337 sourceUrl: row.sourceUrl,
338 sourceNotes: row.sourceNotes,
339 estimatedTables: row.estimatedTables,
340 estimatedRows: row.estimatedRows == null ? null : row.estimatedRows.toString(),
341 estimatedFunctions: row.estimatedFunctions,
342 urgency: row.urgency,
343 status: row.status,
344 contactEmail: row.contactEmail,
345 createdAt: row.createdAt.toISOString(),
346 updatedAt: row.updatedAt.toISOString(),
347 };
348}