fix(backend): stop showing the clinician null, NaN and the wrong failure

Two ways a voice failure described itself wrongly.

describe() built the quoted-back text from fields that are all nullable on
the wire, and toVoiceIntent casts rather than checks — so a half-classified
deadline rendered as “null null” — not a usable date, and an offset with no
amount as “+NaN day”. Blank is already handled by the sheet; it now falls
back to that.

The DTO's constraints resolved to unrelated codes: maxLength fell through
to VALIDATION_FIELD_REQUIRED, so an oversized recording said a field was
missing, and isIn maps to VALIDATION_LANGUAGE_INVALID, so an unsupported
container said the language was invalid. Both now name their own code —
the validation factory already returns a message verbatim when it is itself
a known ErrorCode, so this needs no change to the shared mapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 05:04:30 +08:00
parent 6a4c0cb1bb
commit 3d64414dd3
7 changed files with 72 additions and 6 deletions

View File

@@ -77,16 +77,36 @@ function unresolved(spoken: string): DueResolution {
return { dueDate: null, unresolved: { spoken, reason: 'invalid_date' } };
}
/**
* What to quote back when a deadline could not be resolved.
*
* Every field here is nullable on the wire and `toVoiceIntent` casts rather than checks,
* so a half-classified deadline arrives with nulls in it. The review sheet renders this
* verbatim — `"null null" — not a usable date` in front of a clinician is worse than the
* reason on its own, which the sheet already handles for a blank string.
*/
function describe(intent: DueIntent): string {
const usable = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
switch (intent?.kind) {
case 'weekday':
return `${intent.which} ${intent.weekday}`;
// `which` is legitimately null (it means "this"), the weekday is not.
return [intent.which, intent.weekday]
.filter((part) => typeof part === 'string')
.join(' ');
case 'offset':
return `+${intent.amount} ${intent.unit}`;
return usable(intent.amount)
? `+${intent.amount} ${intent.unit ?? ''}`.trim()
: '';
case 'jalali':
return `${intent.jy}/${intent.jm}/${intent.jd}`;
return [intent.jy, intent.jm, intent.jd].every(usable)
? `${intent.jy}/${intent.jm}/${intent.jd}`
: '';
case 'gregorian':
return `${intent.y}-${intent.m}-${intent.d}`;
return [intent.y, intent.m, intent.d].every(usable)
? `${intent.y}-${intent.m}-${intent.d}`
: '';
default: {
// Reaching here means an unrecognised `kind`, which resolveDueDate has already
// established is a string — echo it so the review row names what was heard.