diff --git a/components/features/activity-feed.tsx b/components/features/activity-feed.tsx
index a371970b..1e08758c 100644
--- a/components/features/activity-feed.tsx
+++ b/components/features/activity-feed.tsx
@@ -11,6 +11,7 @@ import {
STATUS_BADGE_VARIANT_TO_DOT,
} from '@/lib/constants';
import { type ActivityItem, type Reviewer } from '@/lib/types';
+import { getRenamedTo } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { LocalTime } from '@/components/ui/local-time';
@@ -122,12 +123,13 @@ export async function ReviewerActivityFeed({
const applications = await getRecentApplications(reviewer, 10);
const items: ActivityItem[] = applications.map((app) => {
- const applicantLabel = app.user.name ?? app.user.email;
+ const applicantLabel = app.applicantName ?? app.user.name ?? app.user.email;
+ const renamedTo = getRenamedTo(app);
const variant = APPLICATION_STATUS_BADGE_VARIANT[app.status];
return {
id: app.id,
statusVariant: variant,
- sentence: `${applicantLabel} applied for ${app.position.title}`,
+ sentence: `${applicantLabel}${renamedTo ? ` (${renamedTo})` : ''} applied for ${app.position.title}`,
timestamp: app.submittedAt,
};
});
diff --git a/components/features/applications-table.tsx b/components/features/applications-table.tsx
index 2e0a9133..35b9cf5e 100644
--- a/components/features/applications-table.tsx
+++ b/components/features/applications-table.tsx
@@ -14,6 +14,7 @@ import type {
ApplicationSortDirection,
ApplicationSortField,
} from '@/lib/types';
+import { getRenamedTo } from '@/lib/utils';
import { ApplicationsBulkBar } from '@/components/features/applications-bulk-bar';
// Server-side sort; its param format stays decoupled from useSortableTable's.
@@ -213,7 +214,9 @@ export function ApplicationsTable({
{applications.map((app) => {
- const displayName = app.user.name ?? app.user.email;
+ const displayName =
+ app.applicantName ?? app.user.name ?? app.user.email;
+ const renamedTo = getRenamedTo(app);
const isChecked = selectedIds.has(app.id);
return (
{displayName}
- {app.user.name && (
+ {renamedTo && (
+
+ ({renamedTo})
+
+ )}
+ {(app.applicantName ?? app.user.name) && (
{app.user.email}
@@ -262,7 +270,9 @@ export function ApplicationsTable({
{/* Mobile stacked cards — shown only on mobile */}
{applications.map((app) => {
- const displayName = app.user.name ?? app.user.email;
+ const displayName =
+ app.applicantName ?? app.user.name ?? app.user.email;
+ const renamedTo = getRenamedTo(app);
const isChecked = selectedIds.has(app.id);
return (
@@ -274,15 +284,22 @@ export function ApplicationsTable({
/>
-
- {displayName}
-
+
+
+ {displayName}
+
+ {renamedTo && (
+
+ ({renamedTo})
+
+ )}
+
- {app.user.name && (
+ {(app.applicantName ?? app.user.name) && (
{app.user.email}
diff --git a/components/features/edit-name-dialog.tsx b/components/features/edit-name-dialog.tsx
new file mode 100644
index 00000000..de0a8fc7
--- /dev/null
+++ b/components/features/edit-name-dialog.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import type { ReactNode } from 'react';
+
+import { TriangleAlert } from 'lucide-react';
+import { toast } from 'sonner';
+import type { z } from 'zod/v4';
+
+import { setUserName } from '@/prisma/actions/profile';
+
+import { nameSchema } from '@/lib/constants';
+
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { FormDialog } from '@/components/ui/form-dialog';
+import { Input } from '@/components/ui/input';
+import { WarningCallout } from '@/components/ui/warning-callout';
+
+type NameFormValues = z.infer
;
+
+interface EditNameDialogProps {
+ currentName: string | null;
+ trigger: ReactNode;
+}
+
+export function EditNameDialog({ currentName, trigger }: EditNameDialogProps) {
+ async function onSubmit(data: NameFormValues): Promise {
+ const result = await setUserName(data);
+ if (result?.error) {
+ toast.error(result.error);
+ return false;
+ }
+ toast.success('Name updated.');
+ return true;
+ }
+
+ return (
+
+ (
+
+ Full name
+
+
+
+
+
+ )}
+ />
+
+ This updates your name going forward. Applications you've already
+ submitted keep the name you used at the time.
+
+
+ );
+}
diff --git a/components/features/name-field.tsx b/components/features/name-field.tsx
index 99c4101b..6eb0f39e 100644
--- a/components/features/name-field.tsx
+++ b/components/features/name-field.tsx
@@ -11,7 +11,6 @@ import type { z } from 'zod/v4';
import { setUserName } from '@/prisma/actions/profile';
-import { authClient } from '@/lib/auth/client';
import { nameSchema } from '@/lib/constants';
import { Button } from '@/components/ui/button';
@@ -49,15 +48,7 @@ export function NameField({ defaultName, redirectTo }: NameFieldProps) {
return;
}
- // Sync the Neon Auth account — client-only singleton, runs after the
- // server action (which is the gate-clearing source of truth).
- const authResult = await authClient.updateUser({ name: values.name });
- if (authResult.error)
- toast.warning(
- 'Name saved, but account sync failed. Reload if issues persist.',
- );
- else toast.success('Name saved');
-
+ toast.success('Name saved');
router.replace(redirectTo);
});
}
diff --git a/components/features/recent-applications.tsx b/components/features/recent-applications.tsx
index d8ada93f..508b1b8d 100644
--- a/components/features/recent-applications.tsx
+++ b/components/features/recent-applications.tsx
@@ -5,6 +5,7 @@ import { ArrowRight, Inbox } from 'lucide-react';
import { getRecentApplications } from '@/prisma/data/applications';
import { type AdminApplicationListItem, type Reviewer } from '@/lib/types';
+import { getRenamedTo } from '@/lib/utils';
import { ApplicationStatusBadge } from '@/components/features/status-badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -73,7 +74,9 @@ function ApplicationList({
return (
{applications.map((app) => {
- const applicantLabel = app.user.name ?? app.user.email;
+ const applicantLabel =
+ app.applicantName ?? app.user.name ?? app.user.email;
+ const renamedTo = getRenamedTo(app);
return (
-
{applicantLabel}
+ {renamedTo && (
+
+ ({renamedTo})
+
+ )}
{app.position.title}
diff --git a/components/layouts/user-menu.tsx b/components/layouts/user-menu.tsx
index d93c2997..00a3dbbf 100644
--- a/components/layouts/user-menu.tsx
+++ b/components/layouts/user-menu.tsx
@@ -13,6 +13,7 @@ import {
Sun,
SunMoon,
UserCircle,
+ UserPen,
} from 'lucide-react';
import { toast } from 'sonner';
@@ -22,6 +23,7 @@ import { logoutBypassUser } from '@/prisma/services/dev-bypass';
import type { NavIdentity } from '@/lib/types';
import { isError } from '@/lib/utils';
+import { EditNameDialog } from '@/components/features/edit-name-dialog';
import {
DropdownMenu,
DropdownMenuContent,
@@ -36,6 +38,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
+const MENU_ITEM_TOUCH_TARGET = 'min-h-11 md:min-h-0';
+
interface UserMenuProps {
identity: NavIdentity;
onNavigate?: () => void;
@@ -113,7 +117,7 @@ export function UserMenu({ identity, onNavigate }: UserMenuProps) {
)}
-
+
+ e.preventDefault()}
+ className={`cursor-pointer text-sm ${MENU_ITEM_TOUCH_TARGET}`}
+ >
+
+ Edit name
+
+ }
+ />
+
-
+
Theme
@@ -145,7 +162,7 @@ export function UserMenu({ identity, onNavigate }: UserMenuProps) {
variant="destructive"
disabled={pending}
onSelect={handleLogout}
- className="cursor-pointer text-sm"
+ className={`cursor-pointer text-sm ${MENU_ITEM_TOUCH_TARGET}`}
>
Log out
diff --git a/lib/types.ts b/lib/types.ts
index 9c1f34ff..abe36788 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -132,6 +132,7 @@ export type AdminApplicationListItem = Prisma.ApplicationGetPayload<{
id: true;
status: true;
submittedAt: true;
+ applicantName: true;
position: { select: { id: true; title: true } };
user: { select: { id: true; name: true; email: true } };
};
@@ -253,6 +254,7 @@ export type ApplicationForReview = Prisma.ApplicationGetPayload<{
id: true;
status: true;
submittedAt: true;
+ applicantName: true;
user: { select: { name: true; email: true } };
position: { select: { id: true; title: true } };
};
diff --git a/lib/utils.ts b/lib/utils.ts
index f4171790..4208ac88 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -21,6 +21,18 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+/** The live name, when it differs from the frozen `applicantName` on the application. */
+export function getRenamedTo(app: {
+ applicantName: string | null;
+ user: { name: string | null };
+}): string | null {
+ return app.applicantName &&
+ app.user.name &&
+ app.applicantName !== app.user.name
+ ? app.user.name
+ : null;
+}
+
export type ErrorType = { error: string };
export type ResponseType = T | ErrorType;
diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts
index 390f5a53..d0a0b240 100644
--- a/prisma/actions/applications.ts
+++ b/prisma/actions/applications.ts
@@ -441,6 +441,8 @@ export async function submitApplication(
data: {
status: 'applied',
submittedAt: new Date(),
+ // Snapshot, not a live join — see Application.applicantName.
+ applicantName: currentUser.name,
updatedById: currentUser.id,
},
});
diff --git a/prisma/data/applications.ts b/prisma/data/applications.ts
index ee86e954..419a35da 100644
--- a/prisma/data/applications.ts
+++ b/prisma/data/applications.ts
@@ -172,6 +172,7 @@ export async function getApplicationForReview(
id: true,
status: true,
submittedAt: true,
+ applicantName: true,
user: { select: { name: true, email: true } },
position: { select: { id: true, title: true } },
...applicationAnswersSelect,
@@ -227,6 +228,7 @@ export async function getRecentApplications(
id: true,
status: true,
submittedAt: true,
+ applicantName: true,
position: { select: { id: true, title: true } },
user: { select: { id: true, name: true, email: true } },
},
@@ -338,6 +340,7 @@ export async function getApplications(
id: true,
status: true,
submittedAt: true,
+ applicantName: true,
position: { select: { id: true, title: true } },
user: { select: { id: true, name: true, email: true } },
},
diff --git a/prisma/migrations/20260819000000_add_applicant_name_to_application/migration.sql b/prisma/migrations/20260819000000_add_applicant_name_to_application/migration.sql
new file mode 100644
index 00000000..fe850069
--- /dev/null
+++ b/prisma/migrations/20260819000000_add_applicant_name_to_application/migration.sql
@@ -0,0 +1,11 @@
+-- AlterTable
+ALTER TABLE "Application" ADD COLUMN "applicantName" TEXT;
+
+-- Backfill already-submitted applications from the current profile name —
+-- the best available approximation, since no earlier snapshot exists. Drafts
+-- stay null; submitApplication captures the real snapshot going forward.
+UPDATE "Application" a
+SET "applicantName" = u."name"
+FROM "User" u
+WHERE u."id" = a."userId"
+ AND a."status" != 'draft';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 9c4b9715..e5f2763a 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -192,12 +192,14 @@ model Application {
positionId String
status ApplicationStatus @default(applied)
submittedAt DateTime @default(now())
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- deletedAt DateTime?
- createdById String
- updatedById String
- deletedById String?
+ // Frozen User.name at submission; null until then.
+ applicantName String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ deletedAt DateTime?
+ createdById String
+ updatedById String
+ deletedById String?
createdBy User @relation("ApplicationCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("ApplicationUpdatedBy", fields: [updatedById], references: [id])
diff --git a/prisma/seed.ts b/prisma/seed.ts
index af8fb0ef..b5e2ac3a 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -190,6 +190,9 @@ async function main() {
userId: user.id,
positionId: position.id,
status: def.status,
+ // Mirrors submitApplication: only a submitted application has a
+ // name snapshot; drafts stay null.
+ ...(def.status !== 'draft' ? { applicantName: user.name } : {}),
...(def.submittedInDays !== undefined
? { submittedAt: utcDayOffset(now, -def.submittedInDays) }
: {}),
diff --git a/tests/db/applicant-name-snapshot.test.ts b/tests/db/applicant-name-snapshot.test.ts
new file mode 100644
index 00000000..7d0445ce
--- /dev/null
+++ b/tests/db/applicant-name-snapshot.test.ts
@@ -0,0 +1,106 @@
+import {
+ cleanupFixtures,
+ createTestApplication,
+ createTestPosition,
+ createTestUser,
+} from '@/tests/helpers/fixtures';
+import { actAs } from '@/tests/stubs/auth-server';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { submitApplication } from '@/prisma/actions/applications';
+import { setUserName } from '@/prisma/actions/profile';
+import type { Position, User } from '@/prisma/client';
+import {
+ getApplicationForReview,
+ getApplications,
+} from '@/prisma/data/applications';
+
+import { prisma } from '@/lib/prisma';
+
+let admin: User;
+let openPosition: Position;
+
+beforeAll(async () => {
+ admin = await createTestUser({ isAdmin: true });
+ openPosition = await createTestPosition(admin);
+});
+
+afterAll(async () => {
+ await cleanupFixtures();
+});
+
+describe('Application.applicantName snapshot', () => {
+ it('is null on a draft and captured at submit time', async () => {
+ const applicant = await createTestUser({ name: 'Ada Lovelace' });
+ const draft = await createTestApplication(applicant, openPosition, {
+ status: 'draft',
+ });
+ expect(draft.applicantName).toBeNull();
+
+ actAs(applicant);
+ const result = await submitApplication(draft.id);
+ expect(result).toBeUndefined();
+
+ const submitted = await prisma.application.findUniqueOrThrow({
+ where: { id: draft.id },
+ select: { applicantName: true },
+ });
+ expect(submitted.applicantName).toBe('Ada Lovelace');
+ });
+
+ it('keeps the submitted snapshot after the applicant renames their profile', async () => {
+ const applicant = await createTestUser({ name: 'Grace Hopper' });
+ const draft = await createTestApplication(applicant, openPosition, {
+ status: 'draft',
+ });
+
+ actAs(applicant);
+ await submitApplication(draft.id);
+
+ const result = await setUserName({ name: 'Grace M. Hopper' });
+ expect(result).toBeUndefined();
+
+ const renamedUser = await prisma.user.findUniqueOrThrow({
+ where: { id: applicant.id },
+ select: { name: true },
+ });
+ expect(renamedUser.name).toBe('Grace M. Hopper');
+
+ const application = await prisma.application.findUniqueOrThrow({
+ where: { id: draft.id },
+ select: { applicantName: true },
+ });
+ expect(application.applicantName).toBe('Grace Hopper');
+ });
+
+ it('surfaces the frozen name (not the live profile name) on the reviewer detail read path', async () => {
+ const applicant = await createTestUser({ name: 'Katherine Johnson' });
+ const draft = await createTestApplication(applicant, openPosition, {
+ status: 'draft',
+ });
+
+ actAs(applicant);
+ await submitApplication(draft.id);
+ await setUserName({ name: 'K. Johnson' });
+
+ const forReview = await getApplicationForReview(draft.id, admin);
+ expect(forReview?.applicantName).toBe('Katherine Johnson');
+ expect(forReview?.user.name).toBe('K. Johnson');
+ });
+
+ it('surfaces the frozen name on the reviewer list read path', async () => {
+ const applicant = await createTestUser({ name: 'Margaret Hamilton' });
+ const draft = await createTestApplication(applicant, openPosition, {
+ status: 'draft',
+ });
+
+ actAs(applicant);
+ await submitApplication(draft.id);
+ await setUserName({ name: 'Peggy Hamilton' });
+
+ const list = await getApplications(admin, {});
+ const row = list.find((a) => a.id === draft.id);
+ expect(row?.applicantName).toBe('Margaret Hamilton');
+ expect(row?.user.name).toBe('Peggy Hamilton');
+ });
+});