Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion components/features/answer-display.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { formatShortAnswerValue } from '@/lib/constants';
import type { AnswerQuestion, QuestionFileTarget } from '@/lib/types';
import { answerFieldIds, partitionAnswerValue } from '@/lib/utils';

Expand Down Expand Up @@ -27,6 +28,7 @@ export function AnswerDisplay({
id={noticeId}
values={orphaned}
questionType={question.type}
format={question.format}
/>
)}

Expand All @@ -46,7 +48,9 @@ export function AnswerDisplay({
))}
</div>
) : (
<p className="text-foreground text-base font-medium">{value[0]}</p>
<p className="text-foreground text-base font-medium">
{formatShortAnswerValue(value[0] ?? '', question.format)}
</p>
)}
</>
);
Expand Down
1 change: 1 addition & 0 deletions components/features/answer-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export function AnswerField({
id={noticeId}
values={orphaned}
questionType={question.type}
format={question.format}
/>
);

Expand Down
9 changes: 8 additions & 1 deletion components/features/answer-mismatch-notice.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
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`.
export function AnswerMismatchNotice({
id,
values,
questionType,
format,
}: AnswerMismatchNoticeProps) {
if (values.length === 0) return null;

Expand All @@ -39,7 +46,7 @@ export function AnswerMismatchNotice({
<ul className="mt-1 flex flex-col gap-0.5">
{values.map((v, i) => (
<li key={i} className="font-medium">
{v}
{formatShortAnswerValue(v, format)}
</li>
))}
</ul>
Expand Down
11 changes: 9 additions & 2 deletions components/features/application-answers-list.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { formatShortAnswerValue } from '@/lib/constants';
import { type ApplicationReviewAnswer } from '@/lib/types';

import { AnswerFileLink } from '@/components/features/answer-file-link';
Expand Down Expand Up @@ -75,10 +76,16 @@ function AnswerValue({
case 'short_answer':
return answer.value.length > 1 ? (
<dd>
<ChipList values={answer.value} />
<ChipList
values={answer.value.map((v) =>
formatShortAnswerValue(v, answer.format),
)}
/>
</dd>
) : (
<dd className="text-sm break-words">{answer.value[0]}</dd>
<dd className="text-sm break-words">
{formatShortAnswerValue(answer.value[0] ?? '', answer.format)}
</dd>
);
default: {
const exhaustiveCheck: never = answer.type;
Expand Down
8 changes: 4 additions & 4 deletions docs/WORKFLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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."**
Expand Down Expand Up @@ -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 <date>" (or "Draft · last saved <date>", 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 <date>" (or "Draft · last saved <date>", 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 <date>" 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.
Expand Down Expand Up @@ -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 "<snapshotted name> (<current name>)".
Expand Down
38 changes: 38 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ export type ApplicationReviewAnswer = {
questionLabel: string;
value: string[];
type: QuestionType;
format: ShortAnswerFormat | null;
isGlobal: boolean;
};

Expand Down
8 changes: 5 additions & 3 deletions prisma/actions/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
isAllowedApplicationStatusTransition,
isApplicantEditableApplicationStatus,
matchesShortAnswerFormat,
normalizeShortAnswerValue,
} from '@/lib/constants';
import {
type DecisionEmailRecipient,
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading