migration-request-row.tsx394 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import { useRouter } from 'next/navigation'; |
| 4 | import { useState, useTransition } from 'react'; |
| 5 | |
| 6 | import { StepUpPrompt } from '@/components/step-up-prompt'; |
| 7 | import { toValidDate } from '@/lib/utils'; |
| 8 | import { ConvexTranslator } from './convex-translator'; |
| 9 | |
| 10 | interface AdminRequest { |
| 11 | id: string; |
| 12 | userId: string | null; |
| 13 | source: string; |
| 14 | sourceUrl: string | null; |
| 15 | sourceNotes: string; |
| 16 | estimatedTables: number | null; |
| 17 | estimatedRows: string | null; |
| 18 | estimatedFunctions: number | null; |
| 19 | urgency: string; |
| 20 | status: string; |
| 21 | contactEmail: string; |
| 22 | operatorNotes: string; |
| 23 | createdAt: string; |
| 24 | updatedAt: string; |
| 25 | } |
| 26 | |
| 27 | type Status = 'new' | 'contacted' | 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; |
| 28 | |
| 29 | const STATUS_OPTIONS: readonly Status[] = [ |
| 30 | 'new', |
| 31 | 'contacted', |
| 32 | 'scheduled', |
| 33 | 'in_progress', |
| 34 | 'completed', |
| 35 | 'cancelled', |
| 36 | ]; |
| 37 | |
| 38 | // Pre-baked operator reply templates per target status. {{source}} is |
| 39 | // substituted with the request's source platform (convex / supabase / |
| 40 | // etc.) so the operator's first draft already names the platform the |
| 41 | // customer is coming from. The operator edits these before sending — |
| 42 | // they're starting points, not final copy. Empty for 'new' since the |
| 43 | // confirmation email already fires on create. |
| 44 | const STATUS_TEMPLATES: Record<Status, string> = { |
| 45 | new: '', |
| 46 | contacted: |
| 47 | "hi! thanks for the {{source}} migration request. we'd like to schedule a quick 15-min call to scope your data + auth specifics. what times work for you in the next 2–3 business days?", |
| 48 | scheduled: |
| 49 | "great — we've scheduled your {{source}} migration for [date / time]. you'll get a calendar invite shortly. nothing changes on your end until we kick off; your {{source}} keeps serving traffic the entire time.", |
| 50 | in_progress: |
| 51 | "we're starting your {{source}} migration now. expected duration: ~[X] hours. your current platform stays serving traffic throughout. you'll get another email the moment we're done and your data is ready to verify.", |
| 52 | completed: |
| 53 | "your {{source}} migration is complete on the briven side. please open the dashboard to verify your data — row counts, sample queries, anything that matters. when you're ready to flip writes from {{source}} to briven, the cutover button is one click. happy to walk you through it on a call.", |
| 54 | cancelled: |
| 55 | "no problem — we've cancelled this migration request. nothing changed on your {{source}}. if your situation changes, just reply to this email and we'll pick it right back up.", |
| 56 | }; |
| 57 | |
| 58 | interface Props { |
| 59 | request: AdminRequest; |
| 60 | apiOrigin: string; |
| 61 | } |
| 62 | |
| 63 | interface PatchPayload { |
| 64 | status?: Status; |
| 65 | operatorNotes?: string; |
| 66 | messageToCustomer?: string; |
| 67 | } |
| 68 | |
| 69 | function applyTemplate(template: string, source: string): string { |
| 70 | return template.replace(/\{\{source\}\}/g, source); |
| 71 | } |
| 72 | |
| 73 | export function MigrationRequestRow({ request, apiOrigin }: Props) { |
| 74 | const router = useRouter(); |
| 75 | const [busy, setBusy] = useState(false); |
| 76 | const [error, setError] = useState<string | null>(null); |
| 77 | const [pendingPayload, setPendingPayload] = useState<PatchPayload | null>(null); |
| 78 | const [editingNotes, setEditingNotes] = useState(false); |
| 79 | const [notes, setNotes] = useState(request.operatorNotes); |
| 80 | // Two-step status change: picking a new status opens a panel where |
| 81 | // the operator confirms + edits the customer-facing message before |
| 82 | // the PATCH fires. Skips the panel for transitions where there's |
| 83 | // nothing meaningful to say (e.g., back to 'new'). |
| 84 | const [stagedStatus, setStagedStatus] = useState<Status | null>(null); |
| 85 | const [messageToCustomer, setMessageToCustomer] = useState(''); |
| 86 | const [, startTransition] = useTransition(); |
| 87 | |
| 88 | async function patch(payload: PatchPayload) { |
| 89 | setBusy(true); |
| 90 | setError(null); |
| 91 | try { |
| 92 | const res = await fetch(`${apiOrigin}/v1/admin/migration-requests/${request.id}`, { |
| 93 | method: 'PATCH', |
| 94 | credentials: 'include', |
| 95 | headers: { 'content-type': 'application/json' }, |
| 96 | body: JSON.stringify(payload), |
| 97 | }); |
| 98 | if (res.status === 403) { |
| 99 | const body = (await res.json().catch(() => null)) as { code?: string } | null; |
| 100 | if (body?.code === 'step_up_required') { |
| 101 | setPendingPayload(payload); |
| 102 | return; |
| 103 | } |
| 104 | } |
| 105 | if (!res.ok) { |
| 106 | const body = await res.text().catch(() => ''); |
| 107 | throw new Error(body || `update failed: ${res.status}`); |
| 108 | } |
| 109 | setEditingNotes(false); |
| 110 | setStagedStatus(null); |
| 111 | setMessageToCustomer(''); |
| 112 | startTransition(() => router.refresh()); |
| 113 | } catch (err) { |
| 114 | setError(err instanceof Error ? err.message : 'update failed'); |
| 115 | } finally { |
| 116 | setBusy(false); |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | // Sentinel-keyed retry: step-up failure on promote stores `__promote` |
| 121 | // in pendingPayload so the post-step-up handler knows to re-call |
| 122 | // promoteToUser() rather than patch(). |
| 123 | const PROMOTE_SENTINEL = '__promote' as const; |
| 124 | |
| 125 | async function promoteToUser() { |
| 126 | setBusy(true); |
| 127 | setError(null); |
| 128 | try { |
| 129 | const res = await fetch( |
| 130 | `${apiOrigin}/v1/admin/migration-requests/${request.id}/promote-to-user`, |
| 131 | { |
| 132 | method: 'POST', |
| 133 | credentials: 'include', |
| 134 | }, |
| 135 | ); |
| 136 | if (res.status === 403) { |
| 137 | const body = (await res.json().catch(() => null)) as { code?: string } | null; |
| 138 | if (body?.code === 'step_up_required') { |
| 139 | setPendingPayload({ messageToCustomer: PROMOTE_SENTINEL }); |
| 140 | return; |
| 141 | } |
| 142 | } |
| 143 | if (!res.ok) { |
| 144 | const body = await res.text().catch(() => ''); |
| 145 | throw new Error(body || `promote failed: ${res.status}`); |
| 146 | } |
| 147 | const body = (await res.json()) as { linkedUserId: string | null }; |
| 148 | if (!body.linkedUserId) { |
| 149 | setError( |
| 150 | 'no briven user exists with this email yet. ask the customer to sign up, then try again.', |
| 151 | ); |
| 152 | } else { |
| 153 | startTransition(() => router.refresh()); |
| 154 | } |
| 155 | } catch (err) { |
| 156 | setError(err instanceof Error ? err.message : 'promote failed'); |
| 157 | } finally { |
| 158 | setBusy(false); |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | function pickStatus(next: Status) { |
| 163 | if (next === request.status) return; |
| 164 | setStagedStatus(next); |
| 165 | const template = STATUS_TEMPLATES[next]; |
| 166 | setMessageToCustomer(template ? applyTemplate(template, request.source) : ''); |
| 167 | } |
| 168 | |
| 169 | function cancelStagedStatus() { |
| 170 | setStagedStatus(null); |
| 171 | setMessageToCustomer(''); |
| 172 | } |
| 173 | |
| 174 | function sendStatusUpdate() { |
| 175 | if (!stagedStatus) return; |
| 176 | void patch({ |
| 177 | status: stagedStatus, |
| 178 | ...(messageToCustomer.trim() ? { messageToCustomer: messageToCustomer.trim() } : {}), |
| 179 | }); |
| 180 | } |
| 181 | |
| 182 | return ( |
| 183 | <li className="flex flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"> |
| 184 | <div className="flex flex-wrap items-center gap-2"> |
| 185 | <span className={`rounded-full border px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider ${urgencyTone(request.urgency)}`}> |
| 186 | {request.urgency.replace(/_/g, ' ')} |
| 187 | </span> |
| 188 | <span className="rounded-full border border-[var(--color-border-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]"> |
| 189 | {request.source} |
| 190 | </span> |
| 191 | {request.userId === null ? ( |
| 192 | <> |
| 193 | <span className="rounded-full border border-[var(--color-warning)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-warning)]"> |
| 194 | unauth lead |
| 195 | </span> |
| 196 | <button |
| 197 | type="button" |
| 198 | onClick={promoteToUser} |
| 199 | disabled={busy} |
| 200 | title="link this lead to the briven user account with the same email (idempotent)" |
| 201 | className="rounded-md border border-[var(--color-border-subtle)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)] disabled:opacity-50" |
| 202 | > |
| 203 | promote to user |
| 204 | </button> |
| 205 | </> |
| 206 | ) : null} |
| 207 | <span className="font-mono text-[10px] text-[var(--color-text-subtle)]"> |
| 208 | {request.contactEmail} |
| 209 | </span> |
| 210 | <span className="font-mono text-[10px] text-[var(--color-text-subtle)]"> |
| 211 | ·{' '} |
| 212 | {toValidDate(request.createdAt) |
| 213 | ? `${toValidDate(request.createdAt)!.toISOString().slice(0, 16).replace('T', ' ')} utc` |
| 214 | : '—'} |
| 215 | </span> |
| 216 | <span className="ml-auto font-mono text-[10px] text-[var(--color-text-subtle)]"> |
| 217 | {request.id} |
| 218 | </span> |
| 219 | </div> |
| 220 | |
| 221 | {request.sourceUrl ? ( |
| 222 | <p className="font-mono text-xs text-[var(--color-text-muted)]"> |
| 223 | source URL:{' '} |
| 224 | <code className="text-[var(--color-text)]">{request.sourceUrl}</code> |
| 225 | </p> |
| 226 | ) : null} |
| 227 | |
| 228 | <div className="flex flex-wrap gap-4 font-mono text-[10px] text-[var(--color-text-subtle)]"> |
| 229 | <span>tables: {request.estimatedTables ?? '—'}</span> |
| 230 | <span>rows: {request.estimatedRows ?? '—'}</span> |
| 231 | <span>functions: {request.estimatedFunctions ?? '—'}</span> |
| 232 | </div> |
| 233 | |
| 234 | {request.sourceNotes ? ( |
| 235 | <details className="font-mono text-xs text-[var(--color-text-muted)]"> |
| 236 | <summary className="cursor-pointer">customer notes</summary> |
| 237 | <pre className="mt-1 whitespace-pre-wrap break-words">{request.sourceNotes}</pre> |
| 238 | </details> |
| 239 | ) : null} |
| 240 | |
| 241 | {request.source === 'convex' ? ( |
| 242 | <ConvexTranslator apiOrigin={apiOrigin} /> |
| 243 | ) : null} |
| 244 | |
| 245 | <div className="flex flex-wrap items-center gap-2"> |
| 246 | <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">status</span> |
| 247 | <select |
| 248 | value={stagedStatus ?? request.status} |
| 249 | disabled={busy} |
| 250 | onChange={(e) => pickStatus(e.target.value as Status)} |
| 251 | className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1 font-mono text-xs text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50" |
| 252 | > |
| 253 | {STATUS_OPTIONS.map((s) => ( |
| 254 | <option key={s} value={s}> |
| 255 | {s.replace(/_/g, ' ')} |
| 256 | </option> |
| 257 | ))} |
| 258 | </select> |
| 259 | {stagedStatus ? ( |
| 260 | <span className="font-mono text-[10px] text-[var(--color-warning)]"> |
| 261 | unsaved · review the message below before sending |
| 262 | </span> |
| 263 | ) : null} |
| 264 | </div> |
| 265 | |
| 266 | {stagedStatus ? ( |
| 267 | <div className="flex flex-col gap-2 rounded-md border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] p-3"> |
| 268 | <div className="flex items-baseline justify-between"> |
| 269 | <p className="font-mono text-xs text-[var(--color-text)]"> |
| 270 | message to customer · auto-sent on save |
| 271 | </p> |
| 272 | <p className="font-mono text-[10px] text-[var(--color-text-subtle)]"> |
| 273 | template loaded for {request.status.replace(/_/g, ' ')} →{' '} |
| 274 | {stagedStatus.replace(/_/g, ' ')} |
| 275 | </p> |
| 276 | </div> |
| 277 | <textarea |
| 278 | value={messageToCustomer} |
| 279 | onChange={(e) => setMessageToCustomer(e.target.value)} |
| 280 | rows={6} |
| 281 | maxLength={5_000} |
| 282 | placeholder="leave blank to send the auto-generated status-change email without a custom message." |
| 283 | className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none" |
| 284 | /> |
| 285 | <p className="font-mono text-[10px] text-[var(--color-text-subtle)]"> |
| 286 | the email also includes the standard status blurb for{' '} |
| 287 | {stagedStatus.replace(/_/g, ' ')} and a link to /dashboard/migrations. this |
| 288 | field is your editable preamble. |
| 289 | </p> |
| 290 | <div className="flex flex-wrap gap-2"> |
| 291 | <button |
| 292 | type="button" |
| 293 | onClick={sendStatusUpdate} |
| 294 | disabled={busy} |
| 295 | className="rounded-md bg-[var(--color-primary)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)] disabled:opacity-50" |
| 296 | > |
| 297 | {busy ? 'sending…' : `send · status → ${stagedStatus.replace(/_/g, ' ')}`} |
| 298 | </button> |
| 299 | <button |
| 300 | type="button" |
| 301 | onClick={cancelStagedStatus} |
| 302 | disabled={busy} |
| 303 | className="font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]" |
| 304 | > |
| 305 | cancel |
| 306 | </button> |
| 307 | </div> |
| 308 | </div> |
| 309 | ) : null} |
| 310 | |
| 311 | {editingNotes ? ( |
| 312 | <div className="flex flex-col gap-2"> |
| 313 | <textarea |
| 314 | value={notes} |
| 315 | onChange={(e) => setNotes(e.target.value)} |
| 316 | maxLength={20_000} |
| 317 | rows={4} |
| 318 | placeholder="internal notes — never shown to the customer. paste call notes, scheduling info, blocker reasons." |
| 319 | className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text-muted)] focus:border-[var(--color-primary)] focus:outline-none" |
| 320 | /> |
| 321 | <div className="flex gap-2"> |
| 322 | <button |
| 323 | type="button" |
| 324 | onClick={() => patch({ operatorNotes: notes })} |
| 325 | disabled={busy} |
| 326 | className="rounded-md bg-[var(--color-primary)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)] disabled:opacity-50" |
| 327 | > |
| 328 | {busy ? 'saving…' : 'save notes'} |
| 329 | </button> |
| 330 | <button |
| 331 | type="button" |
| 332 | onClick={() => { |
| 333 | setEditingNotes(false); |
| 334 | setNotes(request.operatorNotes); |
| 335 | }} |
| 336 | className="font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]" |
| 337 | > |
| 338 | cancel |
| 339 | </button> |
| 340 | </div> |
| 341 | </div> |
| 342 | ) : ( |
| 343 | <div className="flex flex-wrap items-center gap-2"> |
| 344 | <button |
| 345 | type="button" |
| 346 | onClick={() => setEditingNotes(true)} |
| 347 | className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]" |
| 348 | > |
| 349 | {request.operatorNotes ? 'edit notes' : 'add notes'} |
| 350 | </button> |
| 351 | {request.operatorNotes ? ( |
| 352 | <span className="font-mono text-[10px] text-[var(--color-text-subtle)]"> |
| 353 | · {request.operatorNotes.length} chars of operator notes |
| 354 | </span> |
| 355 | ) : null} |
| 356 | </div> |
| 357 | )} |
| 358 | |
| 359 | {error ? ( |
| 360 | <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p> |
| 361 | ) : null} |
| 362 | |
| 363 | {pendingPayload ? ( |
| 364 | <StepUpPrompt |
| 365 | apiOrigin={apiOrigin} |
| 366 | reason="updating a migration request requires fresh step-up auth. confirm with your password." |
| 367 | onSuccess={async () => { |
| 368 | const payload = pendingPayload; |
| 369 | setPendingPayload(null); |
| 370 | if (payload?.messageToCustomer === PROMOTE_SENTINEL) { |
| 371 | await promoteToUser(); |
| 372 | } else if (payload) { |
| 373 | await patch(payload); |
| 374 | } |
| 375 | }} |
| 376 | onCancel={() => setPendingPayload(null)} |
| 377 | /> |
| 378 | ) : null} |
| 379 | </li> |
| 380 | ); |
| 381 | } |
| 382 | |
| 383 | function urgencyTone(u: string): string { |
| 384 | switch (u) { |
| 385 | case 'this_week': |
| 386 | return 'border-[var(--color-error)] text-[var(--color-error)]'; |
| 387 | case 'this_month': |
| 388 | return 'border-[var(--color-warning)] text-[var(--color-warning)]'; |
| 389 | case 'this_quarter': |
| 390 | return 'border-[var(--color-border-subtle)] text-[var(--color-text-subtle)]'; |
| 391 | default: |
| 392 | return 'border-[var(--color-border-subtle)] text-[var(--color-text-subtle)]'; |
| 393 | } |
| 394 | } |