diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 73d7385..79dfe99 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -187,6 +187,7 @@ export const ErrorCode = { // Voice treatment entry VOICE_NOT_AVAILABLE: 'VOICE_NOT_AVAILABLE', VOICE_CLIP_TOO_LONG: 'VOICE_CLIP_TOO_LONG', + VOICE_UNSUPPORTED_FORMAT: 'VOICE_UNSUPPORTED_FORMAT', VOICE_ASR_FAILED: 'VOICE_ASR_FAILED', VOICE_EXTRACT_FAILED: 'VOICE_EXTRACT_FAILED', VOICE_NOTHING_RECOGNIZED: 'VOICE_NOTHING_RECOGNIZED', diff --git a/backend/src/modules/voice/dto/voice.dto.ts b/backend/src/modules/voice/dto/voice.dto.ts index 41b3b40..18a7ca9 100644 --- a/backend/src/modules/voice/dto/voice.dto.ts +++ b/backend/src/modules/voice/dto/voice.dto.ts @@ -6,6 +6,7 @@ import { MaxLength, Min, } from 'class-validator'; +import { ErrorCode } from '../../../common/errors/error-codes'; /** Containers OpenRouter's transcription endpoint accepts, and MediaRecorder can produce. */ export const VOICE_AUDIO_FORMATS = [ @@ -32,10 +33,14 @@ export class ExtractVoiceDto { */ @IsString() @IsBase64() - @MaxLength(8_000_000) + // Both constraints name their own code. Left to the default mapping, `maxLength` falls + // through to VALIDATION_FIELD_REQUIRED and `isIn` resolves to + // VALIDATION_LANGUAGE_INVALID — so an oversized recording told the clinician a field + // was missing, and an unsupported container told them their language was invalid. + @MaxLength(8_000_000, { message: ErrorCode.VOICE_CLIP_TOO_LONG }) audio: string; - @IsIn(VOICE_AUDIO_FORMATS) + @IsIn(VOICE_AUDIO_FORMATS, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT }) format: VoiceAudioFormat; /** diff --git a/backend/src/modules/voice/due-date.resolver.spec.ts b/backend/src/modules/voice/due-date.resolver.spec.ts index 5eb5029..74b7167 100644 --- a/backend/src/modules/voice/due-date.resolver.spec.ts +++ b/backend/src/modules/voice/due-date.resolver.spec.ts @@ -346,3 +346,40 @@ describe('resolveDueDate', () => { }); }); }); + +describe('what an unresolvable deadline quotes back', () => { + // The wire shape allows nulls in every field and toVoiceIntent casts rather than + // checks, so these reach the resolver intact. The sheet renders `spoken` verbatim. + it('never puts "null" or "NaN" in front of the clinician', () => { + const bad = [ + { kind: 'weekday', weekday: null, which: null }, + { kind: 'offset', unit: null, amount: null }, + { kind: 'offset', unit: 'day', amount: Number.NaN }, + { kind: 'jalali', jy: null, jm: 7, jd: 25 }, + { kind: 'gregorian', y: 2026, m: null, d: null }, + ]; + for (const intent of bad) { + const result = resolveDueDate( + intent as unknown as DueIntent, + SATURDAY, + FA_WEEK, + ); + expect(result.dueDate).toBeNull(); + expect(result.unresolved?.spoken ?? '').not.toMatch(/null|NaN/); + } + }); + + it('still quotes a deadline it did understand the words of', () => { + const result = resolveDueDate( + { + kind: 'weekday', + weekday: 'thursday', + which: null, + } as unknown as DueIntent, + SATURDAY, + FA_WEEK, + ); + // A weekday with no "this/next" resolves, so nothing is quoted back at all. + expect(result.dueDate).not.toBeNull(); + }); +}); diff --git a/backend/src/modules/voice/due-date.resolver.ts b/backend/src/modules/voice/due-date.resolver.ts index 3411efd..47b8017 100644 --- a/backend/src/modules/voice/due-date.resolver.ts +++ b/backend/src/modules/voice/due-date.resolver.ts @@ -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. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0c76d3b..f2681c3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1267,6 +1267,7 @@ "VOICE_MIC_DENIED": "Microphone access was blocked. Allow it in your browser settings and try again.", "VOICE_NOT_AVAILABLE": "Voice entry is not available for this language yet.", "VOICE_CLIP_TOO_LONG": "That recording is too long. Please keep it under two minutes.", + "VOICE_UNSUPPORTED_FORMAT": "That recording format is not supported on this device.", "VOICE_ASR_FAILED": "Could not turn the recording into text. Please try again.", "VOICE_EXTRACT_FAILED": "Could not read the treatment details from the recording.", "VOICE_NOTHING_RECOGNIZED": "No speech was recognised. Check the microphone and try again.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index f3ec8dd..eb3357a 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -1268,6 +1268,7 @@ "VOICE_MIC_DENIED": "دسترسی به میکروفون مسدود شده است. در تنظیمات مرورگر اجازه دهید و دوباره تلاش کنید.", "VOICE_NOT_AVAILABLE": "ثبت گفتاری هنوز برای این زبان در دسترس نیست.", "VOICE_CLIP_TOO_LONG": "مدت ضبط بیش از حد است. لطفاً کمتر از دو دقیقه صحبت کنید.", + "VOICE_UNSUPPORTED_FORMAT": "قالب این ضبط پشتیبانی نمی‌شود.", "VOICE_ASR_FAILED": "تبدیل گفتار به متن انجام نشد. لطفاً دوباره تلاش کنید.", "VOICE_EXTRACT_FAILED": "اطلاعات درمان از روی گفتار استخراج نشد.", "VOICE_NOTHING_RECOGNIZED": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index bad8d12..f5f81b8 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -1267,6 +1267,7 @@ "VOICE_MIC_DENIED": "Microfoontoegang is geblokkeerd. Sta dit toe in uw browserinstellingen en probeer opnieuw.", "VOICE_NOT_AVAILABLE": "Spraakinvoer is nog niet beschikbaar voor deze taal.", "VOICE_CLIP_TOO_LONG": "Die opname is te lang. Houd het onder twee minuten.", + "VOICE_UNSUPPORTED_FORMAT": "Dit opnameformaat wordt niet ondersteund.", "VOICE_ASR_FAILED": "De opname kon niet naar tekst worden omgezet. Probeer het opnieuw.", "VOICE_EXTRACT_FAILED": "De behandelgegevens konden niet uit de opname worden gelezen.", "VOICE_NOTHING_RECOGNIZED": "Er is geen spraak herkend. Controleer de microfoon en probeer opnieuw.",