Files
HealthLog/src/lib/profile/rejected-fields.ts
T
Marc-André Bombeck 625763982b Narrow a spent email budget to the field, and record both refusals
A refused address change returned before the update was built, so every
other field in the same request went with it. The settings form posts
the whole profile at once, so somebody who had spent the budget could
not save a timezone or a height for an hour and was told it was about
email addresses. The address is now dropped and the rest of the save
lands, reported through the existing rejectedFields key with the code
`rate_limited`; the 429 is kept for a request that asked for nothing
else, where a partial success would be a lie.

The comment claiming an audit row beside each question was inverted:
both refusals returned before the row is written, so only the answers
that told the caller nothing were being recorded. The two answers worth
detecting write their own rows now.

The stored address is normalised before the "has it changed" comparison.
Registration writes it as typed, so an account created with capitals did
not match its own address and paid for its first ordinary save.
2026-09-11 14:36:58 +02:00

118 lines
5.0 KiB
TypeScript

/**
* 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<string, string> = {
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<string, string> = 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;
}
/**
* The sentence a person reads under the input, keyed by the validator
* code the server sent back. The code is the only reason that crosses
* the wire — `message` is the validator's own prose and is deliberately
* never shown — so each code gets one plain sentence saying what was
* wrong with the value, in the person's language.
*
* A code with no entry here falls back to `other`, which says the field
* was not accepted without inventing a reason. New Zod codes therefore
* degrade to something honest instead of to silence.
*/
const REJECTION_REASON_KEYS: Record<string, string> = {
// Not a Zod code: the profile helper refuses an email-address change that
// has spent the account's hourly budget this way, so the rest of the save
// still lands. Without an entry it would fall back to "check it and try
// again", which is wrong — the value was fine, the timing was not.
rate_limited: "settings.profileRejection.rateLimited",
too_big: "settings.profileRejection.tooBig",
too_small: "settings.profileRejection.tooSmall",
invalid_value: "settings.profileRejection.invalidValue",
invalid_format: "settings.profileRejection.invalidFormat",
invalid_type: "settings.profileRejection.invalidType",
};
const REJECTION_FALLBACK_KEY = "settings.profileRejection.other";
/**
* Turn a rejection list into one sentence per field, keyed by the
* schema path so a form can drop each one into the slot under the
* input it belongs to.
*
* Every refused field is carried, not just the first: a submission with
* two bad values that only names one sends the person round the loop
* twice. Where a field is refused for several reasons at once the first
* one wins — the others are restatements of the same wrong value.
*
* No label map is needed: the sentence sits under the input, which
* already carries the label, and the key is the schema path so the
* calling screen can place it without a rename. A path the screen has
* no input for still gets an entry rather than being dropped, so the
* caller can decide what to do with it.
*/
export function describeRejectedProfileFields(
fields: RejectedProfileField[] | undefined,
t: (key: string, params?: Record<string, string | number>) => string,
): Record<string, string> {
const out: Record<string, string> = {};
for (const field of fields ?? []) {
if (out[field.path]) continue;
const key = REJECTION_REASON_KEYS[field.code] ?? REJECTION_FALLBACK_KEY;
out[field.path] = t(key);
}
return out;
}