feat(treatment): add voice detail entry #65

Merged
admin merged 32 commits from feat/voice-treatment-entry into master 2026-08-24 11:13:48 +03:30
4 changed files with 32 additions and 5 deletions
Showing only changes of commit 8f2d3f97ba - Show all commits

View File

@@ -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);

View File

@@ -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 {

View File

@@ -2052,6 +2052,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')));

View File

@@ -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;
}