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:
parent
5083961c6b
commit
38bb5af01c
8 changed files with 80 additions and 10 deletions
2
drizzle/0011_user_phone.sql
Normal file
2
drizzle/0011_user_phone.sql
Normal 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;
|
||||
|
|
@ -78,6 +78,13 @@
|
|||
"when": 1749139200000,
|
||||
"tag": "0010_error_events",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1780000000000,
|
||||
"tag": "0011_user_phone",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 & updates about your baby</p>
|
||||
<Button fullWidth size="lg" onClick={() => setStep(2)} disabled={!form.familyName || !form.memberName}>
|
||||
Next →
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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 & updates (optional)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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 });
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue