Files
pn-new-crm/src/app/api/v1/me/route.ts
Matt 07b5756014 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>
2026-05-09 18:36:31 +02:00

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);
}
});