migration-request-row.tsx394 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState, useTransition } from 'react';
5
6import { StepUpPrompt } from '@/components/step-up-prompt';
7import { toValidDate } from '@/lib/utils';
8import { ConvexTranslator } from './convex-translator';
9
10interface 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
27type Status = 'new' | 'contacted' | 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
28
29const 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.
44const 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
58interface Props {
59 request: AdminRequest;
60 apiOrigin: string;
61}
62
63interface PatchPayload {
64 status?: Status;
65 operatorNotes?: string;
66 messageToCustomer?: string;
67}
68
69function applyTemplate(template: string, source: string): string {
70 return template.replace(/\{\{source\}\}/g, source);
71}
72
73export 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
383function 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}