diff --git a/components/features/answer-display.tsx b/components/features/answer-display.tsx
index 0cc1211e..5c51f509 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';
@@ -27,6 +28,7 @@ export function AnswerDisplay({
id={noticeId}
values={orphaned}
questionType={question.type}
+ format={question.format}
/>
)}
@@ -46,7 +48,9 @@ export function AnswerDisplay({
))}
) : (
-
{value[0]}
+
+ {formatShortAnswerValue(value[0] ?? '', 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)}
))}
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..428e8ddc 100644
--- a/lib/constants.ts
+++ b/lib/constants.ts
@@ -162,6 +162,44 @@ 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) : 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');