diff --git a/app/(main)/(auth)/applications/[id]/page.tsx b/app/(main)/(auth)/applications/[id]/page.tsx index 6b8ea5cb..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'; @@ -29,7 +30,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,13 +48,17 @@ 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; + const renamedTo = getRenamedTo(application); return (

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