POST /voice/extract returned 500 for any real recording. The threshold was
exactly 100 kb — Express's body-parser default — which is about 20 seconds of
audio, so the endpoint was unusable at its own 2-minute cap.
The scoped parser was registered as a path-mounted json() stacked in front of a
default one, which relied on two implicit behaviours: Express stripping the
mount path, and body-parser skipping a request another parser had already
handled. That coupling broke when the surrounding middleware order shifted, and
it broke silently — the parser was still registered, just no longer the one that
ran. Bisected by dumping the Express layer stack and confirming the raw error was
`entity.too.large` with `limit: 102400`.
Replaced with a single middleware that picks a parser by path. No mount-path
stripping, no dependence on parser ordering. Extracted to common/body-parsers.ts
so it is covered by a unit test rather than only reachable through main.ts, which
createTestingModule never executes.
The test is mutation-checked: forcing the default parser fails 2 of its 5 cases.
It also pins that the larger limit does not leak app-wide, and that a merely
similar path (/api/voice/extract/extra) does not get it.
Verified against the compiled server: 300 kb now reaches /api/voice/extract,
/api/auth/login still rejects it, and ordinary requests are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Makes the feature reachable end to end: availability is fetched alongside the
catalogs, the capture hook drives the segmented control, and confirming the
review sheet appends a new detail.
Confirm always appends — it never edits an existing detail and never calls
onAddDetail. Ticked rows land on top of the seeded defaults, so unticking the
type row leaves the appointment-purpose default rather than a blank. Lab-side
rows ride on a lab case draft keyed by the detail's *client* id, so a brand-new
unsaved detail can carry a lab, due date and per-tooth prosthesis map.
Availability comes from the API rather than a NEXT_PUBLIC_* var, since those are
baked in at build time; a failure fetching it degrades to no microphone rather
than taking the treatment tab down.
From review of this commit:
- Unticking "teeth" while leaving "prosthesis" ticked attached prosthesis rows
for teeth the detail does not contain. Nothing downstream filters them —
assertCompleteToothProsthesisMap only checks detail-teeth ⊆ map, never the
reverse — so they would have reached task generation as lab work for teeth
nobody is treating. The map is now filtered to the detail's own teeth.
- The microphone was gated on the URL locale while the server resolved
everything from req.user.language. Those diverge (a bookmarked /fa/ URL, a
language toggle whose save failed), which would transcribe Persian with an
English hint and anchor "next Thursday" to a Monday week instead of a Saturday
one — or 403 from a visibly-enabled button. The client now sends the locale the
microphone was offered in, so the gate and the request agree by construction.
Also fixed from the previous review: a civil YYYY-MM-DD date rendered a day
early west of Greenwich (parsed as UTC midnight); the missing-teeth list
hardcoded the Arabic comma for all locales; and voiceApply had no ICU plural, so
the common single-field case read "Apply 1 fields".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MediaRecorder handling and the API call live in lib/, not in ui/, so
TreatmentDetailsEditor can stay presentational and take only a `voice` prop.
Container choice is made at record time and needs no transcode: Chrome and
Android give webm/opus, Safari and iPad give mp4/aac, and the transcription
endpoint accepts both. Safari's `audio/mp4` is sent as `m4a`, the name the
vendor's container list actually uses, so iPad recordings do not fail while
Chrome works. Older Safari shipped MediaRecorder without isTypeSupported, so
that path lets the browser choose rather than refusing outright.
From review of this commit:
- The auto-stop at maxMs guaranteed a 413. The client measures the final length
after the recorder has stopped, so a recording that runs to the cap always
reports slightly over it, and the server rejected exactly the recording the
auto-stop existed to save. The server now allows a documented 2s tolerance and
the client keeps reporting the true length, so telemetry stays honest.
- getUserMedia is async, so a permission granted after unmount installed a live
stream the cleanup effect had already run past — leaving the browser's
recording indicator lit with nothing listening. Guarded with a mounted ref.
- Client-side failures are now ApiError-shaped ({code, statusCode}) rather than
bare Errors, because getUserFacingError only resolves that shape; without it
errors.VOICE_MIC_DENIED was dead in all three locales.
Cancelling aborts the request, which closes the connection and aborts the
metered vendor call server-side rather than letting it settle unseen. The level
meter is best-effort: a blocked AudioContext costs the meter, not the recording.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
ASR and extraction are separate, independently swappable roles resolved per
locale from config. All three locales point at the same OpenRouter models today
(whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the
weakest link and repointing only `fa` must not be a code change.
The model emits a deliberately flat wire shape rather than the internal
discriminated unions — strict json_schema mode has poor union support — and
toVoiceIntent narrows it. That normalizer is total: a missing or malformed
payload yields a shape the resolvers report as unresolved rather than one that
throws.
The prompt supplies catalog codes with labels in the actor's locale, so the
model matches spoken words rather than translating, and carries per-locale
tooth vocabulary. English gets an explicit warning that a bare two-digit number
is ambiguous under Universal numbering, and must not be treated as FDI unless
the speaker said so.
From review of this commit:
- only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside
valid arch/side/position used to lose the tooth entirely
- an unrecognised due kind passes through to be flagged, instead of collapsing
to null and looking like no deadline was ever spoken
- vendor error bodies stay out of the thrown message and the default log level;
a 4xx can echo the request back, transcript included
- the chat call sets provider.require_parameters so OpenRouter only routes to
endpoints that honour the JSON schema, rather than ones treating it as a hint
- an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown
provider id, instead of silently disabling the microphone everywhere
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Composes the tooth, span, prosthesis, catalog and date resolvers into the
payload the review sheet renders.
Connected spans expand: "a bridge from 14 to 16" selects 15, which was never
spoken. Overlapping spans merge into one bridge, group teeth sort along the
arch (16-15-14, and 11 beside 21 across the midline), and a span collapsing to
a single tooth degrades to a single group without losing that tooth — there is
no such thing as a one-tooth bridge. A cross-arch span is impossible and is
reported rather than guessed at.
Prosthesis expands a default across the selection then applies per-tooth
overrides, because "همه زیرکونیا، ۲۶ پیافام" is how clinicians actually speak.
Completeness is computed here so an unshippable map surfaces at review rather
than failing later at dispatch.
Everything the model names is checked against the catalog we supplied it, and
anything rejected is reported rather than dropped — a hallucinated lab id must
not look identical to "no lab was spoken", since silence and a wrong lab lead
to very different corrective actions.
Also fixed, from review of this commit:
- an empty prosthesis object no longer fabricates an "incomplete, cannot ship"
warning on a plain restoration
- an override naming a tooth outside the selection now reports
tooth_not_selected rather than malformed; the clinician was understood, the
tooth just is not on this detail
- a due object with no `kind` is treated as no deadline rather than a blank
"heard but lost" row; an unrecognised kind is still flagged, and named
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four defects found by review of the preceding commits.
"next <weekday>" was occurrence-anchored ("this" plus seven) rather than week-
anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of
+7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30.
A lab case a week late. "next" now counts from the start of the following
Saturday-start week, which also lets "this" and "next" correctly coincide —
said on a Thursday, "the coming Saturday" and "Saturday next week" are the same
day. "this" stays occurrence-anchored so it can never resolve into the past.
The other three all come from the same root cause: exported functions that are
reachable from untrusted model output must degrade, not throw or drop.
- a non-object `due` (the model emitting a bare string) was treated as "no
deadline spoken" and silently discarded; only null/undefined mean absent now,
anything else is flagged so the clinician sees something was heard and lost
- isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion
table, contradicting the module's own "degrade to null" contract; they now
return false / 0, which also makes isValidJalaliDate's day check naturally
false
- civilDateInZone passed a client-supplied zone straight to Intl, which raises
RangeError before any fallback; it now validates and backstops to UTC, so a
bad zone costs at most a day rather than a 500
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jalali conversion is arithmetic here, not inference. A model asked to turn
"۲۵ مهر" into ISO answers confidently and is often wrong, and @IsDateString()
accepts the wrong answer — so the model emits a date intent and this decides
what it means.
Deviation from the spec, deliberately: the resolver takes todayIso rather than
an IANA zone. Working in civil dates means nothing here reasons about instants.
The zone is used one level up, where civilDateInZone() derives "today" from the
actor's zone server-side — better than the spec's client-supplied date, which
the client could set arbitrarily.
Conventions pinned by tests:
- "this <weekday>" is the soonest occurrence strictly after today, so "by
Thursday" said on a Thursday means the next one; a deadline of today is
almost never what was meant. "next" adds a further week.
- month offsets clamp to the end of shorter months (31 Jan + 1 = 28/29 Feb)
- a resolved date in the past, or more than five years out, is treated as
unresolved however it was arrived at — an absolute date the model invented
can land anywhere
- no due date at all is not an error; an unparseable one is, and echoes what
was heard so the review sheet can show it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extraction model emits intents, never resolved values — no FDI codes, no
ISO dates. This adds the contract it must satisfy and the resolver that turns
spoken tooth references into FDI, so quadrant mirroring is a unit test rather
than a hope.
resolveToothIntent never guesses and never clamps: position 9, a deciduous
tooth, or a malformed shape resolve to null and are reported as unresolved with
the transcript span that produced them, so the review sheet can show the
clinician exactly which words were not understood.
Everything here parses untrusted model output, so nothing may throw:
- a non-array where a list was expected degrades like any other malformed shape
- explicit codes are trimmed, for parity with normalizeTeeth
- '51' reports as not_permanent_tooth (a real primary tooth the chart cannot
show) while '99' reports as malformed — the clinician should not be told a
deciduous tooth was heard when nothing tooth-shaped was
- unresolved items only dedupe when they carry a spoken span; without one,
collapsing them would hide a lost tooth behind a single blank review row
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Voice extraction needs quadrant mapping and adjacency server-side, and
treatment.utils.ts already held a private copy of the tooth set. Lift it into
common/fdi.ts rather than create a second source of truth; treatment.utils now
imports it, behaviour unchanged (existing suites still pass).
toFdi() is the single place the patient-right convention lives: quadrant 1 is
the patient's upper right, so upper+patient_right -> 1x, upper+patient_left ->
2x, lower+patient_left -> 3x, lower+patient_right -> 4x. Getting this backwards
mirrors every quadrant and yields a valid-looking code for the wrong tooth,
which no schema check can catch — so all four quadrants are pinned by tests,
along with out-of-range positions never being clamped and deciduous teeth being
rejected outright (the chart is permanent dentition only).
Adjacency mirrors the frontend's arch-order rule, so the midline pairs 11-21
and 41-31 count as neighbours exactly as the chart treats them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Voice extraction resolves spoken Jalali dates into ISO dates server-side, so
the backend needs the conversion the frontend already had. The resolvers live
here rather than in the frontend precisely because this half of the repo has a
test runner.
Ported from frontend/src/lib/i18n/persianCalendar.ts and verified faithful by
differential test: every day from 1900-2100 (73,414 days), zero mismatches on
conversion, leap years and month lengths.
Two deliberate divergences from the original:
- jalaliToIsoDate() returns null instead of throwing. It is fed model-supplied
values, which may be nonsense, and an invalid date must degrade to
"unresolved" rather than a 500. The year guard runs before jalaliDaysInMonth
so the throwing jalCal is unreachable from it.
- toLatinDigits() also handles the Arabic-Indic block (U+0660-U+0669), not just
Persian (U+06F0-U+06F9). ASR output can carry either, sometimes mixed with
ASCII in one transcript; the frontend version only parses keystrokes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Logical API errors throw stable codes so users see translated messages instead of a generic bad request.
Co-authored-by: Cursor <cursoragent@cursor.com>
Extend the Today summary API with treatment mix and workflow task breakdowns, and render permission-aware chart cards on the Today page using Recharts.
Co-authored-by: Cursor <cursoragent@cursor.com>
Replace hardcoded Today cards with a backend summary endpoint and composable frontend widgets filtered by org type and tab permissions.
Co-authored-by: Cursor <cursoragent@cursor.com>