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:
2026-05-09 18:36:31 +02:00
parent 7c25d1aef6
commit 07b5756014
8 changed files with 124 additions and 96 deletions

View File

@@ -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,