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,
|
"when": 1749139200000,
|
||||||
"tag": "0010_error_events",
|
"tag": "0010_error_events",
|
||||||
"breakpoints": true
|
"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({
|
const [form, setForm] = useState({
|
||||||
familyName: "",
|
familyName: "",
|
||||||
memberName: "",
|
memberName: "",
|
||||||
|
phone: "",
|
||||||
childName: "",
|
childName: "",
|
||||||
birthDate: "",
|
birthDate: "",
|
||||||
sex: "" as "male" | "female" | "other",
|
sex: "" as "male" | "female" | "other",
|
||||||
|
|
@ -167,6 +168,10 @@ export default function OnboardingPage() {
|
||||||
<Input label="Your Name" type="text" value={form.memberName}
|
<Input label="Your Name" type="text" value={form.memberName}
|
||||||
onChange={(e) => setForm({ ...form, memberName: e.target.value })}
|
onChange={(e) => setForm({ ...form, memberName: e.target.value })}
|
||||||
placeholder="Mama" />
|
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}>
|
<Button fullWidth size="lg" onClick={() => setStep(2)} disabled={!form.familyName || !form.memberName}>
|
||||||
Next →
|
Next →
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export default function ProfilePage() {
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
@ -35,6 +36,7 @@ export default function ProfilePage() {
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
setName(data.user.name || "");
|
setName(data.user.name || "");
|
||||||
setEmail(data.user.email || "");
|
setEmail(data.user.email || "");
|
||||||
|
setPhone(data.user.phone || "");
|
||||||
setAvatarUrl(data.user.avatarUrl || null);
|
setAvatarUrl(data.user.avatarUrl || null);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -108,7 +110,7 @@ export default function ProfilePage() {
|
||||||
const res = await fetch("/api/auth/profile", {
|
const res = await fetch("/api/auth/profile", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name, phone }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSaveMsg(data.success ? "Saved!" : data.error || "Save failed");
|
setSaveMsg(data.success ? "Saved!" : data.error || "Save failed");
|
||||||
|
|
@ -192,6 +194,18 @@ export default function ProfilePage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
|
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export async function GET() {
|
||||||
|
|
||||||
// Get session and user
|
// Get session and user
|
||||||
const sessions = await sql`
|
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
|
FROM sessions s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.session_token = ${sessionToken}
|
WHERE s.session_token = ${sessionToken}
|
||||||
|
|
@ -41,6 +41,7 @@ export async function GET() {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
email: session.email,
|
email: session.email,
|
||||||
name: session.name || "Parent",
|
name: session.name || "Parent",
|
||||||
|
phone: session.phone || null,
|
||||||
avatarUrl: toProxyUrl(session.image) || null,
|
avatarUrl: toProxyUrl(session.image) || null,
|
||||||
familyId: members?.[0]?.family_id,
|
familyId: members?.[0]?.family_id,
|
||||||
familyName: members?.[0]?.family_name,
|
familyName: members?.[0]?.family_name,
|
||||||
|
|
@ -57,12 +58,29 @@ export async function GET() {
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name } = body;
|
const { name, phone } = body as { name?: string; phone?: string };
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: "Name required" }, { status: 400 });
|
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 cookieStore = await cookies();
|
||||||
const sessionToken = cookieStore.get("tia_session")?.value;
|
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 });
|
return NextResponse.json({ error: "Invalid session" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user name
|
// Update user name (+ phone only when the field was sent)
|
||||||
await sql`
|
if (normalizedPhone !== undefined) {
|
||||||
UPDATE users SET name = ${name}, updated_at = NOW()
|
await sql`
|
||||||
WHERE id = ${session.user_id}
|
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) {
|
} catch (error) {
|
||||||
console.error("Profile update error:", error);
|
console.error("Profile update error:", error);
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
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_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_source ON error_events (source)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_error_events_message ON error_events (message)`,
|
`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[] = [];
|
const results: string[] = [];
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,23 @@ export async function POST(request: Request) {
|
||||||
const userId = sessions[0].user_id;
|
const userId = sessions[0].user_id;
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { familyName, memberName, childName, birthDate, sex } = body;
|
const { familyName, memberName, phone, childName, birthDate, sex } = body;
|
||||||
|
|
||||||
try {
|
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
|
// Create family
|
||||||
const familyId = crypto.randomUUID();
|
const familyId = crypto.randomUUID();
|
||||||
await sql.unsafe(
|
await sql.unsafe(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export const users = pgTable("users", {
|
||||||
email: text("email").notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
emailVerified: timestamp("email_verified"),
|
emailVerified: timestamp("email_verified"),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
|
phone: text("phone"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
passwordHash: varchar("password_hash", { length: 255 }),
|
passwordHash: varchar("password_hash", { length: 255 }),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue