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>
148 lines
5.4 KiB
TypeScript
148 lines
5.4 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { eq } from 'drizzle-orm';
|
|
|
|
import { withAuth, type AuthContext } from '@/lib/api/helpers';
|
|
import { parseBody } from '@/lib/api/route-helpers';
|
|
import { db } from '@/lib/db';
|
|
import { userProfiles } from '@/lib/db/schema';
|
|
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
|
|
// through and `<a href={avatarUrl}>` would otherwise be a stored-XSS
|
|
// vector if any future renderer treated the value as a link.
|
|
avatarUrl: z
|
|
.string()
|
|
.url()
|
|
.refine((u) => /^https?:\/\//i.test(u), 'must be an http(s) URL')
|
|
.nullable()
|
|
.optional(),
|
|
// Strict allow-list — no `.passthrough()` here. The previous schema let
|
|
// arbitrary client-supplied keys survive validation and persist into
|
|
// `userProfiles.preferences` JSONB unbounded; auditor-E3 §28 caught this.
|
|
// Add new keys here as the UI surfaces them rather than letting the
|
|
// client mint them at will.
|
|
preferences: z
|
|
.object({
|
|
dark_mode: z.boolean().optional(),
|
|
locale: z.string().optional(),
|
|
timezone: z.string().optional(),
|
|
// Per-table column visibility. Keyed by entity type — entries
|
|
// with an empty `hiddenColumns` mean "all visible". The validator
|
|
// caps total entries / IDs so a malicious client can't bloat the
|
|
// 8 KB preferences blob; see merge step below for the byte cap.
|
|
tablePreferences: z
|
|
.record(
|
|
z.string().min(1).max(64),
|
|
z
|
|
.object({
|
|
hiddenColumns: z.array(z.string().min(1).max(64)).max(50).optional(),
|
|
})
|
|
.strict(),
|
|
)
|
|
.optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
});
|
|
|
|
export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
|
// Hydrate preferences from user_profiles so the client can read its
|
|
// saved table-column visibility (and other prefs) without a second
|
|
// 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,
|
|
firstName: true,
|
|
lastName: true,
|
|
displayName: true,
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
userId: ctx.userId,
|
|
portId: ctx.portId,
|
|
portSlug: ctx.portSlug,
|
|
permissions: ctx.permissions,
|
|
isSuperAdmin: ctx.isSuperAdmin,
|
|
user: ctx.user,
|
|
preferences: profile?.preferences ?? {},
|
|
profile: {
|
|
avatarFileId: profile?.avatarFileId ?? null,
|
|
avatarUrl: profile?.avatarUrl ?? null,
|
|
firstName: profile?.firstName ?? null,
|
|
lastName: profile?.lastName ?? null,
|
|
displayName: profile?.displayName ?? null,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
export const PATCH = withAuth(async (req, ctx: AuthContext) => {
|
|
try {
|
|
const body = await parseBody(req, updateProfileSchema);
|
|
|
|
const profile = await db.query.userProfiles.findFirst({
|
|
where: eq(userProfiles.userId, ctx.userId),
|
|
});
|
|
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;
|
|
if (body.preferences !== undefined) {
|
|
// Allow-list — only retain keys defined in the strict schema. Pre-
|
|
// strict rows may carry extra keys from when the schema was
|
|
// .passthrough(); the merge prunes them so legacy bloat doesn't
|
|
// accumulate forever, and a future schema regression that tries
|
|
// to ship arbitrary keys still gets dropped here at write time.
|
|
const ALLOWED_PREF_KEYS = new Set(['dark_mode', 'locale', 'timezone', 'tablePreferences']);
|
|
const existing = (profile.preferences as Record<string, unknown>) ?? {};
|
|
const merged = Object.fromEntries(
|
|
Object.entries({ ...existing, ...body.preferences }).filter(([k]) =>
|
|
ALLOWED_PREF_KEYS.has(k),
|
|
),
|
|
);
|
|
// Hard cap on the merged JSONB — defense in depth against any
|
|
// future schema growth that might re-introduce free-form keys.
|
|
const serialized = JSON.stringify(merged);
|
|
if (Buffer.byteLength(serialized, 'utf8') > 8 * 1024) {
|
|
throw new ValidationError('preferences exceeds 8KB');
|
|
}
|
|
updates.preferences = merged;
|
|
}
|
|
|
|
const [updated] = await db
|
|
.update(userProfiles)
|
|
.set(updates)
|
|
.where(eq(userProfiles.userId, ctx.userId))
|
|
.returning();
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
userId: updated!.userId,
|
|
firstName: updated!.firstName,
|
|
lastName: updated!.lastName,
|
|
displayName: updated!.displayName,
|
|
phone: updated!.phone,
|
|
avatarUrl: updated!.avatarUrl,
|
|
preferences: updated!.preferences,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
});
|