From 5d81747d6ce1ecd542a54771eb3d21847cb7e907 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 14:56:38 -0400 Subject: [PATCH 1/6] #443 add edit name dialog to the user dropdown Reuses the existing setUserName action and nameSchema; warns that a rename propagates to every application, submitted and decided alike. Co-Authored-By: Claude Sonnet 4.6 --- components/features/edit-name-dialog.tsx | 76 ++++++++++++++++++++++++ components/layouts/user-menu.tsx | 23 ++++++- 2 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 components/features/edit-name-dialog.tsx diff --git a/components/features/edit-name-dialog.tsx b/components/features/edit-name-dialog.tsx new file mode 100644 index 00000000..352cdbf3 --- /dev/null +++ b/components/features/edit-name-dialog.tsx @@ -0,0 +1,76 @@ +'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 + + + + + + )} + /> + + Changing your name updates it everywhere — including applications + you've already submitted, and ones that have already been reviewed + or decided. + + + ); +} 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 From 242ba7268e683a27e9d3eda929789fd4e6b88dd3 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 14:56:44 -0400 Subject: [PATCH 2/6] #443 drop redundant auth client sync from the sign-up name step Better Auth's updateUser writes the same public.User row through the Prisma adapter, so it was a no-op round trip that only failed for bypass users. setUserName is the single write path now. Co-Authored-By: Claude Sonnet 4.6 --- components/features/name-field.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) 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); }); } From 72b1a904f5810ca4a9ba85ca174f32c926d4a23f Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 11:08:21 -0400 Subject: [PATCH 3/6] #443 snapshot applicant name on submission Editable profile names mean a display name can drift after a user applies. Freeze it on Application.applicantName at submit time, same pattern as GlobalApplicationAnswer.questionLabel, and prefer it over the live profile name on reviewer-facing read paths. Co-Authored-By: Claude Sonnet 4.6 --- app/(main)/(auth)/applications/[id]/page.tsx | 12 +- components/features/activity-feed.tsx | 2 +- components/features/applications-table.tsx | 10 +- components/features/recent-applications.tsx | 3 +- lib/types.ts | 2 + prisma/actions/applications.ts | 2 + prisma/data/applications.ts | 3 + .../migration.sql | 11 ++ prisma/schema.prisma | 16 ++- prisma/seed.ts | 3 + tests/db/applicant-name-snapshot.test.ts | 106 ++++++++++++++++++ 11 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 prisma/migrations/20260819000000_add_applicant_name_to_application/migration.sql create mode 100644 tests/db/applicant-name-snapshot.test.ts diff --git a/app/(main)/(auth)/applications/[id]/page.tsx b/app/(main)/(auth)/applications/[id]/page.tsx index 6b8ea5cb..f497d4a2 100644 --- a/app/(main)/(auth)/applications/[id]/page.tsx +++ b/app/(main)/(auth)/applications/[id]/page.tsx @@ -29,7 +29,12 @@ export async function generateMetadata({ const user = await getCurrentUser(); const application = await getApplicationForReview(id, user); if (!application) return {}; - return { title: application.user.name ?? application.user.email }; + return { + title: + application.applicantName ?? + application.user.name ?? + application.user.email, + }; } export default async function ApplicationDetailPage({ @@ -42,7 +47,10 @@ export default async function ApplicationDetailPage({ if (!application) notFound(); - const applicantName = application.user.name ?? application.user.email; + const applicantName = + application.applicantName ?? + application.user.name ?? + application.user.email; return (
diff --git a/components/features/activity-feed.tsx b/components/features/activity-feed.tsx index a371970b..fb2af175 100644 --- a/components/features/activity-feed.tsx +++ b/components/features/activity-feed.tsx @@ -122,7 +122,7 @@ 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 variant = APPLICATION_STATUS_BADGE_VARIANT[app.status]; return { id: app.id, diff --git a/components/features/applications-table.tsx b/components/features/applications-table.tsx index 2e0a9133..fa109142 100644 --- a/components/features/applications-table.tsx +++ b/components/features/applications-table.tsx @@ -213,7 +213,8 @@ 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 isChecked = selectedIds.has(app.id); return ( {displayName} - {app.user.name && ( + {(app.applicantName ?? app.user.name) && ( {app.user.email} @@ -262,7 +263,8 @@ 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 isChecked = selectedIds.has(app.id); return (
@@ -282,7 +284,7 @@ export function ApplicationsTable({
- {app.user.name && ( + {(app.applicantName ?? app.user.name) && ( {app.user.email} diff --git a/components/features/recent-applications.tsx b/components/features/recent-applications.tsx index d8ada93f..d6dfa10c 100644 --- a/components/features/recent-applications.tsx +++ b/components/features/recent-applications.tsx @@ -73,7 +73,8 @@ function ApplicationList({ return (
    {applications.map((app) => { - const applicantLabel = app.user.name ?? app.user.email; + const applicantLabel = + app.applicantName ?? app.user.name ?? app.user.email; return (
  • { + 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'); + }); +}); From 964ed26e81bae7a7b4793879c7be7b7b62b39b34 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 11:40:57 -0400 Subject: [PATCH 4/6] #443 fix rename warning copy and trim schema comment Ticket #443 now documents the applicant-name snapshot as intended behavior, so update the dialog's warning to match: renaming applies going forward only, and already-submitted applications keep the name on file at the time. Also trims an over-length schema comment. Co-Authored-By: Claude Sonnet 4.6 --- components/features/edit-name-dialog.tsx | 5 ++--- prisma/schema.prisma | 4 +--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/components/features/edit-name-dialog.tsx b/components/features/edit-name-dialog.tsx index 352cdbf3..de0a8fc7 100644 --- a/components/features/edit-name-dialog.tsx +++ b/components/features/edit-name-dialog.tsx @@ -67,9 +67,8 @@ export function EditNameDialog({ currentName, trigger }: EditNameDialogProps) { )} /> - Changing your name updates it everywhere — including applications - you've already submitted, and ones that have already been reviewed - or decided. + This updates your name going forward. Applications you've already + submitted keep the name you used at the time. ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd8292d0..e5f2763a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -192,9 +192,7 @@ model Application { positionId String status ApplicationStatus @default(applied) submittedAt DateTime @default(now()) - // Snapshot of User.name as of submission, so a later profile rename can't - // rewrite what the applicant was called when they applied. Same idea as - // GlobalApplicationAnswer.questionLabel. Null until submitApplication runs. + // Frozen User.name at submission; null until then. applicantName String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From 172a3c1d8a258bc973c97fa5b3db356e770fc6ac Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 12:42:36 -0400 Subject: [PATCH 5/6] #443 show current name beside a renamed applicant Application.applicantName freezes the name at submission time; show it inline as "Snapshot (Current)" wherever the applicant name appears so a rename since submission stays visible without losing the snapshot. Co-Authored-By: Claude Sonnet 5 --- app/(main)/(auth)/applications/[id]/page.tsx | 8 ++++- components/features/applications-table.tsx | 36 ++++++++++++++++---- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/app/(main)/(auth)/applications/[id]/page.tsx b/app/(main)/(auth)/applications/[id]/page.tsx index f497d4a2..eff5cd3c 100644 --- a/app/(main)/(auth)/applications/[id]/page.tsx +++ b/app/(main)/(auth)/applications/[id]/page.tsx @@ -51,12 +51,18 @@ export default async function ApplicationDetailPage({ application.applicantName ?? application.user.name ?? application.user.email; + const renamedTo = + application.applicantName && + application.user.name && + application.applicantName !== application.user.name + ? application.user.name + : null; return (

    diff --git a/components/features/applications-table.tsx b/components/features/applications-table.tsx index fa109142..a0580a3c 100644 --- a/components/features/applications-table.tsx +++ b/components/features/applications-table.tsx @@ -215,6 +215,12 @@ export function ApplicationsTable({ {applications.map((app) => { const displayName = app.applicantName ?? app.user.name ?? app.user.email; + const renamedTo = + app.applicantName && + app.user.name && + app.applicantName !== app.user.name + ? app.user.name + : null; const isChecked = selectedIds.has(app.id); return ( {displayName} + {renamedTo && ( + + ({renamedTo}) + + )} {(app.applicantName ?? app.user.name) && ( {app.user.email} @@ -265,6 +276,12 @@ export function ApplicationsTable({ {applications.map((app) => { const displayName = app.applicantName ?? app.user.name ?? app.user.email; + const renamedTo = + app.applicantName && + app.user.name && + app.applicantName !== app.user.name + ? app.user.name + : null; const isChecked = selectedIds.has(app.id); return (

    @@ -276,12 +293,19 @@ export function ApplicationsTable({ />
    - - {displayName} - +
    + + {displayName} + + {renamedTo && ( + + ({renamedTo}) + + )} +
    {(app.applicantName ?? app.user.name) && ( From 86a08fb56aa890544e862c281af1e313ef8af41e Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 12:57:53 -0400 Subject: [PATCH 6/6] #443 dedupe renamed-applicant check and show it everywhere Extracts the applicantName/user.name comparison into lib/utils.ts's getRenamedTo, reused in the application detail page and both table layouts, and adds the same "(current name)" cue to the activity feed and recent-applications widget so it's consistent across every surface that renders a rename-eligible applicant name. Co-Authored-By: Claude Sonnet 4.6 --- app/(main)/(auth)/applications/[id]/page.tsx | 8 ++------ components/features/activity-feed.tsx | 4 +++- components/features/applications-table.tsx | 15 +++------------ components/features/recent-applications.tsx | 7 +++++++ lib/utils.ts | 12 ++++++++++++ 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/app/(main)/(auth)/applications/[id]/page.tsx b/app/(main)/(auth)/applications/[id]/page.tsx index eff5cd3c..fc8a2720 100644 --- a/app/(main)/(auth)/applications/[id]/page.tsx +++ b/app/(main)/(auth)/applications/[id]/page.tsx @@ -4,6 +4,7 @@ import { notFound } from 'next/navigation'; import { getApplicationForReview } from '@/prisma/data/applications'; import { getCurrentUser } from '@/lib/auth/server'; +import { getRenamedTo } from '@/lib/utils'; import { ApplicationAnswersList } from '@/components/features/application-answers-list'; import { ApplicationStatusControl } from '@/components/features/application-status-control'; @@ -51,12 +52,7 @@ export default async function ApplicationDetailPage({ application.applicantName ?? application.user.name ?? application.user.email; - const renamedTo = - application.applicantName && - application.user.name && - application.applicantName !== application.user.name - ? application.user.name - : null; + const renamedTo = getRenamedTo(application); return (
    diff --git a/components/features/activity-feed.tsx b/components/features/activity-feed.tsx index fb2af175..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'; @@ -123,11 +124,12 @@ export async function ReviewerActivityFeed({ const items: ActivityItem[] = applications.map((app) => { 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 a0580a3c..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. @@ -215,12 +216,7 @@ export function ApplicationsTable({ {applications.map((app) => { const displayName = app.applicantName ?? app.user.name ?? app.user.email; - const renamedTo = - app.applicantName && - app.user.name && - app.applicantName !== app.user.name - ? app.user.name - : null; + const renamedTo = getRenamedTo(app); const isChecked = selectedIds.has(app.id); return ( { const displayName = app.applicantName ?? app.user.name ?? app.user.email; - const renamedTo = - app.applicantName && - app.user.name && - app.applicantName !== app.user.name - ? app.user.name - : null; + const renamedTo = getRenamedTo(app); const isChecked = selectedIds.has(app.id); return (
    diff --git a/components/features/recent-applications.tsx b/components/features/recent-applications.tsx index d6dfa10c..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'; @@ -75,6 +76,7 @@ function ApplicationList({ {applications.map((app) => { const applicantLabel = app.applicantName ?? app.user.name ?? app.user.email; + const renamedTo = getRenamedTo(app); return (
  • {applicantLabel} + {renamedTo && ( + + ({renamedTo}) + + )} {app.position.title} 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;