feat(profile): first/last name fields + collapse notification preferences
Two related cleanups for the user profile surface area:
(1) Add canonical first_name + last_name columns to user_profiles.
Migration 0049 backfills from display_name by splitting on the
first whitespace run; single-token names land as
(display_name, NULL) so we never throw away existing data.
Display name becomes an optional override (nicknames, vanity
formatting). /api/v1/me PATCH now accepts firstName/lastName,
and the user-settings form surfaces them as the primary inputs
with display name as a secondary "How your name appears" field.
(2) Remove the broken Notifications card from user-settings (it called
PATCH on an endpoint that has GET/PUT only and used a flat shape
vs the actual array shape). Replace with the working
NotificationPreferencesForm + ReminderDigestForm under a
#notifications anchor. /notifications/preferences becomes a
server-side redirect to /settings#notifications for back-compat;
the mobile More-sheet + user-menu Bell entry now deep-link to the
new anchor directly.
Drops the auto-generated drizzle-kit catch-up migration so we're not
sneaking accumulated schema drift into the journal — only the targeted
0049 lands here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,8 @@ import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
firstName: z.string().min(1).max(120).nullable().optional(),
|
||||
lastName: z.string().min(1).max(120).nullable().optional(),
|
||||
displayName: z.string().min(1).max(200).optional(),
|
||||
phone: z.string().nullable().optional(),
|
||||
// Refuse `javascript:` / `data:` schemes — z.string().url() lets them
|
||||
@@ -55,7 +57,14 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
||||
// round-trip on app boot.
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, ctx.userId),
|
||||
columns: { preferences: true, avatarFileId: true, avatarUrl: true },
|
||||
columns: {
|
||||
preferences: true,
|
||||
avatarFileId: true,
|
||||
avatarUrl: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
displayName: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -70,6 +79,9 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
||||
profile: {
|
||||
avatarFileId: profile?.avatarFileId ?? null,
|
||||
avatarUrl: profile?.avatarUrl ?? null,
|
||||
firstName: profile?.firstName ?? null,
|
||||
lastName: profile?.lastName ?? null,
|
||||
displayName: profile?.displayName ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -85,6 +97,8 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
|
||||
if (!profile) throw new NotFoundError('profile');
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (body.firstName !== undefined) updates.firstName = body.firstName;
|
||||
if (body.lastName !== undefined) updates.lastName = body.lastName;
|
||||
if (body.displayName !== undefined) updates.displayName = body.displayName;
|
||||
if (body.phone !== undefined) updates.phone = body.phone;
|
||||
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
|
||||
@@ -119,6 +133,8 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
userId: updated!.userId,
|
||||
firstName: updated!.firstName,
|
||||
lastName: updated!.lastName,
|
||||
displayName: updated!.displayName,
|
||||
phone: updated!.phone,
|
||||
avatarUrl: updated!.avatarUrl,
|
||||
|
||||
Reference in New Issue
Block a user