feat(backend): voice extraction endpoint

POST /voice/extract behind JwtAuthGuard + ClinicOrgGuard, plus
GET /voice/availability so the frontend can decide whether to render the
microphone — it cannot learn that from NEXT_PUBLIC_*, which are baked in at
build time.

Audio is held in memory for the request only: never written to disk, never a
Prisma row. The transcript goes back to the client and is not persisted. What
is logged is structured and patient-free — clip length, which fields resolved,
unresolved count, vendor cost, outcome — with log lines as the interim sink
until this repo has metrics infrastructure.

On extraction failure the transcript still travels back in the error details,
so the words the clinician already paid for can be salvaged into a note.

v1 ships ungated beyond a configured locale profile; the Plan.features design
is deferred, not dropped.

From review of this commit, four of which were load-bearing:

- Express's 100 kb default body limit rejected any recording past ~20 seconds,
  making the endpoint unusable at its own 2-minute cap. Body parsers are now
  registered explicitly with a 10 MB limit scoped to the voice route only.
  Verified empirically: 600 KB reaches /api/voice/extract, while /api/auth/login
  still 413s.
- ThrottlerGuard keys on req.ip, so behind nginx the whole deployment would
  share one bucket and an abuser rotating IPs would bypass it. VoiceThrottlerGuard
  keys on the user id instead — with no plan gate, this is the only control on
  metered vendor spend.
- ThrottlerException had no 429 fallback and surfaced as INTERNAL_ERROR; the
  guard now throws VOICE_RATE_LIMITED directly.
- durationMs was optional, so omitting it bypassed VOICE_MAX_RECORDING_MS
  entirely. It is required.
- VOICE_UNSUPPORTED_FORMAT was dead code — the DTO's @IsIn already rejects
  unknown containers — so it is gone rather than left unreachable.

ThrottlerModule is deliberately not bound as a global APP_GUARD: a global
ThrottlerGuard rate-limits every route against every named throttler, which
would have capped the whole API at the voice limit.

All seven remaining VOICE_* codes have errors.* keys in en, fa and nl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 18:27:14 +03:30
parent 336fc76035
commit db9d7d280a
12 changed files with 610 additions and 16 deletions

View File

@@ -1240,6 +1240,13 @@
"LAB_CASE_START_INCOMPLETE": "Complete teeth and prosthesis types before starting this case.",
"LAB_CASE_CLIENT_REQUIRED": "Enter a clinic name or a patient name for this case.",
"BAD_REQUEST": "The request could not be processed.",
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later."
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later.",
"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_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.",
"VOICE_RATE_LIMITED": "Too many recordings in a short time. Please wait a moment."
}
}

View File

@@ -1241,6 +1241,13 @@
"LAB_CASE_START_INCOMPLETE": "قبل از شروع پرونده، دندان‌ها و نوع پروتز را کامل کنید.",
"LAB_CASE_CLIENT_REQUIRED": "نام کلینیک یا نام بیمار را وارد کنید.",
"BAD_REQUEST": "درخواست قابل پردازش نبود.",
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید.",
"VOICE_MIC_DENIED": "دسترسی به میکروفون مسدود شده است. در تنظیمات مرورگر اجازه دهید و دوباره تلاش کنید.",
"VOICE_NOT_AVAILABLE": "ثبت گفتاری هنوز برای این زبان در دسترس نیست.",
"VOICE_CLIP_TOO_LONG": "مدت ضبط بیش از حد است. لطفاً کمتر از دو دقیقه صحبت کنید.",
"VOICE_ASR_FAILED": "تبدیل گفتار به متن انجام نشد. لطفاً دوباره تلاش کنید.",
"VOICE_EXTRACT_FAILED": "اطلاعات درمان از روی گفتار استخراج نشد.",
"VOICE_NOTHING_RECOGNIZED": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.",
"VOICE_RATE_LIMITED": "تعداد ضبط‌ها در بازه کوتاه زیاد بود. کمی صبر کنید."
}
}
}

View File

@@ -1240,6 +1240,13 @@
"LAB_CASE_START_INCOMPLETE": "Vul tanden en prothesetypes in voordat u deze case start.",
"LAB_CASE_CLIENT_REQUIRED": "Voer een klinieknaam of een patiëntnaam in voor deze case.",
"BAD_REQUEST": "Het verzoek kon niet worden verwerkt.",
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw."
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw.",
"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_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.",
"VOICE_RATE_LIMITED": "Te veel opnames in korte tijd. Wacht even."
}
}
}