Project: doctolib-doctolib · Branch: master · Mode: ` · **Model:**claude-sonnet-4-5-20250929`
Stats: 1 user messages, 15 tool calls, tools used: Read, Grep, Glob.
Conversation
Turn 1 — User
Based on the exploration findings, I need to design a fix for the document viewing issue after import.
Problem Summary:
When UPLOAD_DOCUMENT_WRITE feature switch is enabled:
1. Documents are uploaded successfully via /doctor_app/v2/documents (POST)
2. User immediately tries to view the document
3. Viewing fails because React Query cache is stale
4. After 5 minutes (cache expiry) or logout, viewing works
Root Cause:
The useDocumentsCreate() mutation in /engines/documents/app/assets/javascripts/queries/documents/doctor_app/documents.ts (lines 443-551) has NO onSuccess handler to invalidate cache.
In contrast, useDocumentsUpdate() (lines 415-427 in same file) properly invalidates:
- buildDocumentsShowQueryKey(props.documentId)
- Patient and appointment document indices
Cache Configuration:
DOCUMENT_STALE_TIME = 5 * 60 * 1000(5 minutes)DOCUMENT_CACHE_TIME = 5 * 60 * 1000(5 minutes)- Query key:
buildDecryptedDocumentQueryKey(documentId)→['doctor_app', 'decrypted_documents_url', documentId]
Design Requirements:
- Add proper cache invalidation to
useDocumentsCreate()mutation - Invalidate the
decryptedDocumentsquery for the newly created document - Invalidate related document list queries (patient, appointment, unclassified)
- Follow the same pattern as
useDocumentsUpdate()for consistency - Preserve the existing 5-minute cache times (don't remove caching completely)
- Ensure it works with both V1 and V2 API endpoints
Files to Modify:
/engines/documents/app/assets/javascripts/queries/documents/doctor_app/documents.ts(add onSuccess to useDocumentsCreate)- Potentially
/engines/documents/app/assets/javascripts/queries/documents/doctor_app/decryptedDocuments.ts(if we need helper functions)
Please design:
1. What queries should be invalidated after document creation
2. How to structure the onSuccess handler
3. Whether we need any helper functions
4. Any edge cases to consider
Turn 1 — Assistant
I'll design a comprehensive solution to fix the document viewing issue after import. Let me explore the relevant code to understand the complete cache invalidation pattern.
Tools used:
Read:/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/queries/documents/doctor_app/documents.ts
Tools used:
Read:/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/queries/documents/doctor_app/decryptedDocuments.ts
Tools used:
Grep:buildDocumentsIndexQueryKeyin/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts
Tool results:
→ result (ok): 1→/* 2→ * @file 3→ * @deprecated This file is deprecated and will be removed in future releases. 4→ * Please use the new implementation in packages/@doctolib/documents-ui/src/data/queries/documents.ts. 5→ * This refactor happens because of the react query migration to v5. 6→ * Feel free to move endpoints to this new file. 7→ / 8→ 9→import path from 'path' 10→import { useContext } from 'react' 11→import type { DoctorAppDocument } from '@doctolib/f …(truncated, 22922 more chars)
Tool results:
→ result (ok): 1→import type { AccountContextType } from '@doctolib/shared_context' 2→import { AccountContext } from '@doctolib/shared_context' 3→import type { DecryptFile } from 'engines/e2ee/public' 4→import { TankerContext } from 'engines/e2ee/public' 5→import { useQuery, useQueryClient } from '@tanstack/react-query' 6→import { handleEncryptedDocument } from 'components/shared/documents/utils' 7→import { HttpClientContext } from 'contexts/ReactQueryProvider' 8→import { use …(truncated, 5373 more chars)
Tool results:
→ result (ok): engines/documents/app/assets/javascripts/queries/documents/doctor_app/documents.ts:168:export const buildDocumentsIndexQueryKey = (query?: DocumentsIndexQuery) => engines/documents/app/assets/javascripts/queries/documents/doctor_app/documents.ts:214: queryKey: buildDocumentsIndexQueryKey(query), engines/documents/app/assets/javascripts/queries/documents/doctor_app/documents.ts:224: const queryKey = buildDocumentsIndexQueryKey() engines/documents/app/assets/javascripts/queries/documents/docto …(truncated, 276 more chars)
Now let me look at the patient and appointment document invalidation helpers that are already being used.
Tools used:
Read:/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/queries/documents/doctor_app/patients/documents.ts
Tools used:
Read:/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/queries/documents/doctor_app/appointments/documents.ts
Tool results:
→ result (ok): 1→import { useContext } from 'react' 2→import { useQuery, useQueryClient, type UseQueryOptions } from '@tanstack/react-query-v5' 3→import { decodeOrNull } from '@doctolib/security' 4→import { isFeatureEnabled } from '@doctolib/variation_management' 5→import { HttpClientContext } from 'monolith_DEPRECATED/contexts/ReactQueryProvider' 6→import type { HttpClientType } from 'monolith_DEPRECATED/types/http-client' 7→import useDocumentDecryption from 'monolith_DEPRECATED/ …(truncated, 3436 more chars)
Tool results:
→ result (ok): 1→import { useContext } from 'react' 2→import { useQuery, useQueryClient, type UseQueryOptions } from '@tanstack/react-query-v5' 3→import { decodeOrNull } from '@doctolib/security' 4→import { isFeatureEnabled } from '@doctolib/variation_management' 5→import { HttpClientContext } from 'monolith_DEPRECATED/contexts/ReactQueryProvider' 6→import type { HttpClientType } from 'monolith_DEPRECATED/types/http-client' 7→import useDocumentDecryption from 'monolith_DEPRECATED/ …(truncated, 3964 more chars)
Now let me search for where document creation happens to understand how patientId and appointmentId are passed through.
Tools used:
Grep:useDocumentsCreatein/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts
Tool results:
→ result (ok): Found 2 files limit: 10, offset: 0 engines/documents/app/assets/javascripts/modules/DocumentEditModule/useDocumentEditModuleController.tsx engines/documents/app/assets/javascripts/queries/documents/doctor_app/documents.ts
Tools used:
Read:/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/modules/DocumentEditModule/useDocumentEditModuleController.tsx
Tool results:
→ result (ok): 1→import type { ShareWith } from '@doctolib/deepwater_core/public' 2→import { Button } from '@doctolib/design-system/Button' 3→import { useObservationsCreateFromMail } from '@doctolib/ehr_observations_sdk/public/queries/observations' 4→import { useTrackingsCreate } from '@doctolib/ehr_observations_sdk/public/queries/trackings' 5→import { MedicalObservationCategory } from '@doctolib/ehr_observations_sdk/public/types/MedicalObservationCategory' 6→import { mapDeprecatedObse …(truncated, 26535 more chars)
Now let me look at where useDocumentEdit is called to understand how document creation/update works.
Tools used:
Read:/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/hooks/doctor/shared/useDocumentEdit/index.tsx(1–300)
Tool results:
→ result (ERROR): <tool_use_error>File does not exist. Did you mean index.ts?</tool_use_error>
Tools used:
Glob:**/useDocumentEdit/index.ts*in/Users/USER/workspace/doctolib/doctolib/engines/documents
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/hooks/doctor/shared/useDocumentEdit/index.ts
Tools used:
Read:/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/hooks/doctor/shared/useDocumentEdit/index.ts
Tool results:
→ result (ok): 1→import useDocumentEdit from './useDocumentEdit' 2→ 3→export default useDocumentEdit 4→
<system-reminder> Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior. </system-reminder>
Tools used:
Read:/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/hooks/doctor/shared/useDocumentEdit/useDocumentEdit.ts(1–400)
Tool results:
→ result (ok): 1→import { useCallback, useContext, useState } from 'react' 2→import i18n from 'services/i18n' 3→import { ALWAYS_SHOW } from 'monolith_DEPRECATED/components/base/Toast' 4→import { capturePro } from '@doctolib/product-event-wrapper' 5→import { NetworkSignatureError } from 'engines/e_signature/components/shared/helpers' 6→import ModalStackContext from 'contexts/doctor/desktop/ModalStackContext' 7→import type { ShareWith } from '@doctolib/deepwater_core/public' 8→ …(truncated, 16237 more chars)
Tools used:
Read:/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/hooks/doctor/shared/useDocumentEdit/useDocumentEdit.ts(400–600)
Tool results:
→ result (ok): 400→ if (!document.id && document.originating_email_address) {
401→ try {
402→ await addMailDataToBodyRequest({
403→ document,
404→ attributes,
405→ ocr_result,
406→ body,
407→ buildEncryptedFormData,
408→ featureSwitchOverride,
409→ })
410→ } catch (error) {
411→ cancelSubmission({
412→ cause: UpdateDocumentErrorCause.ADD_MAIL_DATA_TO_BODY_REQUEST,
413→
…(truncated, 2657 more chars)
Perfect! Now I have a complete understanding. Let me check how the FormData body includes patient_id and appointment_id to know what to invalidate.
Tools used:
Grep:patient_id.*body\.append|body\.append.*patient_idin/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/hooks/doctor/shared/useDocumentEdit
Tool results:
→ result (ok): engines/documents/app/assets/javascripts/hooks/doctor/shared/useDocumentEdit/helpers.ts:321: if (attributes.patient && !attributes.patient_id) body.append('document[patient_id]', attributes.patient.id) engines/documents/app/assets/javascripts/hooks/doctor/shared/useDocumentEdit/helpers.ts:336: if (document.patient_id) body.append('document[patient_id]', document.patient_id) engines/documents/app/assets/javascripts/hooks/doctor/shared/useDocumentEdit/helpers.ts:350: if (document.patient_ …(truncated, 221 more chars)
Tools used:
Grep:appointment_id.*body\.append|body\.append.*appointment_idin/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/hooks/doctor/shared/useDocumentEdit
Tool results:
→ result (ok): engines/documents/app/assets/javascripts/hooks/doctor/shared/useDocumentEdit/helpers.ts:346: if (appointment?.id !== undefined) body.append('appointment_id', appointment.id)
Excellent! Now let me look at the helpers.ts file to understand the exact structure.
Tools used:
Read:/Users/USER/workspace/doctolib/doctolib/engines/documents/app/assets/javascripts/hooks/doctor/shared/useDocumentEdit/helpers.ts(310–360)
Tool results:
→ result (ok): 310→ if (attributes.kind) { 311→ sessionStorage.setItem(getDocumentKindSessionKey(), attributes.kind) 312→ } 313→ 314→ if (isMspExternalDocumentFlagEnabled) { 315→ body.append('document[external]', String(Boolean(attributes.external))) 316→ } 317→ 318→ const extension = document.filename?.includes('.') ? document.filename.split('.').pop() : null 319→ if (extension) body.append('document[extension]', extension) 320→ 321→ if (attributes.patient && !att …(truncated, 2415 more chars)
Perfect! Now I have a complete understanding of the issue and the solution. Let me create a comprehensive implementation plan.
Implementation Plan: Fix Document Viewing After Import
Problem Analysis
Root Cause: The useDocumentsCreate() mutation successfully uploads documents via the V2 endpoint (/doctor_app/v2/documents) but lacks an onSuccess handler to invalidate React Query caches. This causes:
- Document show cache (
decryptedDocuments) to remain stale - the newly created documentId doesn't exist in cache - Document list caches (patient, appointment, unclassified) to show outdated data without the new document
- Users must wait 5 minutes (cache expiry time) or logout before viewing works
Why Update Works: useDocumentsUpdate() (lines 415-427) already has proper cache invalidation that serves as our reference implementation.
Solution Design
Architecture: Add an onSuccess handler to useDocumentsCreate() that mirrors the pattern in useDocumentsUpdate().
Queries to Invalidate:
-
Document Show Query -
buildDocumentsShowQueryKey(documentId)- Key:['documents', 'doctor_app', 'documents', 'show', documentId]- Purpose: Ensures viewing the newly created document works immediately -
Decrypted Document Query -
buildDecryptedDocumentQueryKey(documentId)
- Key:['doctor_app', 'decrypted_documents_url', documentId]- Purpose: Ensures encrypted documents can be decrypted and viewed -
Patient Documents Index -
buildDocumentsIndexQueryKey({ patient_id })- Key:['documents', 'doctor_app', 'documents', 'index', { patient_id, ... }]- Purpose: Document lists in patient context show new document - Also invalidate:useInvalidatePatientDocuments(patientId) -
Appointment Documents Index -
buildDocumentsIndexQueryKey({ appointment_id })- Purpose: Document lists in appointment context show new document - Also invalidate:useInvalidateAppointmentDocuments() -
Global Documents Index -
buildDocumentsIndexQueryKey()(no params) - Purpose: Unclassified documents view shows new document
Data Flow Analysis:
From useDocumentEdit/helpers.ts (lines 321-351), the FormData includes:
- document[patient_id] - always present (from attributes.patient.id or document.patient_id)
- patient_id - present for unattached documents (line 350-351)
- appointment_id - present when document.appointment_id exists (line 346)
The mutation response returns:
{ document: { id: string } }
Implementation Strategy:
// In useDocumentsCreate(), add onSuccess handler after mutationFn
export const useDocumentsCreate = () => {
const buildEncryptedFormData = useEncryptedDocumentFormDataBuilder()
const queryClient = useQueryClient()
const invalidatePatientDocuments = useInvalidatePatientDocuments()
const invalidateAppointmentDocuments = useInvalidateAppointmentDocuments()
return useMutation<DocumentsCreateData, HttpError, DocumentsCreateVariables>({
mutationFn: async (variables) => {
// ... existing mutationFn code (lines 448-548) ...
},
onSuccess: (data, variables) => {
const documentId = data.document.id
// 1. Invalidate the show query for the new document
queryClient.invalidateQueries({ queryKey: buildDocumentsShowQueryKey(documentId) })
// 2. Invalidate the decrypted document query
queryClient.invalidateQueries({ queryKey: buildDecryptedDocumentQueryKey(documentId) })
// 3. Extract patientId from variables
let patientId: string | undefined
if (variables.encryptionEnabled) {
patientId = variables.patient_id
} else {
// For non-encrypted variables (FormData), extract patient_id
const body = variables.body as FormData | DocumentFormData
if (body instanceof FormData) {
patientId = body.get('document[patient_id]') as string || body.get('patient_id') as string
} else {
// DocumentFormData structure
patientId = body.patient_id?.toString() || body.document?.patient_id
}
}
// 4. Invalidate appointment documents (all appointments, since we don't have specific ID)
invalidateAppointmentDocuments()
// 5. Invalidate patient documents
if (patientId) {
queryClient.invalidateQueries({ queryKey: buildDocumentsIndexQueryKey({ patient_id: patientId }) })
invalidatePatientDocuments(patientId)
} else {
// Invalidate all patient documents if no specific patientId
invalidatePatientDocuments()
}
// 6. Invalidate global documents index (for unclassified documents)
queryClient.invalidateQueries({ queryKey: buildDocumentsIndexQueryKey() })
},
})
}
Edge Cases Handled:
-
Encrypted vs Non-Encrypted Documents: - Encrypted:
variables.encryptionEnabled === true, patient_id invariables.patient_id- Non-encrypted:variables.encryptionEnabled === false, patient_id invariables.body(FormData or DocumentFormData) -
FormData Structure: - Could be actual FormData object (from legacy code) - Could be typed DocumentFormData object - Patient ID could be in
document[patient_id],patient_id, orbody.document.patient_id -
Missing Patient ID: - Invalidate all patient/appointment documents if patientId cannot be extracted - Ensures new document appears even if context is unclear
-
V1 vs V2 API Compatibility: - Both endpoints return same structure:
{ document: { id } }- Solution works for both viabuildDocumentEndpoint()logic (line 509) -
Decrypted Document Cache: - Must invalidate BOTH show query AND decrypted query - Show query: encrypted document metadata - Decrypted query: decrypted blob URL for viewing
Performance Considerations:
- Selective Invalidation: Only invalidate queries that actually exist in cache (React Query handles this automatically)
- No Refetch Cascade: Using
invalidateQueriesinstead ofrefetchQueries- only refetches active queries - Minimal Network: Documents show query only refetches when user attempts to view
- Index Updates: Document lists refetch when user navigates to those views
Testing Strategy:
-
Manual Test Flow: - Enable
UPLOAD_DOCUMENT_WRITEfeature switch - Import document via secure messaging - Immediately click to view document - Verify: Document displays without 404 error - Verify: Document appears in patient document list - Verify: Document appears in appointment document list (if attached) -
Cache Inspection: - Use React Query DevTools to verify queries are invalidated - Check that
decrypted_documents_urlquery exists for new documentId - Check that document index queries are marked as stale -
Regression Testing: - Verify document update still works (uses existing
useDocumentsUpdate) - Verify V1 API still works (when feature switch disabled) - Verify encrypted documents display correctly - Verify non-encrypted documents display correctly
Implementation Checklist:
-
Import required helpers at top of file: -
buildDecryptedDocumentQueryKeyfrom./decryptedDocuments- Already have:useInvalidatePatientDocuments,useInvalidateAppointmentDocuments,buildDocumentsIndexQueryKey,buildDocumentsShowQueryKey -
Initialize hooks in
useDocumentsCreate: -const queryClient = useQueryClient()-const invalidatePatientDocuments = useInvalidatePatientDocuments()-const invalidateAppointmentDocuments = useInvalidateAppointmentDocuments() -
Add
onSuccesshandler aftermutationFn(around line 549) -
Extract patientId logic with proper type guards for FormData vs DocumentFormData
-
Call invalidation functions in correct order
Code Location: - File: `/Users/USER/workspace/doctolib/doctolib/ …(truncated, 1593 more chars)