diff --git a/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts new file mode 100644 index 000000000..198ed664f --- /dev/null +++ b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts @@ -0,0 +1,186 @@ +/** + * The onboarding baseline step used to submit the profile and throw the + * server's answer away. A height the server declined vanished between + * the input and the next screen, and the person walked off believing it + * was stored. + * + * These tests run the real assembly end to end — build the body, PUT it + * over a stubbed `fetch`, read the reply — because the gap was never in + * either end on its own. They use the real English and German bundles, + * so a sentence that reads as a key or leaks validator prose fails here. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getCachedMessages } from "@/lib/i18n/load-locale"; +import { resolveKey } from "@/lib/i18n/resolve-key"; +import { + baselineFieldLabelKeys, + buildBaselineProfileBody, + describeBaselineSaveOutcome, + putBaselineProfile, +} from "../baseline-form-utils"; + +/** A `t` backed by the shipped bundle — no stand-in strings. */ +function translator(locale: "en" | "de") { + const bundle = getCachedMessages(locale); + if (!bundle) throw new Error(`No cached bundle for ${locale}`); + return (key: string, params?: Record) => { + const raw = resolveKey(bundle as Record, key); + if (raw === undefined) return key; + return Object.entries(params ?? {}).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + raw, + ); + }; +} + +const FILLED_FORM = { + displayName: "Robin", + dateOfBirth: "1988-04-02", + gender: "OTHER", +}; + +/** Stub `fetch` with one canned response and capture what was sent. */ +function stubProfilePut(status: number, body: unknown) { + const calls: Array<{ url: string; init: RequestInit }> = []; + vi.stubGlobal("fetch", (url: string, init: RequestInit) => { + calls.push({ url, init }); + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); + }); + return calls; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("onboarding baseline profile save", () => { + it("names the rejected field and still lets the accepted ones stand", async () => { + const calls = stubProfilePut(200, { + data: { + id: "u1", + displayName: "Robin", + rejectedFields: [{ path: "heightCm", code: "too_big" }], + }, + error: null, + }); + + const body = buildBaselineProfileBody(FILLED_FORM, 940); + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(body), + translator("en"), + baselineFieldLabelKeys(false), + ); + + // Everything the person filled in travelled to the server; nothing + // was withheld because one sibling was going to be refused. + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("/api/auth/profile"); + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + displayName: "Robin", + heightCm: 940, + dateOfBirth: "1988-04-02", + gender: "OTHER", + }); + + // The accepted fields are saved and the wizard moves on... + expect(outcome.advance).toBe(true); + // ...but the declined one is said out loud, by the name this screen + // put next to the input. + expect(outcome.notice).toEqual({ + tone: "warning", + message: "Profile saved, but Height (cm) could not be updated.", + }); + }); + + it("says nothing when every field was accepted", async () => { + stubProfilePut(200, { data: { id: "u1" }, error: null }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)), + translator("en"), + baselineFieldLabelKeys(false), + ); + + expect(outcome).toEqual({ advance: true, notice: null }); + }); + + it("names the blocking field and holds the step when nothing was saved", async () => { + stubProfilePut(422, { + data: null, + error: 'Nothing was saved. Fix the "dateOfBirth" field and try again.', + meta: { errorCode: "profile.update.nothingSaved" }, + details: { issues: [{ path: "dateOfBirth", code: "invalid_value" }] }, + }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, null)), + translator("en"), + baselineFieldLabelKeys(false), + ); + + expect(outcome.advance).toBe(false); + expect(outcome.notice?.tone).toBe("error"); + expect(outcome.notice?.message).toBe( + "Nothing was saved. Fix Date of birth and try again.", + ); + // The server's own sentence carries a schema key in quotes; it must + // not be what the person reads. + expect(outcome.notice?.message).not.toContain("dateOfBirth"); + }); + + it("speaks the person's language, not the validator's", async () => { + stubProfilePut(200, { + data: { rejectedFields: [{ path: "gender", code: "invalid_value" }] }, + error: null, + }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)), + translator("de"), + baselineFieldLabelKeys(false), + ); + + const de = translator("de"); + expect(outcome.notice?.message).toBe( + de("settings.profilePartiallySaved", { + field: de("onboarding.baseline.genderLabel"), + }), + ); + expect(outcome.notice?.message).not.toContain("settings."); + expect(outcome.notice?.message).not.toContain("invalid_value"); + }); + + it("uses the feet-and-inches label when that is what the person read", () => { + const outcome = describeBaselineSaveOutcome( + { + ok: true, + body: { data: { rejectedFields: [{ path: "heightCm", code: "x" }] } }, + }, + translator("en"), + baselineFieldLabelKeys(true), + ); + expect(outcome.notice?.message).toContain("Height (ft, in)"); + }); + + it("falls back to the step's own sentence for a failure with no field detail", async () => { + stubProfilePut(500, { data: null, error: "boom" }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)), + translator("en"), + baselineFieldLabelKeys(false), + ); + + expect(outcome.advance).toBe(false); + expect(outcome.notice?.message).toBe( + "Something went wrong while finishing setup. Please try again.", + ); + expect(outcome.notice?.message).not.toContain("boom"); + }); +}); diff --git a/src/components/onboarding/baseline-form-utils.ts b/src/components/onboarding/baseline-form-utils.ts new file mode 100644 index 000000000..634d5ffc9 --- /dev/null +++ b/src/components/onboarding/baseline-form-utils.ts @@ -0,0 +1,173 @@ +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { + describeRejectedProfileField, + type RejectedProfileField, +} from "@/lib/profile/rejected-fields"; +import { toProfileSex } from "@/lib/profile/sex"; + +/** + * Reading the answer to the onboarding baseline step's profile write. + * + * `PUT /api/auth/profile` is field-independent: it writes everything + * that validated and reports the rest back rather than throwing the + * whole submission away. Until now this step ignored that report, so a + * height the server declined disappeared between the input and the next + * screen with nothing said, and the person walked away believing it was + * stored. + * + * The settings account form already answers this question (see + * `handleSaveProfile` in `src/components/settings/account-section`): + * name the field in the person's own words, keep validator prose off + * the screen, and only claim a clean save when there is one. The two + * screens share the field-naming helper and the two sentences, so the + * same rejection reads the same wherever it happens; only the labels + * differ, because each screen calls the fields what its own inputs + * call them. + */ + +/** The parts of the profile response this step reads. */ +export interface BaselineProfileResponse { + ok: boolean; + /** Parsed envelope, or `null` when the body was absent or unreadable. */ + body: { + data?: { rejectedFields?: RejectedProfileField[] } | null; + meta?: { errorCode?: string } | null; + details?: { issues?: RejectedProfileField[] } | null; + } | null; +} + +export interface BaselineSaveOutcome { + /** Whether the wizard may move on to the next step. */ + advance: boolean; + /** A ready-to-show sentence, already localized. Null on a clean save. */ + notice: { tone: "warning" | "error"; message: string } | null; +} + +/** + * Collect the fields the person actually filled in. An untouched field + * is left out entirely rather than sent as an empty value, so skipping + * the step never clears something already stored. Height arrives + * already converted to canonical centimetres — the wire stays cm + * whatever unit the control was showing. + */ +export function buildBaselineProfileBody( + form: { displayName: string; dateOfBirth: string; gender: string }, + heightCm: number | null, +): Record { + const body: Record = {}; + if (form.displayName.trim()) body.displayName = form.displayName.trim(); + if (heightCm !== null) body.heightCm = heightCm; + if (form.dateOfBirth) body.dateOfBirth = form.dateOfBirth; + // Every value the profile stores travels; the guard drops only the + // "prefer not to say" placeholder. It used to list two of the three + // by hand, so a third-value answer vanished on submit without a word. + const gender = toProfileSex(form.gender); + if (gender) body.gender = gender; + return body; +} + +/** + * Write the collected fields and hand back what the server said. + * + * A raw fetch rather than `apiPut`, because the answer this step needs + * lives in the body on both sides of the `ok` line: `rejectedFields` on + * a partial success, `details.issues` when nothing landed. The throwing + * wrapper would collapse both into one opaque failure. + */ +export async function putBaselineProfile( + body: Record, +): Promise { + const res = await apiFetchRaw("/api/auth/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const parsed = (await res.json().catch(() => null)) as + BaselineProfileResponse["body"] | null; + return { ok: res.ok, body: parsed }; +} + +/** + * The labels the baseline step puts next to its own inputs, keyed by the + * schema field the server rejects. Height carries two labels because the + * control follows the unit preference, so the caller passes the one the + * person actually read. + */ +export function baselineFieldLabelKeys(usesFeetInches: boolean) { + return { + displayName: "onboarding.baseline.displayNameLabel", + heightCm: usesFeetInches + ? "onboarding.baseline.heightLabelFtIn" + : "onboarding.baseline.heightLabel", + dateOfBirth: "onboarding.baseline.dateOfBirthLabel", + gender: "onboarding.baseline.genderLabel", + }; +} + +/** + * Decide what the person is told and whether the wizard continues. + * + * - Some fields declined, the rest written: say which one did not make + * it and carry on. The accepted values are already stored; stopping + * here would only make the person re-enter what already saved. + * - Nothing written: name the field that blocked it and stay put, so + * there is something specific to fix instead of a shrug. + * - Any other failure: fall back to the step's own generic sentence. + * The server's `error` string is never shown — the stable + * `meta.errorCode` is resolved through the locale catalog instead, so + * no validator wording can reach the screen. + */ +export function describeBaselineSaveOutcome( + res: BaselineProfileResponse, + t: (key: string, params?: Record) => string, + labelKeys: Record, +): BaselineSaveOutcome { + if (res.ok) { + const rejected = res.body?.data?.rejectedFields; + const field = describeRejectedProfileField(rejected, t, labelKeys); + if (!field) return { advance: true, notice: null }; + return { + advance: true, + notice: { + tone: "warning", + message: t("settings.profilePartiallySaved", { field }), + }, + }; + } + + const errorCode = res.body?.meta?.errorCode; + const blockingField = describeRejectedProfileField( + res.body?.details?.issues, + t, + labelKeys, + ); + + if (errorCode === "profile.update.nothingSaved" && blockingField) { + return { + advance: false, + notice: { + tone: "error", + message: t("settings.profileNothingSaved", { field: blockingField }), + }, + }; + } + + return { + advance: false, + notice: { tone: "error", message: localizedCode(errorCode, t) }, + }; +} + +/** Resolve a stable error code through the catalog, or fall back to the + * step's own sentence when the code is absent or has no entry. */ +function localizedCode( + errorCode: string | undefined, + t: (key: string, params?: Record) => string, +): string { + if (errorCode) { + const key = `apiErrors.${errorCode}`; + const translated = t(key); + if (translated !== key) return translated; + } + return t("onboarding.errorGeneric"); +} diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index f905c1ca1..6ab2f7a17 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -29,12 +29,17 @@ import { useTranslations } from "@/lib/i18n/context"; import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; import { queryKeys } from "@/lib/query-keys"; -import { toProfileSex } from "@/lib/profile/sex"; import { AnamnesisCard, buildAnamnesisAboutMeBody, type AnamnesisValue, } from "@/components/onboarding/anamnesis-card"; +import { + baselineFieldLabelKeys, + buildBaselineProfileBody, + describeBaselineSaveOutcome, + putBaselineProfile, +} from "@/components/onboarding/baseline-form-utils"; /** * v1.4.25 W14b-Content — onboarding step 3 (baseline). @@ -49,7 +54,12 @@ import { * path (see `applyProfileUpdate` in `src/lib/auth/profile-update.ts`). * * Submit flow on "Save and continue": - * 1. PUT profile (best-effort — empty fields are skipped). + * 1. PUT profile (empty fields are skipped). The write is + * field-independent, so the answer is read rather than assumed: + * a field the server declined is named on screen and the rest + * still counts as saved; a submission where nothing landed keeps + * the person on this step with the blocking field named. See + * `baseline-form-utils.ts`. * 2. PUT /api/coach/about-me — only when the optional anamnesis card * (v1.17.1) was filled; preserves any existing `aboutMe` and * writes conditions / allergies encrypted at rest. @@ -134,19 +144,28 @@ export function BaselineForm() { setSaving(true); try { if (opts.saveProfile) { - const profileBody: Record = {}; - if (form.displayName.trim()) - profileBody.displayName = form.displayName.trim(); - const heightCm = heightAdapter.toCanonicalCm(form.height); - if (heightCm !== null) profileBody.heightCm = heightCm; - if (form.dateOfBirth) profileBody.dateOfBirth = form.dateOfBirth; - // Every value the profile stores travels; the guard drops only the - // "prefer not to say" placeholder. It used to list two of the three - // by hand, so a third-value answer vanished on submit without a word. - const gender = toProfileSex(form.gender); - if (gender) profileBody.gender = gender; + const profileBody = buildBaselineProfileBody( + form, + heightAdapter.toCanonicalCm(form.height), + ); if (Object.keys(profileBody).length > 0) { - await apiPut("/api/auth/profile", profileBody); + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(profileBody), + t, + baselineFieldLabelKeys(heightAdapter.usesFeetInches), + ); + if (outcome.notice) { + const show = + outcome.notice.tone === "warning" ? toast.warning : toast.error; + show(outcome.notice.message); + } + if (!outcome.advance) { + // Nothing was written. Staying on the step is the point — + // the person has a named field to fix and the values they + // typed are still in front of them. + setSaving(false); + return; + } } // Anamnesis — only write when the user actually filled a field diff --git a/src/components/settings/account-section/__tests__/account-section-utils.test.ts b/src/components/settings/account-section/__tests__/account-section-utils.test.ts index d1ef5b814..304d46848 100644 --- a/src/components/settings/account-section/__tests__/account-section-utils.test.ts +++ b/src/components/settings/account-section/__tests__/account-section-utils.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect } from "vitest"; -import { - describeRejectedProfileField, - resolveInitialTimezone, -} from "../account-section-utils"; +import { describeRejectedProfileField } from "@/lib/profile/rejected-fields"; +import { resolveInitialTimezone } from "../account-section-utils"; describe("describeRejectedProfileField", () => { const t = (key: string) => `translated:${key}`; diff --git a/src/components/settings/account-section/account-section-utils.ts b/src/components/settings/account-section/account-section-utils.ts index a2a455267..d37ab9683 100644 --- a/src/components/settings/account-section/account-section-utils.ts +++ b/src/components/settings/account-section/account-section-utils.ts @@ -61,44 +61,9 @@ export function statusText( return "key" in msg ? t(msg.key, msg.params) : msg.text; } -/** A single rejected-field entry from `applyProfileUpdate`'s wire shape - * (`details.issues` on failure, `data.rejectedFields` on a partial - * success) — `path` is the schema field name, never shown verbatim. */ -export interface RejectedProfileField { - path: string; - code: string; - message?: string; -} - /** - * Maps a rejected field's schema `path` to the same i18n label already - * shown next to that input on this screen. The server only ever knows - * the field by its schema key (`heightCm`, `gender`, ...) — naming it - * in a person-facing sentence is the client's job, using labels that - * already exist and are already localized for this form. + * Naming a rejected profile field is shared with the onboarding + * baseline step — see `src/lib/profile/rejected-fields.ts`. This screen + * uses the default label map, which points at the labels these inputs + * already carry. */ -const PROFILE_FIELD_LABEL_KEYS: Record = { - email: "auth.email", - heightCm: "settings.height", - dateOfBirth: "settings.dateOfBirth", - gender: "settings.gender", - fullName: "settings.identity.fullName", - insurerName: "settings.identity.insurer", - insuranceNumber: "settings.identity.insuranceNumber", -}; - -/** - * Renders the first rejected field's label, falling back to its raw - * schema key for a field this screen has no input for (defensive — - * every field `applyProfileUpdate` accepts from this form is mapped - * above). - */ -export function describeRejectedProfileField( - fields: RejectedProfileField[] | undefined, - t: (key: string) => string, -): string | null { - const first = fields?.[0]; - if (!first) return null; - const labelKey = PROFILE_FIELD_LABEL_KEYS[first.path]; - return labelKey ? t(labelKey) : first.path; -} diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 4caf5bbb5..4cb6e4f89 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -39,9 +39,11 @@ import { detectBrowserTimezone } from "@/lib/tz/format"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { describeRejectedProfileField, + type RejectedProfileField, +} from "@/lib/profile/rejected-fields"; +import { resolveInitialTimezone, statusText, - type RejectedProfileField, type StatusMessage, } from "./account-section-utils"; import { AvatarSection } from "./avatar-section"; diff --git a/src/lib/profile/rejected-fields.ts b/src/lib/profile/rejected-fields.ts new file mode 100644 index 000000000..12cd82a20 --- /dev/null +++ b/src/lib/profile/rejected-fields.ts @@ -0,0 +1,62 @@ +/** + * Naming the profile fields a save declined. + * + * `applyProfileUpdate` (`src/lib/auth/profile-update.ts`) writes every + * field that validated and reports the rest back: `data.rejectedFields` + * on a partial success, `details.issues` when nothing could be written + * at all. Both carry the schema key (`heightCm`, `gender`, ...) and a + * machine-readable code — never a sentence a person should read. + * + * Turning that key into words is the client's job, and every screen + * that writes the profile has to do it the same way, or the same + * rejection reads differently depending on where the person happened + * to be standing. This module is that one way: the settings account + * form and the onboarding baseline step both resolve the key through + * it, each passing the labels its own inputs already carry. + */ + +/** A single rejected-field entry from `applyProfileUpdate`'s wire shape + * (`details.issues` on failure, `data.rejectedFields` on a partial + * success) — `path` is the schema field name, never shown verbatim. */ +export interface RejectedProfileField { + path: string; + code: string; + message?: string; +} + +/** + * Maps a rejected field's schema `path` to the same i18n label already + * shown next to that input on the settings account form. The server + * only ever knows the field by its schema key — naming it in a + * person-facing sentence is the client's job, using labels that already + * exist and are already localized for that form. + * + * A screen with different labels for the same fields (the onboarding + * baseline step calls them by its own names) passes its own map to + * `describeRejectedProfileField` instead. + */ +export const PROFILE_FIELD_LABEL_KEYS: Record = { + email: "auth.email", + heightCm: "settings.height", + dateOfBirth: "settings.dateOfBirth", + gender: "settings.gender", + fullName: "settings.identity.fullName", + insurerName: "settings.identity.insurer", + insuranceNumber: "settings.identity.insuranceNumber", +}; + +/** + * Renders the first rejected field's label, falling back to its raw + * schema key for a field the calling screen has no input for + * (defensive — every field a form submits is expected in its map). + */ +export function describeRejectedProfileField( + fields: RejectedProfileField[] | undefined, + t: (key: string) => string, + labelKeys: Record = PROFILE_FIELD_LABEL_KEYS, +): string | null { + const first = fields?.[0]; + if (!first) return null; + const labelKey = labelKeys[first.path]; + return labelKey ? t(labelKey) : first.path; +}