Clinical Notes — Visit Notes with Audio Transcription
Audiences: doctor, developer
Clinical notes in Dudoxx HMS are FHIR R4
DocumentReferenceresources that store structured SOAP-style visit notes (chief complaint, diagnosis, treatment plan) as base64-encoded HTML in FHIR content attachments, with file attachments and MinIO-backed audio transcriptions linked as additional content entries.
Business Purpose
Documenting a clinical encounter is the most time-intensive part of a doctor's workflow — studies show clinicians spend 1–2 hours per day on documentation after patient hours. Dudoxx HMS reduces this through:
- Structured note templates: SOAP, progress, referral, and discharge notes with typed fields (chief complaint, diagnosis, treatment plan), reducing free-text entry.
- AI-assisted transcription: Voice recordings captured during the visit are transcribed via the on-premise STT engine and linked as attachments to the note, enabling retrospective review.
- Encounter-linked retrieval: Notes are linked to FHIR Encounters (visits), so the full clinical narrative for any appointment is retrievable in one query.
- FHIR-native storage: Notes stored as
DocumentReferenceare shareable with referral networks, EHR systems, and patient portals using standard FHIR queries.
Audiences
- Investor: Structured clinical documentation is a key differentiator — most German EMRs still use unstructured free-text or PDF uploads. AI transcription + structured SOAP notes demonstrate a workflow advantage that reduces documentation burden, a top pain point for clinicians.
- Clinical buyer (doctor/nurse/receptionist): Doctors create a note during or after each visit, filling structured fields. Audio recordings from the voice assistant are automatically linked. Notes are searchable by note type and filterable by author. Receptionists can list all notes for their organization.
- Developer/partner: REST at
GET|POST|PATCH|DELETE /api/v1/clinical-notes. Notes are identified by FHIR DocumentReference numeric IDs. Encounter linking usescontext.encounterFHIR field — pass visit UUID; the service resolves to FHIR Encounter ID. Attachments are referenced by MinIO file IDs (minio://) or existing DocumentReference IDs. - Internal (ops/support):
ClinicalNotesServiceimplements a FHIR SoT feature flag (featureFlagtable, name =FHIR_SOT_FLAG_NAME). When the flag is ON, afhirIndexPrisma row tracks each DocumentReference. Parity logs (msg: 'fhir-parity') are emitted on every create/update/delete — use these to audit migration progress.
Architecture
ClinicalNotesController (/api/v1/clinical-notes)
└── ClinicalNotesService
├── IFhirClient (FHIR_CLIENT) ← DocumentReference CRUD
├── PatientIdResolverService ← UUID → FHIR Patient ID
├── PractitionerIdMapper ← UUID → FHIR Practitioner ID
├── OrganizationResolverService ← slug → UUID for fhirIndex
└── PrismaService
├── featureFlag ← FHIR SoT flag + rollout %
└── fhirIndex ← bookkeeping when flag ON
DocumentReference structure:
type.coding[0] = LOINC note type code (e.g. '34131-3' for encounter note)
category[0] = { system: 'http://dudoxx.com/...', code: 'clinical-note' }
subject.reference = Patient/{fhirPatientId}
content[0] = HTML attachment (base64) — chief complaint, notes, diagnosis, treatment plan
content[1..n] = file attachments (minio:// or DocumentReference/ URLs)
context.encounter = Encounter/{fhirEncounterId}
extension[] = structured fields as FHIR extensions (chief-complaint, diagnosis, treatment-plan, title, content)
The FHIR SoT flag enables a gradual migration from FHIR-only storage to FHIR + Prisma fhirIndex bookkeeping. Flag OFF: plain FHIR write. Flag ON: FHIR write first, then fhirIndex.create() / upsert() / delete(). Either path emits a structured fhir-parity log line.
See coding_context/ddx-hms-context.md §Clinical Data for the full module map.
Tech Stack & Choices
| Layer | Technology | Rationale |
|---|---|---|
| API | NestJS 11, @Controller('clinical-notes') | Uniform REST with audit decorators (@AuditMeta) on create/update/delete |
| FHIR resource | DocumentReference (R4) | Standard HL7 type for clinical documents; supports structured attachments, encounter context, and category tagging |
| Note types | LOINC system codes | NOTE_TYPE_DISPLAY map translates codes to human labels (SOAP, progress, referral, discharge) |
| Content encoding | Base64 HTML in content[0].attachment.data | FHIR-portable; embedded content survives export without external URL resolution |
| Attachments | MinIO file IDs (minio://) or DocumentReference refs | Files stored in MinIO via VisitAttachmentService; referenced by URL in content entries |
| SoT migration | Feature flag FHIR_SOT_FLAG_NAME + fhirIndex Prisma table | Percentage-based rollout; fail-closed (defaults OFF on error) |
| Audit | @AuditMeta on create/update/delete | Structured audit rows in ddx_api_log schema |
| Bulk operations | POST /clinical-notes/bulk — DELETE, ARCHIVE, STATUS_UPDATE | Promise.allSettled() — partial success reported per-ID |
Key design decision: Structured note fields (chief complaint, diagnosis, treatment plan) are stored BOTH in the base64 HTML attachment (for display) AND in FHIR extensions (for programmatic access). mergeUpdates() rebuilds the HTML attachment from the extensions on every update, keeping them in sync.
Data Flow
Doctor creates a visit note
Business outcome: Doctor dictates a SOAP note and links it to the current visit; within 2 seconds, the note appears on the patient chart.
Technical mechanism:
POST /api/v1/clinical-noteswith{ patientId, noteType, content, chiefComplaint, diagnosis, treatmentPlan, encounterId, attachments[] }andX-Clinic-ID.ClinicalNotesController.create()(clinical-notes.controller.ts:138) auto-fillspractitionerIdfromuser.fhirPractitionerIdif not provided.ClinicalNotesService.create()(clinical-notes.service.ts:244) resolves org UUID, buildsDocumentReferencepayload viabuildDocumentReferenceForDto():patientIdResolver.resolveFhirPatientId(patientId)→ FHIR Patient numeric IDresolveEncounterId(encounterId)→ FHIR Encounter numeric ID (fromprisma.visit.fhirEncounterId)practitionerIdMapper.userUuidToFhirId(practitionerId)→ FHIR Practitioner ID
- Flag check:
isFhirSotEnabled(orgUuid)— routes tofhirFirstCreate()(flag ON) orlegacyFhirOnlyCreate()(flag OFF). fhirClient.create('DocumentReference', ref, clinicSlug)writes to HAPI.- If flag ON:
prisma.fhirIndex.create(...)adds bookkeeping row. logParity({ op: 'create', flagState, success, fhirId, versionId })— emitted regardless of path.
Doctor finds notes for a patient
GET /api/v1/clinical-notes/patient/:patientId → findByPatient() (clinical-notes.service.ts:385) → FHIR search DocumentReference?subject=Patient/{fhirId}&category=clinical-note&_sort=-date → for each note, resolves practitioner name via fhirClient.read('Practitioner', fhirPractitionerId, orgId).
Find notes linked to an encounter
GET /api/v1/clinical-notes/encounter/:encounterId → findByEncounter() (clinical-notes.service.ts:447) → resolves visit UUID to FHIR Encounter integer ID → FHIR search DocumentReference?encounter=Encounter/{fhirEncounterId}. Critical: HAPI indexes by FHIR integer ID; searching with a UUID reference returns empty.
Bulk archive
POST /api/v1/clinical-notes/bulk with { action: 'ARCHIVE', ids: [...] } → bulkOperation() (clinical-notes.service.ts:577) → Promise.allSettled(ids.map(id => fhirClient.update(..., { status: 'superseded' }))) → returns { succeeded: [], failed: [], successCount, failureCount }.
Implicated Code
ddx-api/src/clinical-data/clinical-notes/clinical-notes.controller.ts:39—ClinicalNotesController—@ResourceMetadefinition: list/get/create/update/delete/count/search onnoteTypeandauthorIdfieldsddx-api/src/clinical-data/clinical-notes/clinical-notes.controller.ts:96—bulkOperation()—POST /clinical-notes/bulk— DELETE, ARCHIVE, STATUS_UPDATE in one callddx-api/src/clinical-data/clinical-notes/clinical-notes.controller.ts:138—create()— auto-fillspractitionerIdfrom gateway user if missingddx-api/src/clinical-data/clinical-notes/clinical-notes.controller.ts:188—findByPatient()—GET /clinical-notes/patient/:patientId— resolves practitioner names per-noteddx-api/src/clinical-data/clinical-notes/clinical-notes.controller.ts:224—findByEncounter()—GET /clinical-notes/encounter/:encounterId— UUID→FHIR Encounter ID resolution requiredddx-api/src/clinical-data/clinical-notes/clinical-notes.service.ts:129—ClinicalNotesService— injectsFHIR_CLIENT,PatientIdResolverService,PractitionerIdMapper,PrismaService,OrganizationResolverServiceddx-api/src/clinical-data/clinical-notes/clinical-notes.service.ts:148—isFhirSotEnabled()— percentage-based feature flag lookup; fail-closed (returns false on DB error)ddx-api/src/clinical-data/clinical-notes/clinical-notes.service.ts:244—create()— flag-gated: routes tofhirFirstCreate()orlegacyFhirOnlyCreate()ddx-api/src/clinical-data/clinical-notes/clinical-notes.service.ts:447—findByEncounter()— resolves UUID → FHIR Encounter ID before HAPI search (prevents empty results)ddx-api/src/clinical-data/clinical-notes/clinical-notes.service.ts:577—bulkOperation()—Promise.allSettled()for partial-success bulk opsddx-api/src/clinical-data/clinical-notes/clinical-notes.service.ts:692—buildDocumentReference()— builds FHIR DocumentReference from DTO, encoding structured fields in both HTML content and FHIR extensionsddx-api/src/clinical-data/clinical-blobs/— clinical blob storage (large binary clinical data linked to notes)ddx-api/src/clinical/visit-attachments/— attachment management for visit-scoped files (images, PDFs linked to notes)ddx-api/src/voice-video/recordings/— audio recordings and transcriptions; linked to notes viaminio://attachment references
Operational Notes
- Encounter ID resolution is mandatory: Passing a visit UUID to the FHIR search
encounterparam returns empty results — HAPI indexes by its own numeric Encounter ID.resolveEncounterId()handles this by readingprisma.visit.fhirEncounterId. If the Prisma visit row has nofhirEncounterId, the UUID is passed through unchanged (HAPI will not match anything). - Practitioner ID drift: The
authorfield in DocumentReference is stored asPractitioner/{practitionerId}. If the practitioner ID was stored as a Postgres UUID instead of the FHIR numeric ID (pre-CR-021),findByPatient()attempts to remap it viapractitionerIdMapper.userUuidToFhirId(). Missing mapping → null practitioner name (not an error). - FHIR SoT flag is fail-closed: If the
featureFlagDB lookup fails,isFhirSotEnabled()returnsfalse, falling back to the legacy path. This prevents a DB outage from blocking note creation. - HTML rebuild on update: When
content,chiefComplaint,diagnosis, ortreatmentPlanchanges,mergeUpdates()callsrebuildHtmlContent()to regenerate the base64 HTML content[0] attachment from the current extensions. Failing to do this creates a split: extensions hold new data but the rendered HTML shows stale content. - Bulk ARCHIVE semantics:
ARCHIVEsets FHIRstatus: 'superseded'(not deleted). Resources remain in HAPI and are retrievable by direct ID lookup but excluded from activestatus=currentsearches. - Category field required for list:
findAll()searchesDocumentReference?category=clinical-note. Notes created without the Dudoxx category code will not appear in list results but will appear infindByPatient()which also applies category filtering.
Related Topics
- Clinical Visits — Notes link to FHIR Encounters;
fhirEncounterIdon the Prisma visit row drives encounter-based search - HAPI FHIR R4 Server — DocumentReference CRUD, partition routing, search index behavior
- Patients — Registration and Demographics — Patient FHIR IDs used as
subjectin DocumentReference - Medical Cards — Patient Health Summary — Notes accessible via
GET /visits/:id/agent-contextfor AI context injection