feat: collect optional user phone number (onboarding + profile)

Google OAuth cannot provide phone numbers (no scope returns them reliably),
so we collect it ourselves. Optional, stored unverified.

- Migration 0011: users.phone text column (+ debug-migration hot-apply step)
- schema/auth.ts: add phone field
- onboarding: optional phone input on step 1; saved to users.phone via the
  onboarding API (normalised: leading + then digits, 8-15 digit validation)
- profile page: editable Phone field; loaded from + saved to /api/auth/profile
- /api/auth/profile: GET returns phone; POST accepts & normalises it
  (empty string clears, undefined leaves untouched)

Capture point covers both Google and email/password signups since both land
on onboarding. Verification (OTP) and marketing-consent flag intentionally
deferred per product decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-31 21:25:00 +05:30
parent 5083961c6b
commit 38bb5af01c
8 changed files with 80 additions and 10 deletions

View file

@ -0,0 +1,2 @@
-- Add optional phone number to users (collected at onboarding / profile).
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone text;

View file

@ -78,6 +78,13 @@
"when": 1749139200000,
"tag": "0010_error_events",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780000000000,
"tag": "0011_user_phone",
"breakpoints": true
}
]
}

View file

@ -55,6 +55,7 @@ export default function OnboardingPage() {
const [form, setForm] = useState({
familyName: "",
memberName: "",
phone: "",
childName: "",
birthDate: "",
sex: "" as "male" | "female" | "other",
@ -167,6 +168,10 @@ export default function OnboardingPage() {
<Input label="Your Name" type="text" value={form.memberName}
onChange={(e) => setForm({ ...form, memberName: e.target.value })}
placeholder="Mama" />
<Input label="Phone Number (optional)" type="tel" value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder="+91 98765 43210" />
<p className="text-xs text-gray-400 -mt-2">For important reminders &amp; updates about your baby</p>
<Button fullWidth size="lg" onClick={() => setStep(2)} disabled={!form.familyName || !form.memberName}>
Next
</Button>

View file

@ -21,6 +21,7 @@ export default function ProfilePage() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@ -35,6 +36,7 @@ export default function ProfilePage() {
if (data.user) {
setName(data.user.name || "");
setEmail(data.user.email || "");
setPhone(data.user.phone || "");
setAvatarUrl(data.user.avatarUrl || null);
}
setLoading(false);
@ -108,7 +110,7 @@ export default function ProfilePage() {
const res = await fetch("/api/auth/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
body: JSON.stringify({ name, phone }),
});
const data = await res.json();
setSaveMsg(data.success ? "Saved!" : data.error || "Save failed");
@ -192,6 +194,18 @@ export default function ProfilePage() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Phone Number</label>
<input
type="tel"
value={phone}
onChange={e => setPhone(e.target.value)}
className="w-full px-3 py-2.5 bg-gray-50 dark:bg-gray-700 rounded-xl border border-gray-200 dark:border-gray-600 text-sm dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300"
placeholder="+91 98765 43210"
/>
<p className="text-xs text-gray-400 mt-1">For important reminders &amp; updates (optional)</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
<input

View file

@ -15,7 +15,7 @@ export async function GET() {
// Get session and user
const sessions = await sql`
SELECT s.user_id, s.expires, u.id, u.email, u.name, u.image, u.created_at
SELECT s.user_id, s.expires, u.id, u.email, u.name, u.image, u.phone, u.created_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.session_token = ${sessionToken}
@ -41,6 +41,7 @@ export async function GET() {
id: session.id,
email: session.email,
name: session.name || "Parent",
phone: session.phone || null,
avatarUrl: toProxyUrl(session.image) || null,
familyId: members?.[0]?.family_id,
familyName: members?.[0]?.family_name,
@ -57,12 +58,29 @@ export async function GET() {
export async function POST(request: Request) {
try {
const body = await request.json();
const { name } = body;
const { name, phone } = body as { name?: string; phone?: string };
if (!name) {
return NextResponse.json({ error: "Name required" }, { status: 400 });
}
// Phone is optional. Normalise: keep a leading + then digits only.
// Empty string clears it. Light validation — 8-15 digits if provided.
let normalizedPhone: string | null | undefined; // undefined = don't touch
if (phone !== undefined) {
const trimmed = (phone || "").trim();
if (trimmed === "") {
normalizedPhone = null;
} else {
const cleaned = trimmed.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
const digits = cleaned.replace(/\D/g, "");
if (digits.length < 8 || digits.length > 15) {
return NextResponse.json({ error: "Enter a valid phone number" }, { status: 400 });
}
normalizedPhone = cleaned;
}
}
const cookieStore = await cookies();
const sessionToken = cookieStore.get("tia_session")?.value;
@ -84,13 +102,20 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid session" }, { status: 401 });
}
// Update user name
await sql`
UPDATE users SET name = ${name}, updated_at = NOW()
WHERE id = ${session.user_id}
`;
// Update user name (+ phone only when the field was sent)
if (normalizedPhone !== undefined) {
await sql`
UPDATE users SET name = ${name}, phone = ${normalizedPhone}, updated_at = NOW()
WHERE id = ${session.user_id}
`;
} else {
await sql`
UPDATE users SET name = ${name}, updated_at = NOW()
WHERE id = ${session.user_id}
`;
}
return NextResponse.json({ success: true, name });
return NextResponse.json({ success: true, name, phone: normalizedPhone ?? undefined });
} catch (error) {
console.error("Profile update error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 });

View file

@ -101,6 +101,8 @@ export async function POST(req: Request) {
`CREATE INDEX IF NOT EXISTS idx_error_events_created ON error_events (created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_error_events_source ON error_events (source)`,
`CREATE INDEX IF NOT EXISTS idx_error_events_message ON error_events (message)`,
// 0011 — optional user phone number
`ALTER TABLE users ADD COLUMN IF NOT EXISTS phone text`,
];
const results: string[] = [];

View file

@ -22,9 +22,23 @@ export async function POST(request: Request) {
const userId = sessions[0].user_id;
const body = await request.json();
const { familyName, memberName, childName, birthDate, sex } = body;
const { familyName, memberName, phone, childName, birthDate, sex } = body;
try {
// Save the parent's name + optional phone onto their user record.
// Phone: keep leading + then digits; store null when blank/invalid.
let normalizedPhone: string | null = null;
if (phone && typeof phone === "string") {
const cleaned = phone.trim().replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
const digits = cleaned.replace(/\D/g, "");
if (digits.length >= 8 && digits.length <= 15) normalizedPhone = cleaned;
}
await sql`
UPDATE users
SET name = COALESCE(${memberName || null}, name), phone = ${normalizedPhone}, updated_at = NOW()
WHERE id = ${userId}
`;
// Create family
const familyId = crypto.randomUUID();
await sql.unsafe(

View file

@ -14,6 +14,7 @@ export const users = pgTable("users", {
email: text("email").notNull().unique(),
emailVerified: timestamp("email_verified"),
image: text("image"),
phone: text("phone"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
passwordHash: varchar("password_hash", { length: 255 }),