From 2ce6f77359c3e10947b1b91a6dd97e888e156ff6 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Sun, 20 Sep 2026 23:57:54 -0400 Subject: [PATCH 1/3] #731 normalize and format phone_number short answers Store new phone_number answers as digits with an optional leading +, and mask 10/11-digit US-shaped values on every short-answer renderer. Unparseable and international values render exactly as stored, and existing rows are never backfilled. Co-Authored-By: Claude Sonnet 4.6 --- components/features/answer-display.tsx | 5 +- .../features/application-answers-list.tsx | 11 ++- docs/WORKFLOWS.md | 8 +- lib/constants.ts | 40 ++++++++ lib/types.ts | 1 + prisma/actions/applications.ts | 8 +- prisma/actions/profile.ts | 8 +- prisma/data/applications.ts | 8 +- tests/db/phone-number-normalization.test.ts | 82 +++++++++++++++++ tests/unit/constants.test.ts | 91 +++++++++++++++++++ 10 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 tests/db/phone-number-normalization.test.ts diff --git a/components/features/answer-display.tsx b/components/features/answer-display.tsx index 0cc1211e..206a04c3 100644 --- a/components/features/answer-display.tsx +++ b/components/features/answer-display.tsx @@ -1,3 +1,4 @@ +import { formatShortAnswerValue } from '@/lib/constants'; import type { AnswerQuestion, QuestionFileTarget } from '@/lib/types'; import { answerFieldIds, partitionAnswerValue } from '@/lib/utils'; @@ -46,7 +47,9 @@ export function AnswerDisplay({ ))} ) : ( -

{value[0]}

+

+ {formatShortAnswerValue(value[0] ?? '', question.format)} +

)} ); diff --git a/components/features/application-answers-list.tsx b/components/features/application-answers-list.tsx index 36dcd265..67b17e1b 100644 --- a/components/features/application-answers-list.tsx +++ b/components/features/application-answers-list.tsx @@ -1,3 +1,4 @@ +import { formatShortAnswerValue } from '@/lib/constants'; import { type ApplicationReviewAnswer } from '@/lib/types'; import { AnswerFileLink } from '@/components/features/answer-file-link'; @@ -75,10 +76,16 @@ function AnswerValue({ case 'short_answer': return answer.value.length > 1 ? (
- + + formatShortAnswerValue(v, answer.format), + )} + />
) : ( -
{answer.value[0]}
+
+ {formatShortAnswerValue(answer.value[0] ?? '', answer.format)} +
); default: { const exhaustiveCheck: never = answer.type; diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index d53c7533..d82d17e5 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -229,7 +229,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a ### AP-2 Answer profile questions - **Trigger** — the Profile item in the user menu, the completeness banner, or the apply page's **Go to Profile** button. -- **Happy path** — `getProfileData(user.id)` returns every non-deleted global question with the caller's answer. Each field autosaves on blur through `updateGlobalAnswer`, which validates format and option membership and upserts scoped to the caller. Answers are shared across every application: "Your answers are shared across every application." +- **Happy path** — `getProfileData(user.id)` returns every non-deleted global question with the caller's answer. Each field autosaves on blur through `updateGlobalAnswer`, which validates format and option membership and upserts scoped to the caller. A `phone_number`-formatted answer is additionally normalized to digits with an optional leading `+` before it's stored. Answers are shared across every application: "Your answers are shared across every application." - **Failure / edge** - Format mismatch on a `short_answer` with a `format` → the format's message from `SHORT_ANSWER_FORMAT_ERROR_MESSAGES`, inline; the autosave is skipped so the error isn't also toasted. - Option not in the question's list, too many values, or over the length limit → the message from `getAnswerValueError`. @@ -277,7 +277,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a ### AP-6 Answer application questions - **Trigger** — the stepper on `/positions/[id]/apply` for a `draft` or `withdrawn` application. -- **Happy path** — step 1 holds the global (profile) questions, step 2 the position questions; the stepper collapses to one step when the position has no questions of its own. Each field autosaves on blur via `createOrUpdateApplicationAnswer`, which re-reads the question's label and shape from the database (never from the client) and upserts the answer with that label snapshotted onto the row. +- **Happy path** — step 1 holds the global (profile) questions, step 2 the position questions; the stepper collapses to one step when the position has no questions of its own. Each field autosaves on blur via `createOrUpdateApplicationAnswer`, which re-reads the question's label and shape from the database (never from the client) and upserts the answer with that label snapshotted onto the row. A `phone_number`-formatted answer is normalized to digits with an optional leading `+` the same way as [AP-2](#ap-2-answer-profile-questions). - **Failure / edge** - Format mismatch, bad option, too many values, or over-length → the specific message inline and as a toast; the value is not persisted. - Application no longer applicant-editable → **"This application has already been submitted. Withdraw it to make changes."** @@ -345,7 +345,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a ### AP-11 View one of your applications - **Trigger** — a position title link on `/applications`, or the redirect after submitting ([AP-9](#ap-9-submit-an-application)). -- **Happy path** — `getMyApplication(id, user.id)` is scoped to the caller with the same visibility as the list, and returns the public status ([XC-8](#xc-8-applicant-facing-status-grouping)). The header shows the position title with the status badge directly beside it, the status sentence underneath, and the primary/row actions (Continue, Edit & resubmit, Withdraw, Delete draft — whichever applies) right-aligned on that same header row; below it, "Applied " (or "Draft · last saved ", from `lastSavedAt` — a draft's own `updatedAt`, never exposed for a submitted application) and a link to the position, then both answer groups — "Your Profile Answers" and "Your Answers for This Position" — full width. The position-answers group lists **every** live position question, in the position's own question order, whether it was answered or not: an answered row renders from its snapshotted `questionLabel`/`value`/`type`, so a retyped or relabeled question still shows the original label and value; an unanswered one shows the current question's label with "No answer" in the value slot. An answer to a since-deleted question still renders, appended after the live questions. The header's primary action follows the same rule as the list ([AP-10](#ap-10-track-your-applications)): on a draft or withdrawn application whose position is no longer accepting, Continue/Edit & resubmit is disabled with the reason in a tooltip on hover/focus, and Delete/Withdraw still render alongside it. +- **Happy path** — `getMyApplication(id, user.id)` is scoped to the caller with the same visibility as the list, and returns the public status ([XC-8](#xc-8-applicant-facing-status-grouping)). The header shows the position title with the status badge directly beside it, the status sentence underneath, and the primary/row actions (Continue, Edit & resubmit, Withdraw, Delete draft — whichever applies) right-aligned on that same header row; below it, "Applied " (or "Draft · last saved ", from `lastSavedAt` — a draft's own `updatedAt`, never exposed for a submitted application) and a link to the position, then both answer groups — "Your Profile Answers" and "Your Answers for This Position" — full width. The position-answers group lists **every** live position question, in the position's own question order, whether it was answered or not: an answered row renders from its snapshotted `questionLabel`/`value`/`type`, so a retyped or relabeled question still shows the original label and value; an unanswered one shows the current question's label with "No answer" in the value slot. An answer to a since-deleted question still renders, appended after the live questions. The header's primary action follows the same rule as the list ([AP-10](#ap-10-track-your-applications)): on a draft or withdrawn application whose position is no longer accepting, Continue/Edit & resubmit is disabled with the reason in a tooltip on hover/focus, and Delete/Withdraw still render alongside it. A `short_answer` with `format: 'phone_number'` renders 10-digit and 11-digit-leading-`1` values masked as `(555) 123-4567`; anything else — international numbers, legacy shapes that don't fit the mask — renders exactly as stored. - **Deadline (drafts and withdrawn applications only)** — a fourth segment after the **View position** link, `DeadlineIndicator` (`variant="full"`, `emphasizeUrgency`) tiered against a server-resolved `now` — same helper and thresholds as [AP-10](#ap-10-track-your-applications). The gate is separate from the "Draft · last saved" vs "Applied" prefix above it, so a withdrawn application keeps its "Applied " prefix while still gaining the deadline segment. A submitted or terminal application's meta line is unchanged; `getMyApplication`'s select already carries the fields needed, so no data-layer change. - **Failure / edge** - Not the caller's, soft-deleted, or on an unpublished position → `notFound()`, so a bookmarked URL cannot outlive its list row. @@ -523,7 +523,7 @@ A user who manages at least one non-deleted position. Manager status is **derive ### PM-9 Open an application for review - **Trigger** — a row on `/manage/applications` (`/manage/applications/[id]`). -- **Happy path** — `getApplicationForReview(id, user)` uses the `listable` scope — withdrawn rows are kept, drafts are not — and `getApplicationStatusHistory(id, user)` fetches alongside it. The page shows a "Back to Applications" link, then a header row with the applicant's snapshotted name and status badge together, their email underneath, and a header action appropriate to status, right-aligned on that same row — a split button for the four unresolved statuses (its caret dropdown ends in **See more**, which opens the status dialog), or for terminal decisions and non-reviewable statuses alike, the same explanatory note plus a standalone caret whose dropdown menu also ends in **See more** — there is no separate standalone `⋯` for any status, since that would be a second control shape doing the same job as the caret; below it, a linked position title and the applied date, then an "Other Applications" section (`getApplicantOtherApplications`) followed by the profile and position answer groups, then an "Email History" section (`getApplicationEmailHistory`) last, each full width. The "Other Applications" section lists this applicant's other applications platform-wide — including positions the viewer doesn't manage — with precise status, applied date, and the position title linked to `/positions/[id]`; a row links to `/manage/applications/[id]` only when the viewer can actually open it (admin, or a manager of that position) — otherwise the row shows no link at all. The position-answers group lists **every** live position question, in the position's own question order, whether it was answered or not: an answered row renders from its snapshotted `questionLabel`/`value`/`type`, so a question retyped or relabeled after submission still shows the original label and value; an unanswered one shows the current question's label with "No answer" in the value slot. An answer to a since-deleted question still renders, appended after the live questions. The "Email History" section lists every `EmailLog` row for this application — subject, a status badge, and a meta line of `{Template label} · {timestamp} · {status description}` (`getEmailLogOccurredAt`) — through the same `listable` scope as the rest of the page, applied through the `application` relation; OTP rows never appear, since they're written with no `applicationId` and so can never match the equality filter (no template filter exists to be forgotten or bypassed). The template label is what tells `application_accepted` and `application_rejected` apart here, since both now render an identical subject ([XC-9](#xc-9-applicant-email)). `sent` is shown distinctly from `delivered` — a sentence under the timestamp states that delivery isn't confirmed yet, since a provider hand-off is not proof of receipt. See `PERMISSIONS.md` → "Cross-scope disclosure" for the authorization rule. A manager working this queue may separately receive a daily digest of new arrivals or a weekly reminder of everything still unresolved across their positions ([XC-10](#xc-10-manager-digests)) — neither email is tied to any one application, so neither appears in this page's Email History section. +- **Happy path** — `getApplicationForReview(id, user)` uses the `listable` scope — withdrawn rows are kept, drafts are not — and `getApplicationStatusHistory(id, user)` fetches alongside it. The page shows a "Back to Applications" link, then a header row with the applicant's snapshotted name and status badge together, their email underneath, and a header action appropriate to status, right-aligned on that same row — a split button for the four unresolved statuses (its caret dropdown ends in **See more**, which opens the status dialog), or for terminal decisions and non-reviewable statuses alike, the same explanatory note plus a standalone caret whose dropdown menu also ends in **See more** — there is no separate standalone `⋯` for any status, since that would be a second control shape doing the same job as the caret; below it, a linked position title and the applied date, then an "Other Applications" section (`getApplicantOtherApplications`) followed by the profile and position answer groups, then an "Email History" section (`getApplicationEmailHistory`) last, each full width. The "Other Applications" section lists this applicant's other applications platform-wide — including positions the viewer doesn't manage — with precise status, applied date, and the position title linked to `/positions/[id]`; a row links to `/manage/applications/[id]` only when the viewer can actually open it (admin, or a manager of that position) — otherwise the row shows no link at all. The position-answers group lists **every** live position question, in the position's own question order, whether it was answered or not: an answered row renders from its snapshotted `questionLabel`/`value`/`type`, so a question retyped or relabeled after submission still shows the original label and value; an unanswered one shows the current question's label with "No answer" in the value slot. An answer to a since-deleted question still renders, appended after the live questions. A `short_answer` with `format: 'phone_number'` renders 10-digit and 11-digit-leading-`1` values masked as `(555) 123-4567`, same as [AP-11](#ap-11-view-one-of-your-applications); anything else renders exactly as stored. The "Email History" section lists every `EmailLog` row for this application — subject, a status badge, and a meta line of `{Template label} · {timestamp} · {status description}` (`getEmailLogOccurredAt`) — through the same `listable` scope as the rest of the page, applied through the `application` relation; OTP rows never appear, since they're written with no `applicationId` and so can never match the equality filter (no template filter exists to be forgotten or bypassed). The template label is what tells `application_accepted` and `application_rejected` apart here, since both now render an identical subject ([XC-9](#xc-9-applicant-email)). `sent` is shown distinctly from `delivered` — a sentence under the timestamp states that delivery isn't confirmed yet, since a provider hand-off is not proof of receipt. See `PERMISSIONS.md` → "Cross-scope disclosure" for the authorization rule. A manager working this queue may separately receive a daily digest of new arrivals or a weekly reminder of everything still unresolved across their positions ([XC-10](#xc-10-manager-digests)) — neither email is tied to any one application, so neither appears in this page's Email History section. - **Failure / edge** - Outside the caller's scope, a draft, or missing → `notFound()`; unauthorized and missing are indistinguishable. - The applicant renamed themselves since submitting → the heading reads " ()". diff --git a/lib/constants.ts b/lib/constants.ts index 09e17daa..b1b8ff22 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -162,6 +162,46 @@ export function matchesShortAnswerFormat( return SHORT_ANSWER_FORMAT_PATTERNS[format].test(value.trim()); } +// Digits + optional leading +; 00 promotes to + (validator treats them as the same prefix). +export function normalizePhoneNumber(value: string): string { + const trimmed = value.trim(); + if (!matchesShortAnswerFormat(trimmed, 'phone_number')) return trimmed; + + const hasPlus = trimmed.startsWith('+'); + const digits = trimmed.replace(/\D/g, ''); + if (hasPlus) return `+${digits}`; + if (digits.startsWith('00')) return `+${digits.slice(2)}`; + return digits; +} + +// Digit-strips first so legacy, unnormalized rows also mask. Anything else renders unchanged. +export function formatPhoneNumber(value: string): string { + const digits = value.replace(/\D/g, ''); + if (digits.length === 10) + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; + if (digits.length === 11 && digits.startsWith('1')) + return `(${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`; + return value; +} + +// Trims every format; phone_number additionally normalizes to digits + optional leading +. +export function normalizeShortAnswerValue( + value: string, + format: ShortAnswerFormatValue, +): string { + return format === 'phone_number' + ? normalizePhoneNumber(value.trim()) + : value.trim(); +} + +// Identity for every format except phone_number, which masks US-shaped values. +export function formatShortAnswerValue( + value: string, + format: ShortAnswerFormatValue | null, +): string { + return format === 'phone_number' ? formatPhoneNumber(value) : value; +} + export const baseQuestionSchema = z.object({ label: z.string().min(1, 'Label is required'), type: z.enum(QUESTION_TYPE_VALUES), diff --git a/lib/types.ts b/lib/types.ts index aecb391a..9a33e20d 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -342,6 +342,7 @@ export type ApplicationReviewAnswer = { questionLabel: string; value: string[]; type: QuestionType; + format: ShortAnswerFormat | null; isGlobal: boolean; }; diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts index 8d1a3f14..ee36776c 100644 --- a/prisma/actions/applications.ts +++ b/prisma/actions/applications.ts @@ -34,6 +34,7 @@ import { isAllowedApplicationStatusTransition, isApplicantEditableApplicationStatus, matchesShortAnswerFormat, + normalizeShortAnswerValue, } from '@/lib/constants'; import { type DecisionEmailRecipient, @@ -273,10 +274,11 @@ export async function createOrUpdateApplicationAnswer(params: { const answerError = getAnswerValueError(question, value); if (answerError) return { error: answerError }; - // matchesShortAnswerFormat trims internally, so save the trimmed value. + // Trims every short-answer format; phone_number additionally normalizes to digits. + const format = question.format; const persistedValue = - question.type === 'short_answer' && question.format - ? value.map((v) => v.trim()) + question.type === 'short_answer' && format + ? value.map((v) => normalizeShortAnswerValue(v, format)) : value; if (isGlobal) { diff --git a/prisma/actions/profile.ts b/prisma/actions/profile.ts index 79e6cb88..50f0e24e 100644 --- a/prisma/actions/profile.ts +++ b/prisma/actions/profile.ts @@ -14,6 +14,7 @@ import { getAnswerValueError, matchesShortAnswerFormat, nameSchema, + normalizeShortAnswerValue, } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; import { type ErrorType, type ResponseType } from '@/lib/utils'; @@ -72,10 +73,11 @@ export async function updateGlobalAnswer( const answerError = getAnswerValueError(question, parsed.data.value); if (answerError) return { error: answerError }; - // matchesShortAnswerFormat trims internally, so save the trimmed value. + // Trims every short-answer format; phone_number additionally normalizes to digits. + const format = question.format; const persistedValue = - question.type === 'short_answer' && question.format - ? parsed.data.value.map((v) => v.trim()) + question.type === 'short_answer' && format + ? parsed.data.value.map((v) => normalizeShortAnswerValue(v, format)) : parsed.data.value; const result = await prisma.globalAnswer.upsert({ diff --git a/prisma/data/applications.ts b/prisma/data/applications.ts index 12434dcb..638f0851 100644 --- a/prisma/data/applications.ts +++ b/prisma/data/applications.ts @@ -107,6 +107,7 @@ const applicationAnswersSelect = { questionLabel: true, questionType: true, value: true, + globalQuestion: { select: { format: true } }, }, }, positionAnswers: { @@ -118,6 +119,7 @@ const applicationAnswersSelect = { questionLabel: true, questionType: true, value: true, + positionQuestion: { select: { format: true } }, }, }, } as const; @@ -127,7 +129,7 @@ const positionQuestionsSelect = { questions: { where: { deletedAt: null }, orderBy: { order: 'asc' }, - select: { id: true, label: true, type: true }, + select: { id: true, label: true, type: true, format: true }, }, } as const; @@ -162,6 +164,7 @@ function normalizeApplicationAnswers( questionLabel: answer.questionLabel, value: answer.value, type: answer.questionType, + format: question.format, isGlobal: false, }; return { @@ -170,6 +173,7 @@ function normalizeApplicationAnswers( questionLabel: question.label, value: [], type: question.type, + format: question.format, isGlobal: false, }; }, @@ -183,6 +187,7 @@ function normalizeApplicationAnswers( questionLabel: a.questionLabel, value: a.value, type: a.questionType, + format: a.positionQuestion.format, isGlobal: false, })); @@ -193,6 +198,7 @@ function normalizeApplicationAnswers( questionLabel: a.questionLabel, value: a.value, type: a.questionType, + format: a.globalQuestion.format, isGlobal: true, })), positionAnswers: [...liveQuestionAnswers, ...orphanedAnswers], diff --git a/tests/db/phone-number-normalization.test.ts b/tests/db/phone-number-normalization.test.ts new file mode 100644 index 00000000..5d857975 --- /dev/null +++ b/tests/db/phone-number-normalization.test.ts @@ -0,0 +1,82 @@ +import { + cleanupFixtures, + createTestApplication, + createTestGlobalQuestion, + createTestPosition, + createTestUser, +} from '@/tests/helpers/fixtures'; +import { actAs } from '@/tests/stubs/auth-server'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createOrUpdateApplicationAnswer } from '@/prisma/actions/applications'; +import { updateGlobalAnswer } from '@/prisma/actions/profile'; +import type { Position, User } from '@/prisma/client'; + +import { prisma } from '@/lib/prisma'; +import { isError } from '@/lib/utils'; + +let admin: User; +let openPosition: Position; + +beforeAll(async () => { + admin = await createTestUser({ isAdmin: true }); + openPosition = await createTestPosition(admin); +}); + +afterAll(async () => { + await cleanupFixtures(); +}); + +describe('phone_number normalization on write', () => { + it('updateGlobalAnswer stores a digits-only value', async () => { + const question = await createTestGlobalQuestion(admin, { + format: 'phone_number', + }); + const applicant = await createTestUser(); + + actAs(applicant); + const result = await updateGlobalAnswer(question.id, ['(617) 555-0100']); + expect(isError(result)).toBe(false); + + const answer = await prisma.globalAnswer.findUniqueOrThrow({ + where: { + userId_globalQuestionId: { + userId: applicant.id, + globalQuestionId: question.id, + }, + }, + select: { value: true }, + }); + expect(answer.value).toEqual(['6175550100']); + }); + + it('createOrUpdateApplicationAnswer stores a digits-only value', async () => { + const question = await createTestGlobalQuestion(admin, { + format: 'phone_number', + required: false, + }); + const applicant = await createTestUser(); + const draft = await createTestApplication(applicant, openPosition, { + status: 'draft', + }); + + actAs(applicant); + const result = await createOrUpdateApplicationAnswer({ + applicationId: draft.id, + questionId: question.id, + value: ['(617) 555-0100'], + }); + expect(isError(result)).toBe(false); + + const answer = await prisma.globalApplicationAnswer.findUniqueOrThrow({ + where: { + applicationId_globalQuestionId: { + applicationId: draft.id, + globalQuestionId: question.id, + }, + }, + select: { value: true }, + }); + expect(answer.value).toEqual(['6175550100']); + }); +}); diff --git a/tests/unit/constants.test.ts b/tests/unit/constants.test.ts index f1637197..0a97acd7 100644 --- a/tests/unit/constants.test.ts +++ b/tests/unit/constants.test.ts @@ -26,11 +26,15 @@ import { REVIEWER_APPLICATION_STATUSES, TERMINAL_DECISION_STATUSES, UNRESOLVED_APPLICATION_STATUSES, + formatPhoneNumber, + formatShortAnswerValue, getAnswerBlurError, getAnswerValueError, getStatusOptions, makePositionFormSchema, matchesShortAnswerFormat, + normalizePhoneNumber, + normalizeShortAnswerValue, positionDateOrderIssues, positionPastDateIssues, positionScheduleIssues, @@ -277,6 +281,93 @@ describe('matchesShortAnswerFormat', () => { }); }); +describe('normalizePhoneNumber / formatPhoneNumber', () => { + it('round-trips a bare 10-digit number', () => { + expect(normalizePhoneNumber('5551234567')).toBe('5551234567'); + expect(formatPhoneNumber(normalizePhoneNumber('5551234567'))).toBe( + '(555) 123-4567', + ); + }); + + it('round-trips a legacy punctuated 10-digit number', () => { + expect(normalizePhoneNumber('(555) 123-4567')).toBe('5551234567'); + expect(formatPhoneNumber(normalizePhoneNumber('(555) 123-4567'))).toBe( + '(555) 123-4567', + ); + }); + + it('round-trips an 11-digit number with a leading 1 and +', () => { + expect(normalizePhoneNumber('+1 555 123 4567')).toBe('+15551234567'); + expect(formatPhoneNumber(normalizePhoneNumber('+1 555 123 4567'))).toBe( + '(555) 123-4567', + ); + }); + + it('leaves an already-normalized value unchanged', () => { + expect(normalizePhoneNumber('+15551234567')).toBe('+15551234567'); + expect(formatPhoneNumber('+15551234567')).toBe('(555) 123-4567'); + }); + + it('round-trips a + international number by rendering unchanged', () => { + expect(normalizePhoneNumber('+44 20 7123 4567')).toBe('+442071234567'); + expect(formatPhoneNumber(normalizePhoneNumber('+44 20 7123 4567'))).toBe( + '+442071234567', + ); + }); + + it('round-trips a 00 international number, promoting the prefix to +', () => { + expect(normalizePhoneNumber('0044 20 7123 4567')).toBe('+442071234567'); + expect(formatPhoneNumber(normalizePhoneNumber('0044 20 7123 4567'))).toBe( + '+442071234567', + ); + }); + + it('round-trips a 7-digit local number by rendering unchanged', () => { + expect(normalizePhoneNumber('555-0100')).toBe('5550100'); + expect(formatPhoneNumber(normalizePhoneNumber('555-0100'))).toBe('5550100'); + }); + + it('round-trips a validator-passing value that fits no mask', () => { + expect(normalizePhoneNumber('12345678901234')).toBe('12345678901234'); + expect(formatPhoneNumber(normalizePhoneNumber('12345678901234'))).toBe( + '12345678901234', + ); + }); + + it('returns unparseable input unchanged from normalize', () => { + expect(normalizePhoneNumber('abc')).toBe('abc'); + }); +}); + +describe('normalizeShortAnswerValue / formatShortAnswerValue', () => { + it('normalizes a phone_number value', () => { + expect(normalizeShortAnswerValue('(617) 555-0100', 'phone_number')).toBe( + '6175550100', + ); + }); + + it('formats a phone_number value', () => { + expect(formatShortAnswerValue('6175550100', 'phone_number')).toBe( + '(617) 555-0100', + ); + }); + + it('trims but otherwise leaves email/url/zip_code unchanged on normalize', () => { + expect(normalizeShortAnswerValue(' a@b.com ', 'email')).toBe('a@b.com'); + expect(normalizeShortAnswerValue(' example.com ', 'url')).toBe( + 'example.com', + ); + expect(normalizeShortAnswerValue(' 02115 ', 'zip_code')).toBe('02115'); + }); + + it('is the identity on format for email/url/zip_code and a null format', () => { + expect(formatShortAnswerValue('a@b.com', 'email')).toBe('a@b.com'); + expect(formatShortAnswerValue('example.com', 'url')).toBe('example.com'); + expect(formatShortAnswerValue('02115', 'zip_code')).toBe('02115'); + expect(formatShortAnswerValue('6175550100', null)).toBe('6175550100'); + }); +}); + describe('status-set invariants', () => { it('REVIEWER_APPLICATION_STATUSES excludes draft and withdrawn', () => { expect(REVIEWER_APPLICATION_STATUSES).not.toContain('draft'); From 7bf8e7577d0247c8d6ee71d9750f5c2882ec540d Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Mon, 21 Sep 2026 00:07:07 -0400 Subject: [PATCH 2/3] #731 address review feedback Run orphaned short_answer values through formatShortAnswerValue in AnswerMismatchNotice so a legacy multi-value phone_number row masks consistently in the mismatch notice. Co-Authored-By: Claude Sonnet 4.6 --- components/features/answer-display.tsx | 1 + components/features/answer-field.tsx | 1 + components/features/answer-mismatch-notice.tsx | 9 ++++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/components/features/answer-display.tsx b/components/features/answer-display.tsx index 206a04c3..5c51f509 100644 --- a/components/features/answer-display.tsx +++ b/components/features/answer-display.tsx @@ -28,6 +28,7 @@ export function AnswerDisplay({ id={noticeId} values={orphaned} questionType={question.type} + format={question.format} /> )} diff --git a/components/features/answer-field.tsx b/components/features/answer-field.tsx index 735c3e4b..51d2bb1a 100644 --- a/components/features/answer-field.tsx +++ b/components/features/answer-field.tsx @@ -82,6 +82,7 @@ export function AnswerField({ id={noticeId} values={orphaned} questionType={question.type} + format={question.format} /> ); diff --git a/components/features/answer-mismatch-notice.tsx b/components/features/answer-mismatch-notice.tsx index a43cc648..21aee36e 100644 --- a/components/features/answer-mismatch-notice.tsx +++ b/components/features/answer-mismatch-notice.tsx @@ -1,11 +1,17 @@ import type { QuestionType } from '@/prisma/client'; +import { + type ShortAnswerFormatValue, + formatShortAnswerValue, +} from '@/lib/constants'; + import { WarningCallout } from '@/components/ui/warning-callout'; interface AnswerMismatchNoticeProps { id: string; values: string[]; questionType: QuestionType; + format: ShortAnswerFormatValue | null; } // Callers wire `id` to the control's `aria-describedby`. @@ -13,6 +19,7 @@ export function AnswerMismatchNotice({ id, values, questionType, + format, }: AnswerMismatchNoticeProps) { if (values.length === 0) return null; @@ -39,7 +46,7 @@ export function AnswerMismatchNotice({
    {values.map((v, i) => (
  • - {v} + {formatShortAnswerValue(v, format)}
  • ))}
From e3c1561915d200513b47230a33a820c9afe2a875 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 23 Sep 2026 19:56:01 -0400 Subject: [PATCH 3/3] #731 address review feedback Co-Authored-By: Claude Sonnet 4.6 --- lib/constants.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index b1b8ff22..428e8ddc 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -189,9 +189,7 @@ export function normalizeShortAnswerValue( value: string, format: ShortAnswerFormatValue, ): string { - return format === 'phone_number' - ? normalizePhoneNumber(value.trim()) - : value.trim(); + return format === 'phone_number' ? normalizePhoneNumber(value) : value.trim(); } // Identity for every format except phone_number, which masks US-shaped values.