Compare commits
2 Commits
e48eeb18c4
...
9ec23d0a58
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ec23d0a58 | |||
| 5d8393c1eb |
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -482,6 +482,12 @@ that justified this whole design.
|
||||
- any row carrying an unresolved item or an incomplete prosthesis map.
|
||||
- Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the
|
||||
clinician can see what the system did not understand.
|
||||
- **The sheet is a contract: confirm fills exactly what it previewed — no more.** Per-detail
|
||||
conveniences that would top the case up afterwards are suppressed for a voice-created
|
||||
case; concretely, the dispatch panel's remembered-prosthesis default
|
||||
(`previewConfirmedCaseIds`). A default that quietly adds a prosthesis type to a tooth the
|
||||
sheet never mentioned turns the confirmation step into a lie about what it was going to
|
||||
do, which is the whole reason the step exists.
|
||||
- An item that carries `candidates` renders them as **tappable chips** — the one place the
|
||||
sheet is interactive. Picking one folds the tooth into the result (`withChosenTeeth`) and
|
||||
ticks the teeth row, so an under-specified tooth is one tap from resolved instead of a
|
||||
|
||||
@@ -28,6 +28,9 @@ import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
|
||||
|
||||
/** Stable empty default — a fresh Set literal would re-fire the auto-fill effect. */
|
||||
const EMPTY_CASE_IDS: ReadonlySet<string> = new Set<string>();
|
||||
|
||||
interface LabCasesDispatchPanelProps {
|
||||
details: TreatmentDetailDraft[];
|
||||
activeDetailId: string;
|
||||
@@ -41,6 +44,12 @@ interface LabCasesDispatchPanelProps {
|
||||
onLabCaseMarkedRead?: (labCaseId: string) => void;
|
||||
onLabCaseActivityChange?: () => void;
|
||||
activeLabCaseId: string | null;
|
||||
/**
|
||||
* Lab cases whose contents the clinician already confirmed in a preview (voice entry).
|
||||
* The remembered-prosthesis default is suppressed for these: the preview is a contract,
|
||||
* and filling teeth it never showed would break it.
|
||||
*/
|
||||
previewConfirmedCaseIds?: ReadonlySet<string>;
|
||||
onLabCasesChange: (labCases: LabCaseDraft[]) => void;
|
||||
disabled: boolean;
|
||||
canEdit: boolean;
|
||||
@@ -104,6 +113,11 @@ function isProsthesisMapComplete(
|
||||
);
|
||||
}
|
||||
|
||||
/** Identity of one prosthesis entry: a tooth on a detail. */
|
||||
function toothKey(detailClientId: string, tooth: string): string {
|
||||
return `${detailClientId}::${tooth}`;
|
||||
}
|
||||
|
||||
function toothProsthesisForRows(
|
||||
rows: ProsthesisGroupRow[],
|
||||
prosthesisTypeCode: string,
|
||||
@@ -131,6 +145,7 @@ export function LabCasesDispatchPanel({
|
||||
onLabCaseMarkedRead,
|
||||
onLabCaseActivityChange,
|
||||
activeLabCaseId,
|
||||
previewConfirmedCaseIds = EMPTY_CASE_IDS,
|
||||
onLabCasesChange,
|
||||
disabled,
|
||||
canEdit,
|
||||
@@ -206,6 +221,21 @@ export function LabCasesDispatchPanel({
|
||||
};
|
||||
}, [activeLabCase?.destinationOrganizationId]);
|
||||
|
||||
useEffect(() => {
|
||||
// The invariant the old wipe was protecting, enforced where it can actually be
|
||||
// checked: no code survives that the destination lab does not offer. Guarded on a
|
||||
// non-empty catalogue because the fetch above falls back to [] on failure, and a
|
||||
// failed request must not erase the clinician's work.
|
||||
if (!activeLabCase || sent || prosthesisOptions.length === 0) return;
|
||||
const offered = new Set(prosthesisOptions.map((opt) => opt.code));
|
||||
const kept = activeLabCase.toothProsthesis.filter((tp) =>
|
||||
offered.has(tp.prosthesisTypeCode),
|
||||
);
|
||||
if (kept.length === activeLabCase.toothProsthesis.length) return;
|
||||
updateActiveLabCase({ toothProsthesis: kept });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [prosthesisOptions, activeLabCase?.clientId, sent]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingComment('');
|
||||
}, [activeLabCase?.clientId]);
|
||||
@@ -233,12 +263,21 @@ export function LabCasesDispatchPanel({
|
||||
? activeLinkedOrganizations.find((o) => o.id === lastLabId)
|
||||
: undefined;
|
||||
if (!lastLab) return;
|
||||
updateActiveLabCase({ destinationOrganizationId: lastLab.id, toothProsthesis: [] });
|
||||
// Deliberately does NOT clear toothProsthesis. This runs only when no lab is set, so
|
||||
// there is no other lab's catalogue for a code to have come from — the map is simply
|
||||
// unvalidated, not foreign. Clearing it here threw away a whole voice-dictated
|
||||
// prosthesis map before it was ever rendered. Validation happens against the
|
||||
// catalogue below, once it loads; a deliberate lab *switch* still clears.
|
||||
updateActiveLabCase({ destinationOrganizationId: lastLab.id });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeLabCase?.clientId, clinicOrganizationId, sent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLabCase || sent) return;
|
||||
// A case whose contents the clinician already confirmed in a preview is left exactly
|
||||
// as previewed. Adding a remembered default to teeth the preview never mentioned
|
||||
// would make the confirmation step a lie about what it was going to fill.
|
||||
if (previewConfirmedCaseIds.has(activeLabCase.clientId)) return;
|
||||
if (!activeLabCase.destinationOrganizationId) return;
|
||||
if (prosthesisOptions.length === 0 || prosthesisRows.length === 0) return;
|
||||
const fillKey = `${activeLabCase.clientId}:${prosthesisRows.length}`;
|
||||
@@ -252,14 +291,39 @@ export function LabCasesDispatchPanel({
|
||||
activeLabCase.destinationOrganizationId,
|
||||
);
|
||||
if (!lastCode || !prosthesisOptions.some((opt) => opt.code === lastCode)) return;
|
||||
|
||||
// Fill the blanks. This used to rebuild the whole map from `lastCode`, so one row
|
||||
// reading as missing overwrote every type the clinician (or a dictation) had already
|
||||
// set — a convenience default quietly destroying real choices.
|
||||
const typed = new Set(
|
||||
activeLabCase.toothProsthesis
|
||||
.filter((tp) => tp.prosthesisTypeCode)
|
||||
.map((tp) => toothKey(tp.detailClientId, tp.tooth)),
|
||||
);
|
||||
const blanks = toothProsthesisForRows(prosthesisRows, lastCode).filter(
|
||||
(entry) => !typed.has(toothKey(entry.detailClientId, entry.tooth)),
|
||||
);
|
||||
if (blanks.length === 0) {
|
||||
autoFilledCaseRef.current = fillKey;
|
||||
setApplyAllProsthesis(lastCode);
|
||||
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, lastCode) });
|
||||
return;
|
||||
}
|
||||
|
||||
autoFilledCaseRef.current = fillKey;
|
||||
// Only claim "all teeth" when the fill really did cover all of them; otherwise the
|
||||
// bulk select shows one type while the rows below disagree with it.
|
||||
if (blanks.length === flatToothCount) setApplyAllProsthesis(lastCode);
|
||||
updateActiveLabCase({
|
||||
toothProsthesis: [
|
||||
...activeLabCase.toothProsthesis.filter((tp) => tp.prosthesisTypeCode),
|
||||
...blanks,
|
||||
],
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
activeLabCase?.clientId,
|
||||
activeLabCase?.destinationOrganizationId,
|
||||
clinicOrganizationId,
|
||||
previewConfirmedCaseIds,
|
||||
prosthesisOptions,
|
||||
prosthesisRows.length,
|
||||
sent,
|
||||
|
||||
@@ -417,6 +417,16 @@ export function TreatmentWorkspace({
|
||||
|
||||
const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]);
|
||||
const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]);
|
||||
/**
|
||||
* Lab cases created by confirming a voice result.
|
||||
*
|
||||
* Kept here rather than as a field on LabCaseDraft on purpose: a draft field is dropped
|
||||
* by mapLabCaseDraftFromApi on the first server round-trip, which is exactly the window
|
||||
* where the dispatch panel's remembered-prosthesis default would fire.
|
||||
*/
|
||||
const [voiceConfirmedLabCaseIds, setVoiceConfirmedLabCaseIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set<string>(),
|
||||
);
|
||||
const [activeDetailId, setActiveDetailId] = useState<string>(() => details[0].clientId);
|
||||
const [activeLabCaseId, setActiveLabCaseId] = useState<string | null>(null);
|
||||
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
|
||||
@@ -1985,6 +1995,9 @@ export function TreatmentWorkspace({
|
||||
}
|
||||
const updatedLabCases = [...labCaseDrafts, draft];
|
||||
setLabCaseDrafts(updatedLabCases);
|
||||
// The sheet already showed the clinician exactly what this case would contain, so
|
||||
// the dispatch panel must not top it up with a remembered default afterwards.
|
||||
setVoiceConfirmedLabCaseIds((prev) => new Set(prev).add(draft.clientId));
|
||||
|
||||
// Every other path that creates a lab draft persists it immediately, and the
|
||||
// autosave effect only watches `details`. Left in state alone, the destination
|
||||
@@ -1993,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')));
|
||||
@@ -2745,6 +2766,7 @@ export function TreatmentWorkspace({
|
||||
}
|
||||
}}
|
||||
activeLabCaseId={activeLabCaseId}
|
||||
previewConfirmedCaseIds={voiceConfirmedLabCaseIds}
|
||||
onLabCasesChange={handleLabCasesChange}
|
||||
disabled={!canEditTreatmentForDay}
|
||||
canEdit={canEdit}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user