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:
@@ -1,17 +1,15 @@
|
|||||||
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
|
import { redirect } from 'next/navigation';
|
||||||
import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form';
|
|
||||||
|
|
||||||
export default function NotificationPreferencesPage() {
|
interface PageProps {
|
||||||
return (
|
params: Promise<{ portSlug: string }>;
|
||||||
<div className="max-w-2xl mx-auto py-6 space-y-6">
|
}
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Notification Preferences</h1>
|
/**
|
||||||
<p className="text-sm text-muted-foreground">
|
* Legacy route. Notification preferences now live on the user-settings
|
||||||
Choose which notifications you receive and how.
|
* page alongside every other personal preference. Kept as a redirect so
|
||||||
</p>
|
* older bookmarks / email links still land somewhere useful.
|
||||||
</div>
|
*/
|
||||||
<NotificationPreferencesForm />
|
export default async function NotificationPreferencesRedirect({ params }: PageProps) {
|
||||||
<ReminderDigestForm />
|
const { portSlug } = await params;
|
||||||
</div>
|
redirect(`/${portSlug}/settings#notifications`);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const updateProfileSchema = z.object({
|
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(),
|
displayName: z.string().min(1).max(200).optional(),
|
||||||
phone: z.string().nullable().optional(),
|
phone: z.string().nullable().optional(),
|
||||||
// Refuse `javascript:` / `data:` schemes — z.string().url() lets them
|
// 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.
|
// round-trip on app boot.
|
||||||
const profile = await db.query.userProfiles.findFirst({
|
const profile = await db.query.userProfiles.findFirst({
|
||||||
where: eq(userProfiles.userId, ctx.userId),
|
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({
|
return NextResponse.json({
|
||||||
@@ -70,6 +79,9 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
|||||||
profile: {
|
profile: {
|
||||||
avatarFileId: profile?.avatarFileId ?? null,
|
avatarFileId: profile?.avatarFileId ?? null,
|
||||||
avatarUrl: profile?.avatarUrl ?? 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');
|
if (!profile) throw new NotFoundError('profile');
|
||||||
|
|
||||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
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.displayName !== undefined) updates.displayName = body.displayName;
|
||||||
if (body.phone !== undefined) updates.phone = body.phone;
|
if (body.phone !== undefined) updates.phone = body.phone;
|
||||||
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
|
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
|
||||||
@@ -119,6 +133,8 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: {
|
data: {
|
||||||
userId: updated!.userId,
|
userId: updated!.userId,
|
||||||
|
firstName: updated!.firstName,
|
||||||
|
lastName: updated!.lastName,
|
||||||
displayName: updated!.displayName,
|
displayName: updated!.displayName,
|
||||||
phone: updated!.phone,
|
phone: updated!.phone,
|
||||||
avatarUrl: updated!.avatarUrl,
|
avatarUrl: updated!.avatarUrl,
|
||||||
|
|||||||
@@ -50,9 +50,8 @@ const MORE_ITEMS: MoreItem[] = [
|
|||||||
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
||||||
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
|
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
|
||||||
// Notifications themselves live on the topbar bell — this entry deep-links
|
// Notifications themselves live on the topbar bell — this entry deep-links
|
||||||
// to the per-channel preferences page. Pointing at the bare `/notifications`
|
// to the notification panel inside user-settings (collapsed in 2026-05-09).
|
||||||
// segment 404s today (no page.tsx, only `/preferences`).
|
{ label: 'Notification preferences', icon: BellRing, segment: 'settings#notifications' },
|
||||||
{ label: 'Notification preferences', icon: BellRing, segment: 'notifications/preferences' },
|
|
||||||
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
||||||
{ label: 'Website analytics', icon: Globe, segment: 'website-analytics' },
|
{ label: 'Website analytics', icon: Globe, segment: 'website-analytics' },
|
||||||
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
|
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export function UserMenu({ trigger, align = 'end', user, ports }: UserMenuProps)
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onClick={() => router.push(`${base}/notifications/preferences` as any)}
|
onClick={() => router.push(`${base}/settings#notifications` as any)}
|
||||||
>
|
>
|
||||||
<Bell className="w-4 h-4 mr-2" />
|
<Bell className="w-4 h-4 mr-2" />
|
||||||
Notification preferences
|
Notification preferences
|
||||||
|
|||||||
@@ -7,35 +7,32 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||||
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
||||||
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
||||||
import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog';
|
import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog';
|
||||||
|
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
|
||||||
|
import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
||||||
import type { CountryCode } from '@/lib/i18n/countries';
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
|
|
||||||
interface NotificationPrefs {
|
|
||||||
reminder_due: boolean;
|
|
||||||
reminder_overdue: boolean;
|
|
||||||
eoi_signed: boolean;
|
|
||||||
eoi_completed: boolean;
|
|
||||||
invoice_overdue: boolean;
|
|
||||||
duplicate_alert: boolean;
|
|
||||||
[key: string]: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
user?: { name: string; email: string };
|
user?: { name: string; email: string };
|
||||||
preferences?: { country?: string; timezone?: string };
|
preferences?: { country?: string; timezone?: string };
|
||||||
profile?: { avatarFileId?: string | null };
|
profile?: {
|
||||||
|
avatarFileId?: string | null;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
displayName?: string | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserSettings() {
|
export function UserSettings() {
|
||||||
const [notifPrefs, setNotifPrefs] = useState<NotificationPrefs | null>(null);
|
const [firstName, setFirstName] = useState('');
|
||||||
|
const [lastName, setLastName] = useState('');
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -66,12 +63,15 @@ export function UserSettings() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadProfile();
|
void loadProfile();
|
||||||
void loadNotificationPrefs();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadProfile() {
|
async function loadProfile() {
|
||||||
const res = await apiFetch<{ data: MeResponse }>('/api/v1/me', { method: 'GET' });
|
const res = await apiFetch<{ data: MeResponse }>('/api/v1/me', { method: 'GET' });
|
||||||
setDisplayName(res.data.user?.name ?? '');
|
setFirstName(res.data.profile?.firstName ?? '');
|
||||||
|
setLastName(res.data.profile?.lastName ?? '');
|
||||||
|
// Display name is the override; fall back to user.name if profile
|
||||||
|
// doesn't carry one (e.g. legacy rows pre-Wave 10).
|
||||||
|
setDisplayName(res.data.profile?.displayName ?? res.data.user?.name ?? '');
|
||||||
setEmail(res.data.user?.email ?? '');
|
setEmail(res.data.user?.email ?? '');
|
||||||
setOriginalEmail(res.data.user?.email ?? '');
|
setOriginalEmail(res.data.user?.email ?? '');
|
||||||
setCountry(res.data.preferences?.country ?? null);
|
setCountry(res.data.preferences?.country ?? null);
|
||||||
@@ -113,22 +113,6 @@ export function UserSettings() {
|
|||||||
setAvatarUrl(`/api/v1/files/${json.data.avatarFileId}/preview?t=${Date.now()}`);
|
setAvatarUrl(`/api/v1/files/${json.data.avatarFileId}/preview?t=${Date.now()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadNotificationPrefs() {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch<{ data: NotificationPrefs }>('/api/v1/notifications/preferences');
|
|
||||||
setNotifPrefs(res.data);
|
|
||||||
} catch {
|
|
||||||
setNotifPrefs({
|
|
||||||
reminder_due: true,
|
|
||||||
reminder_overdue: true,
|
|
||||||
eoi_signed: true,
|
|
||||||
eoi_completed: true,
|
|
||||||
invoice_overdue: true,
|
|
||||||
duplicate_alert: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCountryChange(iso: string | null) {
|
function handleCountryChange(iso: string | null) {
|
||||||
setCountry(iso);
|
setCountry(iso);
|
||||||
// Auto-default timezone when the rep picks a country and hasn't
|
// Auto-default timezone when the rep picks a country and hasn't
|
||||||
@@ -146,6 +130,8 @@ export function UserSettings() {
|
|||||||
await apiFetch('/api/v1/me', {
|
await apiFetch('/api/v1/me', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: {
|
body: {
|
||||||
|
firstName: firstName.trim() || null,
|
||||||
|
lastName: lastName.trim() || null,
|
||||||
displayName: displayName || undefined,
|
displayName: displayName || undefined,
|
||||||
phone: phone || null,
|
phone: phone || null,
|
||||||
preferences: {
|
preferences: {
|
||||||
@@ -190,32 +176,10 @@ export function UserSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleNotifPref(key: string, value: boolean) {
|
|
||||||
setSaving(key);
|
|
||||||
try {
|
|
||||||
await apiFetch('/api/v1/notifications/preferences', {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { [key]: value },
|
|
||||||
});
|
|
||||||
setNotifPrefs((prev) => (prev ? { ...prev, [key]: value } : prev));
|
|
||||||
} finally {
|
|
||||||
setSaving(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function adoptDetectedTz() {
|
function adoptDetectedTz() {
|
||||||
if (detectedTz) setTimezone(detectedTz);
|
if (detectedTz) setTimezone(detectedTz);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOTIF_LABELS: Record<string, string> = {
|
|
||||||
reminder_due: 'Reminder due',
|
|
||||||
reminder_overdue: 'Reminder overdue',
|
|
||||||
eoi_signed: 'EOI signed by a party',
|
|
||||||
eoi_completed: 'EOI fully completed',
|
|
||||||
invoice_overdue: 'Invoice overdue',
|
|
||||||
duplicate_alert: 'Duplicate client detected',
|
|
||||||
};
|
|
||||||
|
|
||||||
const tzMismatch = detectedTz && timezone && detectedTz !== timezone;
|
const tzMismatch = detectedTz && timezone && detectedTz !== timezone;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -263,14 +227,41 @@ export function UserSettings() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="settings-first-name">First name</Label>
|
||||||
|
<Input
|
||||||
|
id="settings-first-name"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
placeholder="Given name"
|
||||||
|
autoComplete="given-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="settings-last-name">Last name</Label>
|
||||||
|
<Input
|
||||||
|
id="settings-last-name"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
placeholder="Family name"
|
||||||
|
autoComplete="family-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="settings-name">Display name</Label>
|
<Label htmlFor="settings-name">
|
||||||
|
Display name <span className="text-muted-foreground">(optional)</span>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="settings-name"
|
id="settings-name"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
placeholder="Your name"
|
placeholder="How your name appears in the UI"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Defaults to first + last when blank. Override with a nickname if you prefer.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="settings-phone">Phone</Label>
|
<Label htmlFor="settings-phone">Phone</Label>
|
||||||
@@ -377,25 +368,16 @@ export function UserSettings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<section id="notifications" className="space-y-4">
|
||||||
<CardHeader>
|
<div>
|
||||||
<CardTitle>Notifications</CardTitle>
|
<h2 className="text-lg font-semibold">Notifications</h2>
|
||||||
<CardDescription>Choose which notifications you receive</CardDescription>
|
<p className="text-sm text-muted-foreground">
|
||||||
</CardHeader>
|
Choose which notifications you receive and how they're delivered.
|
||||||
<CardContent className="space-y-4">
|
</p>
|
||||||
{notifPrefs &&
|
</div>
|
||||||
Object.entries(NOTIF_LABELS).map(([key, label]) => (
|
<NotificationPreferencesForm />
|
||||||
<div key={key} className="flex items-center justify-between">
|
<ReminderDigestForm />
|
||||||
<Label>{label}</Label>
|
</section>
|
||||||
<Switch
|
|
||||||
checked={notifPrefs[key] ?? true}
|
|
||||||
disabled={saving === key}
|
|
||||||
onCheckedChange={(checked) => toggleNotifPref(key, checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ImageCropperDialog
|
<ImageCropperDialog
|
||||||
|
|||||||
22
src/lib/db/migrations/0049_user_profiles_first_last_name.sql
Normal file
22
src/lib/db/migrations/0049_user_profiles_first_last_name.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- Add canonical first/last name pair to user_profiles. The older
|
||||||
|
-- display_name column is kept as a derived/optional override (nicknames,
|
||||||
|
-- vanity formatting). Backfills first_name + last_name 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.
|
||||||
|
ALTER TABLE "user_profiles"
|
||||||
|
ADD COLUMN IF NOT EXISTS "first_name" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "last_name" text;
|
||||||
|
|
||||||
|
-- Backfill: split display_name on the first run of whitespace.
|
||||||
|
-- - "Alice" → first_name='Alice', last_name=NULL
|
||||||
|
-- - "Alice Smith" → first_name='Alice', last_name='Smith'
|
||||||
|
-- - "Alice Mary Smith" → first_name='Alice', last_name='Mary Smith'
|
||||||
|
-- Skip rows that already have a first_name set so re-runs are no-ops.
|
||||||
|
UPDATE "user_profiles"
|
||||||
|
SET
|
||||||
|
"first_name" = COALESCE(NULLIF(SPLIT_PART("display_name", ' ', 1), ''), "display_name"),
|
||||||
|
"last_name" = NULLIF(
|
||||||
|
REGEXP_REPLACE("display_name", '^\S+\s+', ''),
|
||||||
|
"display_name"
|
||||||
|
)
|
||||||
|
WHERE "first_name" IS NULL;
|
||||||
@@ -302,6 +302,6 @@
|
|||||||
"when": 1778500000000,
|
"when": 1778500000000,
|
||||||
"tag": "0042_missing_fk_constraints",
|
"tag": "0042_missing_fk_constraints",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -224,6 +224,17 @@ export const userProfiles = pgTable(
|
|||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => crypto.randomUUID()),
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
userId: text('user_id').notNull().unique(), // references Better Auth user ID
|
userId: text('user_id').notNull().unique(), // references Better Auth user ID
|
||||||
|
/**
|
||||||
|
* Canonical first/last name pair. Added 2026-05-09 as the primary
|
||||||
|
* source for greetings, invoicing, and DocSign field-merging — the
|
||||||
|
* older `displayName` is now kept around as a derived/optional
|
||||||
|
* override (e.g. for nicknames or vanity formatting). When migrating
|
||||||
|
* production, backfill these columns from displayName by splitting
|
||||||
|
* on the first space and zero-pad the trailing column with NULL so
|
||||||
|
* single-token names don't fail the not-null assumption.
|
||||||
|
*/
|
||||||
|
firstName: text('first_name'),
|
||||||
|
lastName: text('last_name'),
|
||||||
displayName: text('display_name').notNull(),
|
displayName: text('display_name').notNull(),
|
||||||
avatarUrl: text('avatar_url'),
|
avatarUrl: text('avatar_url'),
|
||||||
/** FK into the polymorphic `files` table — the avatar is stored
|
/** FK into the polymorphic `files` table — the avatar is stored
|
||||||
|
|||||||
Reference in New Issue
Block a user