migration-requests.ts348 lines · main
| 1 | import { ValidationError } from '@briven/shared'; |
| 2 | import { Hono } from 'hono'; |
| 3 | |
| 4 | import { |
| 5 | sendMigrationRequestCustomerConfirmation, |
| 6 | sendMigrationRequestOperatorAlert, |
| 7 | } from '../lib/email.js'; |
| 8 | import { log } from '../lib/logger.js'; |
| 9 | import { ipKey, rateLimit } from '../middleware/rate-limit.js'; |
| 10 | import { requireAuth } from '../middleware/session.js'; |
| 11 | import { audit, hashIp, listAuditForMigrationRequest } from '../services/audit.js'; |
| 12 | import { trackMarketingEvent } from '../services/marketing-events.js'; |
| 13 | import { |
| 14 | createMigrationRequest, |
| 15 | deleteMigrationRequestForUser, |
| 16 | getMigrationRequest, |
| 17 | listMigrationRequestsForUser, |
| 18 | } from '../services/migration-requests.js'; |
| 19 | import 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 | */ |
| 28 | export const migrationRequestsRouter = new Hono<AppEnv>(); |
| 29 | |
| 30 | migrationRequestsRouter.use('/v1/migration-requests', requireAuth()); |
| 31 | migrationRequestsRouter.use('/v1/migration-requests/*', requireAuth()); |
| 32 | |
| 33 | migrationRequestsRouter.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 | */ |
| 47 | migrationRequestsRouter.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 | */ |
| 99 | migrationRequestsRouter.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 | |
| 115 | migrationRequestsRouter.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 | */ |
| 195 | export const migrationRequestsPublicRouter = new Hono<AppEnv>(); |
| 196 | |
| 197 | migrationRequestsPublicRouter.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 | |
| 279 | function hashIpFromReq(forwarded: string | null): string | null { |
| 280 | const ip = forwarded ? forwarded.split(',')[0]!.trim() : null; |
| 281 | return hashIp(ip); |
| 282 | } |
| 283 | |
| 284 | function 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 | |
| 291 | function 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 | |
| 305 | interface 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 | |
| 320 | function 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 | } |