diff --git a/backend/src/common/body-parsers.spec.ts b/backend/src/common/body-parsers.spec.ts index d64b859..e54019b 100644 --- a/backend/src/common/body-parsers.spec.ts +++ b/backend/src/common/body-parsers.spec.ts @@ -93,6 +93,9 @@ describe('createJsonBodyParser', () => { '/api/voice/extract/extra', '/api/voice', '/voice/extract', + // Express ignores one trailing slash, not two — this one never routes, so it must + // not get the large parser either. + '/api/voice/extract//', ]) { const res = await request(buildApp()).post(path).send(bodyOfKb(300)); expect(res.status).toBe(413); diff --git a/backend/src/common/body-parsers.ts b/backend/src/common/body-parsers.ts index 97278da..9f40162 100644 --- a/backend/src/common/body-parsers.ts +++ b/backend/src/common/body-parsers.ts @@ -31,7 +31,10 @@ export const VOICE_BODY_LIMIT = '10mb'; * recording — a failure that looks like a broken microphone, not a routing detail. */ function isVoiceExtractPath(path: string): boolean { - return path.toLowerCase().replace(/\/+$/, '') === VOICE_EXTRACT_PATH; + // Exactly one trailing slash, because that is exactly what Express ignores. Stripping + // every trailing slash would hand the 10 MB parser to `/api/voice/extract//`, which + // buffers the body and then 404s — memory spent on a request that never routes. + return path.toLowerCase().replace(/\/$/, '') === VOICE_EXTRACT_PATH; } export function createJsonBodyParser(): RequestHandler { diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 0aca70e..c50978a 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -2006,6 +2006,14 @@ export function TreatmentWorkspace({ void (async () => { try { const saved = await persistDraft({ force: true }); + // persistDraft returns a *preview* treatment rather than saving when the + // details are not persistable — one blank detail, the kind the workspace opens + // with, is enough. A preview's detail id falls back to the client id, so + // posting lab cases against it would send the server an id it has never seen + // and fail the whole save. Check what came back, not the precondition, so this + // holds for every early return persistDraft has. + const savedDetail = saved.details.find((d) => d.clientId === detail.clientId); + if (!savedDetail?.id || savedDetail.id === detail.clientId) return; await persistLabCases(saved, updatedLabCases); } catch (error: unknown) { showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments'))); diff --git a/frontend/src/lib/voice/useVoiceCapture.ts b/frontend/src/lib/voice/useVoiceCapture.ts index f17a74e..7882db3 100644 --- a/frontend/src/lib/voice/useVoiceCapture.ts +++ b/frontend/src/lib/voice/useVoiceCapture.ts @@ -139,8 +139,16 @@ export function useVoiceCapture({ ); const stop = useCallback(() => { + // No recorder means nothing will fire `onstop`, so nothing else will move the phase. + // Optional-chaining into a no-op here left the bar recording forever with a running + // timer, and only Cancel could get out of it. + if (!recorderRef.current) { + teardown(); + setPhase('idle'); + return; + } try { - recorderRef.current?.stop(); + recorderRef.current.stop(); } catch { teardown(); setPhase('idle'); @@ -150,7 +158,9 @@ export function useVoiceCapture({ const onStart = useCallback(() => { if (phase !== 'idle' || startingRef.current) return; if (!isMediaRecorderSupported()) { - onError(clientError('VOICE_MIC_DENIED')); + // Not a permission problem: this browser cannot record at all. Saying "microphone + // denied" sends the clinician to hunt for a permission nothing ever asked for. + onError(clientError('VOICE_UNSUPPORTED_FORMAT')); return; } @@ -177,8 +187,9 @@ export function useVoiceCapture({ const mimeType = pickRecordingMimeType(); if (mimeType === null) { + // The browser records, but in no container the transcription API accepts. stream.getTracks().forEach((track) => track.stop()); - onError(clientError('VOICE_MIC_DENIED')); + onError(clientError('VOICE_UNSUPPORTED_FORMAT')); return; } @@ -226,7 +237,9 @@ export function useVoiceCapture({ // recording indicator stays lit until the workspace unmounts. teardown(); setPhase('idle'); - onError(clientError('VOICE_MIC_DENIED')); + // The permission was already granted by this point — what failed is the recorder + // itself, so this is "this browser cannot record", not "you denied the mic". + onError(clientError('VOICE_UNSUPPORTED_FORMAT')); } finally { startingRef.current = false; }