Skip to content

Commit ecfc201

Browse files
committed
feat: collect user phone and add as spectrum user to claim bot number
Free-plan Spectrum projects don't have a phone until a user is added with their own phone. The user-add response carries the assigned shared bot number. Insert a phone collection step in onboarding, pass userPhone to /api/provision, call POST /spectrum/users with it, and scan the response for the assigned number (preferring keys like assignedPhoneNumber / botPhoneNumber / sharedPhoneNumber and excluding the input phone).
1 parent 3ae097b commit ecfc201

3 files changed

Lines changed: 275 additions & 9 deletions

File tree

app/api/provision/route.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isOpenAIKeyShape, verifyOpenAIKey } from "@/lib/openai-key";
55
import {
66
SpectrumError,
77
createProject,
8+
createSpectrumUser,
89
getProject,
910
getSession,
1011
imessageRedirectUrl,
@@ -19,6 +20,49 @@ import { NextResponse } from "next/server";
1920
export const runtime = "nodejs";
2021
export const dynamic = "force-dynamic";
2122

23+
const PHONE_RE = /^\+?\d{6,}$/;
24+
const PREFERRED_PHONE_KEYS = [
25+
"assignedPhoneNumber",
26+
"botPhoneNumber",
27+
"spectrumPhoneNumber",
28+
"inboundPhoneNumber",
29+
"sharedPhoneNumber",
30+
"lineNumber",
31+
];
32+
const FALLBACK_PHONE_KEYS = ["phoneNumber", "phone", "number", "msisdn"];
33+
34+
function pickPhoneFrom(value: unknown, exclude?: string | null, depth = 0): string | null {
35+
if (depth > 6 || value === null || value === undefined) return null;
36+
if (Array.isArray(value)) {
37+
for (const item of value) {
38+
const hit = pickPhoneFrom(item, exclude, depth + 1);
39+
if (hit) return hit;
40+
}
41+
return null;
42+
}
43+
if (typeof value !== "object") return null;
44+
const obj = value as Record<string, unknown>;
45+
for (const key of PREFERRED_PHONE_KEYS) {
46+
const v = obj[key];
47+
if (typeof v === "string" && PHONE_RE.test(v.trim())) {
48+
const trimmed = v.trim();
49+
if (!exclude || trimmed !== exclude) return trimmed;
50+
}
51+
}
52+
for (const key of FALLBACK_PHONE_KEYS) {
53+
const v = obj[key];
54+
if (typeof v === "string" && PHONE_RE.test(v.trim())) {
55+
const trimmed = v.trim();
56+
if (!exclude || trimmed !== exclude) return trimmed;
57+
}
58+
}
59+
for (const v of Object.values(obj)) {
60+
const hit = pickPhoneFrom(v, exclude, depth + 1);
61+
if (hit) return hit;
62+
}
63+
return null;
64+
}
65+
2266
export async function POST(req: Request) {
2367
const jar = await cookies();
2468
const bearer = jar.get("bearer")?.value;
@@ -27,8 +71,15 @@ export async function POST(req: Request) {
2771
}
2872

2973
let openaiKey: string | null = null;
74+
let userPhone: string | null = null;
3075
try {
31-
const body = (await req.json().catch(() => ({}))) as { openaiKey?: unknown };
76+
const body = (await req.json().catch(() => ({}))) as {
77+
openaiKey?: unknown;
78+
userPhone?: unknown;
79+
};
80+
if (typeof body.userPhone === "string" && body.userPhone.trim().length > 0) {
81+
userPhone = body.userPhone.trim();
82+
}
3283
if (typeof body.openaiKey === "string" && body.openaiKey.trim().length > 0) {
3384
const candidate = body.openaiKey.trim();
3485
if (!isOpenAIKeyShape(candidate)) {
@@ -57,6 +108,10 @@ export async function POST(req: Request) {
57108
if (!session) {
58109
return NextResponse.json({ error: "session invalid" }, { status: 401 });
59110
}
111+
console.log("[provision] session.user keys:", Object.keys(session.user));
112+
113+
const ownerPhone =
114+
userPhone ?? (typeof session.user.phoneNumber === "string" ? session.user.phoneNumber : null);
60115

61116
const db = getDb();
62117

@@ -95,7 +150,50 @@ export async function POST(req: Request) {
95150
`[provision] dashboard id=${project.id} cloud spectrumProjectId=${details.spectrumProjectId ?? "(missing — using dashboard id)"}`,
96151
);
97152

98-
const line = await provisionImessage(bearer, cloudProjectId, projectSecret);
153+
if (!ownerPhone) {
154+
return NextResponse.json(
155+
{
156+
error:
157+
"We need your phone number to assign you a Spectrum iMessage bot. Add it and continue.",
158+
reason: "phone_required",
159+
},
160+
{ status: 422 },
161+
);
162+
}
163+
164+
const fullName = session.user.name?.trim() ?? "";
165+
const [firstName, ...rest] = fullName.split(/\s+/);
166+
const userResp = await createSpectrumUser(bearer, project.id, {
167+
firstName: firstName || "Codex",
168+
lastName: rest.join(" ") || "User",
169+
email: session.user.email ?? `${session.user.id}@codex.local`,
170+
phoneNumber: ownerPhone,
171+
sendInvite: false,
172+
});
173+
console.log("[provision] create-spectrum-user response:", JSON.stringify(userResp));
174+
175+
let line = await provisionImessage(bearer, cloudProjectId, projectSecret).catch(
176+
(err: unknown) => {
177+
console.warn(
178+
"[provision] provisionImessage fallback after user-add:",
179+
err instanceof Error ? err.message : err,
180+
);
181+
return null;
182+
},
183+
);
184+
185+
if (!line) {
186+
const fromUser = pickPhoneFrom(userResp, ownerPhone);
187+
if (fromUser) {
188+
line = { id: userResp.user?.id ?? `${cloudProjectId}:imessage`, phoneNumber: fromUser };
189+
} else {
190+
throw new SpectrumError(
191+
"Couldn't read the assigned iMessage number from Spectrum's response.",
192+
500,
193+
userResp,
194+
);
195+
}
196+
}
99197

100198
const projectSecretBlob = encrypt(projectSecret);
101199
const openaiBlob = openaiKey ? encrypt(openaiKey) : null;

app/onboard/onboard-client.tsx

Lines changed: 126 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { useRouter } from "next/navigation";
1616
import { useCallback, useEffect, useMemo, useState } from "react";
1717
import { toast } from "sonner";
1818

19-
type Stage = "key" | "device" | "provision" | "done";
19+
type Stage = "key" | "device" | "phone" | "provision" | "done";
2020

2121
interface DeviceState {
2222
user_code: string;
@@ -34,15 +34,17 @@ interface TenantState {
3434
const STEP_INDEX: Record<Stage, number> = {
3535
key: 0,
3636
device: 1,
37-
provision: 1,
38-
done: 2,
37+
phone: 2,
38+
provision: 2,
39+
done: 3,
3940
};
40-
const TOTAL_STEPS = 3;
41+
const TOTAL_STEPS = 4;
4142

4243
export default function OnboardClient() {
4344
const router = useRouter();
4445
const [stage, setStage] = useState<Stage>("key");
4546
const [apiKey, setApiKey] = useState("");
47+
const [userPhone, setUserPhone] = useState("");
4648
const [device, setDevice] = useState<DeviceState | null>(null);
4749
const [tenant, setTenant] = useState<TenantState | null>(null);
4850
const [busy, setBusy] = useState(false);
@@ -98,7 +100,7 @@ export default function OnboardClient() {
98100
if (cancelled) return;
99101
switch (data.status) {
100102
case "ok":
101-
setStage("provision");
103+
setStage("phone");
102104
return;
103105
case "pending":
104106
pollTimer = setTimeout(poll, interval);
@@ -144,7 +146,7 @@ export default function OnboardClient() {
144146
void fetch("/api/provision", {
145147
method: "POST",
146148
headers: { "content-type": "application/json" },
147-
body: JSON.stringify({ openaiKey: apiKey.trim() }),
149+
body: JSON.stringify({ openaiKey: apiKey.trim(), userPhone: userPhone.trim() }),
148150
})
149151
.then(async (res) => {
150152
if (cancelled) return;
@@ -173,7 +175,7 @@ export default function OnboardClient() {
173175
return () => {
174176
cancelled = true;
175177
};
176-
}, [stage, apiKey]);
178+
}, [stage, apiKey, userPhone]);
177179

178180
const activeIdx = STEP_INDEX[stage];
179181

@@ -194,6 +196,9 @@ export default function OnboardClient() {
194196
beginSpectrum={beginSpectrum}
195197
device={device}
196198
tenant={tenant}
199+
userPhone={userPhone}
200+
setUserPhone={setUserPhone}
201+
onPhoneSubmit={() => setStage("provision")}
197202
/>
198203
</div>
199204
</div>
@@ -205,6 +210,7 @@ function StageIcon({ stage }: { stage: Stage }) {
205210
case "key":
206211
return <ChatGPTChip />;
207212
case "device":
213+
case "phone":
208214
return <SpectrumChip />;
209215
case "provision":
210216
case "done":
@@ -243,6 +249,9 @@ interface StageContentProps {
243249
beginSpectrum: () => void;
244250
device: DeviceState | null;
245251
tenant: TenantState | null;
252+
userPhone: string;
253+
setUserPhone: (v: string) => void;
254+
onPhoneSubmit: () => void;
246255
}
247256

248257
function StageContent({
@@ -253,6 +262,9 @@ function StageContent({
253262
beginSpectrum,
254263
device,
255264
tenant,
265+
userPhone,
266+
setUserPhone,
267+
onPhoneSubmit,
256268
}: StageContentProps) {
257269
switch (stage) {
258270
case "key":
@@ -271,6 +283,16 @@ function StageContent({
271283
</>
272284
);
273285

286+
case "phone":
287+
return (
288+
<PhoneStage
289+
userPhone={userPhone}
290+
setUserPhone={setUserPhone}
291+
busy={busy}
292+
onSubmit={onPhoneSubmit}
293+
/>
294+
);
295+
274296
case "provision":
275297
return (
276298
<>
@@ -300,6 +322,103 @@ function StageContent({
300322
}
301323
}
302324

325+
function PhoneStage({
326+
userPhone,
327+
setUserPhone,
328+
busy,
329+
onSubmit,
330+
}: {
331+
userPhone: string;
332+
setUserPhone: (v: string) => void;
333+
busy: boolean;
334+
onSubmit: () => void;
335+
}) {
336+
const [shaking, setShaking] = useState(false);
337+
const [attempted, setAttempted] = useState(false);
338+
const trimmed = userPhone.trim();
339+
const isValid = useMemo(() => /^\+[1-9]\d{6,14}$/.test(trimmed), [trimmed]);
340+
const isEmpty = trimmed.length === 0;
341+
342+
const state: "valid" | "invalid" | "neutral" = isValid
343+
? "valid"
344+
: attempted && !isEmpty
345+
? "invalid"
346+
: "neutral";
347+
348+
const handleSubmit = () => {
349+
if (!isValid) {
350+
setAttempted(true);
351+
setShaking(true);
352+
setTimeout(() => setShaking(false), 420);
353+
toast.error("That doesn't look like a phone number", {
354+
description: "Use E.164 format, e.g. +14155550123.",
355+
});
356+
return;
357+
}
358+
onSubmit();
359+
};
360+
361+
return (
362+
<>
363+
<h1 className="section-title fade-up fade-up-4 mt-4">Your phone number</h1>
364+
<p className="body-muted fade-up fade-up-5 mt-2 max-w-[24rem] text-balance">
365+
Spectrum uses this to assign you a shared iMessage bot you can text.
366+
</p>
367+
<div className="fade-up fade-up-6 mt-7 w-full max-w-[28rem]">
368+
<form
369+
className={`flex w-full flex-col gap-3 ${shaking ? "shake" : ""}`}
370+
onSubmit={(e) => {
371+
e.preventDefault();
372+
handleSubmit();
373+
}}
374+
noValidate
375+
>
376+
<div className="relative">
377+
<input
378+
className="input-glass font-mono text-center text-[15px] tracking-[0.02em] pr-10"
379+
type="tel"
380+
inputMode="tel"
381+
placeholder="+14155550123"
382+
autoComplete="tel"
383+
spellCheck={false}
384+
value={userPhone}
385+
onChange={(e) => {
386+
setUserPhone(e.target.value);
387+
if (attempted) setAttempted(false);
388+
}}
389+
disabled={busy}
390+
aria-label="Your phone number (E.164)"
391+
aria-invalid={state === "invalid" || undefined}
392+
data-state={state === "neutral" ? undefined : state}
393+
required
394+
/>
395+
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
396+
{state === "valid" ? (
397+
<Check size={16} className="text-[var(--color-success)]" />
398+
) : state === "invalid" ? (
399+
<AlertCircle size={16} className="text-[var(--color-danger)]" />
400+
) : null}
401+
</span>
402+
</div>
403+
<button
404+
type="submit"
405+
className="btn-pill-primary inline-flex w-full items-center justify-center"
406+
disabled={busy || isEmpty}
407+
>
408+
{busy ? <Loader2 size={14} className="mr-1.5 animate-spin" /> : null}
409+
Continue
410+
{!busy && <ArrowRight size={14} className="ml-1.5" />}
411+
</button>
412+
<p className="mt-1 text-[12px] text-[var(--color-text-dim)]">
413+
Include the country code. We never message you — Spectrum uses this to register your
414+
account.
415+
</p>
416+
</form>
417+
</div>
418+
</>
419+
);
420+
}
421+
303422
function KeyStage({
304423
apiKey,
305424
setApiKey,

0 commit comments

Comments
 (0)